Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1117,9 +1117,25 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
|||||||
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
|
- **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
|
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).
|
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
|
||||||
|
- **FOCAS alarms (#647):** an `ALARM:` address (`*`, `<Type>`, `<Type>/<n>`, `<Type>/<lo>-<hi>`, `T<code>`, `OTHER`) +
|
||||||
|
the `alarm` object = ONE condition at the RawPath (either alone is skipped + reported). Overlapping selectors all
|
||||||
|
fire; `OTHER` = what no type selector claims. ⚠️ A failed `cnc_rdalmmsg2` THROWS (it used to return `[]` ⇒ mass
|
||||||
|
clear + un-acked re-raise) — and annotates its conditions `BadNoCommunication` once per outage (quality-only) and
|
||||||
|
degrades driver health with `LastSuccessfulRead` frozen, so an alarm-only driver trips the #527 watchdog. ⚠️ A CNC
|
||||||
|
REFUSAL (non-OK rc, transport fine) is `BadNotSupported` + one Warning and does NOT touch health — else the
|
||||||
|
watchdog reconnect-loops a production control. Health = worst of the value and alarm paths, computed together. The told
|
||||||
|
record lives on the driver INSTANCE (reconnect / in-place re-init re-raise nothing); a deploy RESPAWNS the driver,
|
||||||
|
and its Raise for a still-active alarm is a host-side re-statement (below). Type names are the 16i-era table only
|
||||||
|
(30i codes unconfirmed, not invented — use `T<code>` there). Read-only; ack server-side. `AlarmProjection.Enabled`
|
||||||
|
is a no-op. E2E: `Runtime.Tests/FocasNativeAlarmEndToEndTests` (incl. a respawn). `docs/drivers/FOCAS.md`.
|
||||||
|
- **A Raise on an already-active condition with the SAME message is a RE-STATEMENT (#647 review):** `NativeAlarmProjector` keeps its ack and
|
||||||
|
`ForwardNativeAlarm` writes no second "Activated" `/alerts` row — every native driver's told-record dies with its
|
||||||
|
instance, and a deploy respawns the driver. Only `Retrigger` (or inactive → active) un-acks, so a source whose
|
||||||
|
UPSTREAM is authoritative for the ack sends Retrigger for an unacked re-statement: OpcUaClient on first sight of a
|
||||||
|
condition (no prior told), Galaxy for an unacked active snapshot entry.
|
||||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
- **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
|
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection;
|
||||||
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||||
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
|
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
|
||||||
"tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
|
"tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
|
||||||
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
||||||
@@ -1165,7 +1181,7 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
|||||||
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
|
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
|
(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
|
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`).
|
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS routes since #647 (below).
|
||||||
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
- **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
|
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
|
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||||
|
|||||||
+10
-1
@@ -56,7 +56,16 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
|||||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||||
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
|
- **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
|
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
|
under #641), and so did FOCAS (`"{host}#{Type}:{Number}"`, fixed under #647 — one condition per `ALARM:` tag, see
|
||||||
|
`drivers/FOCAS.md`). A `Raise` on a condition already projected Active **with the same message** is a
|
||||||
|
**re-statement** (#647 review): the ack is kept and no second "Activated" row is published — a respawned driver
|
||||||
|
re-reading a still-active alarm sends exactly that. A Raise with a DIFFERENT message is a new statement and un-acks:
|
||||||
|
a respawned driver cannot tell "the same alarm" from "a different one that appeared while it was down", so the
|
||||||
|
host compares what the condition shows. Only `Retrigger` (or inactive → active, or a changed-message Raise) un-acks.
|
||||||
|
⚠️ **Residual:** the comparison is only as good as the source's message determinism. FOCAS and MTConnect messages
|
||||||
|
are deterministic for an unchanged statement; a source whose message carries volatile text (a timestamp, a
|
||||||
|
counter) would un-ack on every respawn — conservative, never hiding an alarm — and a source whose message omits
|
||||||
|
what changed (a new alarm under identical text) would keep a stale ack across a respawn. A native source must also set `Kind`: an
|
||||||
`Unspecified` kind projects no state change.
|
`Unspecified` kind projects no state change.
|
||||||
|
|
||||||
### Native condition state survives a full rebuild (Gitea #641)
|
### Native condition state survives a full rebuild (Gitea #641)
|
||||||
|
|||||||
+110
-32
@@ -90,6 +90,7 @@ on.
|
|||||||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||||||
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
||||||
|
| `ALARM:*` / `ALARM:Servo/401-453` / `ALARM:OTHER` | CNC alarm selector | One Part 9 condition per tag, with the `alarm` object — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) |
|
||||||
|
|
||||||
PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are
|
PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are
|
||||||
the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while
|
the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while
|
||||||
@@ -151,11 +152,11 @@ reference them will throw at startup with a message pointing here.
|
|||||||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
| `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` |
|
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||||||
| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
|
| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
|
||||||
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | Opt-in — set `AlarmProjection.Enabled=true`; diffs `(AlarmNumber, Type)` between ticks |
|
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | One condition per authored `ALARM:` tag at its RawPath — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) |
|
||||||
|
|
||||||
Ack is a no-op — FANUC clears alarms on its own once the underlying condition
|
Ack is a no-op — FANUC clears alarms on its own once the underlying condition
|
||||||
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
|
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
|
||||||
`BadNotSupported`.
|
`BadNotSupported`; the Part 9 acknowledge is applied server-side.
|
||||||
|
|
||||||
## Fixed node tree
|
## Fixed node tree
|
||||||
|
|
||||||
@@ -381,41 +382,116 @@ Rules worth knowing:
|
|||||||
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
|
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.
|
key edited through that form is dropped), so set it in the raw config.
|
||||||
|
|
||||||
## Alarm projection
|
## Alarm conditions (`ALARM:`, Gitea #647)
|
||||||
|
|
||||||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
A FANUC CNC exposes **one flat active-alarm list per device** (`cnc_rdalmmsg2`), not one structure per
|
||||||
on sites that don't consume CNC alarms. Opt in per driver instance:
|
alarm. The driver maps that list onto Part 9 conditions through authored raw tags: a tag whose address is an
|
||||||
|
`ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object becomes **exactly one**
|
||||||
|
condition, with `ConditionId` = the tag's RawPath (the key the host routes on). Same opt-in-per-tag shape as
|
||||||
|
MTConnect CONDITION alarms (#626).
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
"Config": {
|
// raw tag TagConfig
|
||||||
"Devices": [ ... ],
|
{ "address": "ALARM:Servo/401-453", "dataType": "Int32",
|
||||||
"Tags": [ ... ],
|
"alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
|
||||||
"AlarmProjection": {
|
|
||||||
"Enabled": true,
|
|
||||||
"PollInterval": "00:00:02"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Every alarm transition fires `OnAlarmEvent` with:
|
| Address | Condition is active while… |
|
||||||
|
|---|---|
|
||||||
|
| `ALARM:*` | any alarm is active on the tag's device (device summary) |
|
||||||
|
| `ALARM:Servo` / `ALARM:T4` | any alarm of that type is active (a type name, or a raw type code `T<code>`) |
|
||||||
|
| `ALARM:Servo/401` | that type + number is active |
|
||||||
|
| `ALARM:Servo/401-453` | that type, number in the inclusive range |
|
||||||
|
| `ALARM:OTHER` | an alarm is active that **no type selector** on the same device claims — the "unmatched" bucket |
|
||||||
|
|
||||||
- `SourceNodeId` = the device host address (FOCAS has no per-node alarm model;
|
Type names: `Parameter`, `PulseCode`, `Overtravel`, `Overheat`, `Servo`, `DataIo`, `MemoryCheck`,
|
||||||
the CNC exposes a single flat active-alarm list per session)
|
`MacroAlarm` (case-insensitive). The grammar is `FocasAlarmSelector` in the Contracts assembly, and it is the
|
||||||
- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
|
**one authority**: the `/raw` tag modal, the address picker ("CNC alarm (condition)"), the deploy-time
|
||||||
- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
|
inspector and the driver's init all parse with it, and a malformed selector gets the same reason text from
|
||||||
`Parameter`, `MacroAlarm`)
|
the editor and the gate.
|
||||||
- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
|
|
||||||
→ `Medium`; everything else → `High`
|
|
||||||
|
|
||||||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
⚠️ **The type table is the 16i/18i/21i-era `ALM_*` numbering** (`Parameter=0, PulseCode=1, Overtravel=2,
|
||||||
so downstream consumers can ignore the clear if they only care about raises.
|
Overheat=3, Servo=4, DataIo=5, MemoryCheck=6, MacroAlarm=13`) and is the only one the driver knows. The
|
||||||
|
30i/31i/32i series number `alm_type` differently; that table has not been confirmed against a FOCAS
|
||||||
|
reference, so it is deliberately **not** encoded — a guessed code would silently route the wrong alarms.
|
||||||
|
On a 30i-series control (the Makino is a 31i-B) a type *name* may therefore select the wrong family. Use the
|
||||||
|
raw-code form there: every condition message carries the raw code (`… (type 4 Servo, axis 1)`), so observe
|
||||||
|
it, then author `ALARM:T<code>`. Number ranges are series-independent.
|
||||||
|
|
||||||
> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition
|
**Semantics.**
|
||||||
> 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
|
- **Identity is (type, number).** Cross-family number collisions exist. The axis is not part of the key: one
|
||||||
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
|
alarm number reported on two axes is one alarm, and the message lists both axes.
|
||||||
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
|
- **Overlap is intended.** One servo alarm drives `ALARM:Servo` *and* `ALARM:*`. `*` summaries and `OTHER`
|
||||||
> not one condition per tag), so it is tracked separately rather than patched.
|
tags never claim an alarm, so a summary beside `OTHER` does not starve it.
|
||||||
|
- **Displayed alarm** = the most severe matching one (by the type table), ties to the lowest (type, number) —
|
||||||
|
never wire order, so a reordered list cannot re-raise. Message:
|
||||||
|
`401 SERVO V-READY OFF (type 4 Servo, axis 1) [+2 more]`; a summary condition whose read hit the per-read
|
||||||
|
cap (32) adds `[alarm list truncated]` (and the driver warns once).
|
||||||
|
- **Transitions** (`AlarmEventArgs.Kind` — the host's projector reads nothing else):
|
||||||
|
inactive → active = `Raise`; a key **not in the matched set** appears = `Retrigger` (un-acknowledges: a new
|
||||||
|
CNC alarm is a statement the operator has not seen); the **displayed** alarm leaves while others remain =
|
||||||
|
`Retrigger` with the next one down (the MTConnect precedent); a **non-displayed** member leaves = nothing;
|
||||||
|
nothing matches = `Clear`.
|
||||||
|
- **Severity, explicitly.** An authored integer `alarm.severity` is the condition's exact Part 9 severity
|
||||||
|
(`OpcUaSeverity`; the host clamps 1..1000). Without one, the bucket comes from the displayed alarm's type:
|
||||||
|
Overtravel / Servo / PulseCode → Critical (900), Parameter / MacroAlarm → Medium (500), everything else
|
||||||
|
including unknown codes → High (700). On a 30i-series control, author a severity rather than rely on the
|
||||||
|
16i-era table. Note the AdminUI native-alarm editor always writes a `severity`, so a UI-authored tag always
|
||||||
|
uses its authored value; the type table applies to an `alarm` object that carries none (CSV import, API).
|
||||||
|
- **Unmatched alarms** — active, but selected by no `*`, `OTHER` or type tag on the device — are logged once
|
||||||
|
each at Information.
|
||||||
|
|
||||||
|
**Delivery and failure.**
|
||||||
|
|
||||||
|
- **A failed read is not "no alarms".** `IFocasClient.ReadAlarmsAsync` throws on a down session, a non-OK
|
||||||
|
return code, a wire fault, or a non-empty reply that fits neither the 76- nor the 80-byte record layout (an
|
||||||
|
empty reply is "no alarms"). That device is skipped for the tick and its conditions keep their Active/Acked
|
||||||
|
state. (It used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them
|
||||||
|
un-acknowledged on recovery.)
|
||||||
|
- **…but an unreachable source says so.** The first failed read of an outage annotates every subscribed condition
|
||||||
|
on that device `BadNoCommunication` through the #626 quality-only path (no state change, no `/alerts` row) and
|
||||||
|
logs ONE Warning naming `cnc_rdalmmsg2`; the next successful read lifts it to Good (before any transition it
|
||||||
|
also finds, e.g. Good then Clear for an alarm that cleared during the outage). Transport failures also reach
|
||||||
|
driver health: `Degraded` with `LastSuccessfulRead` frozen (only a successful read advances it), so a driver
|
||||||
|
whose only tags are `ALARM:` tags trips the comms-loss watchdog (#527) and the host's driver-wide #477
|
||||||
|
annotation like any other.
|
||||||
|
- **A CNC refusal is not comms loss.** A non-OK FOCAS return from `cnc_rdalmmsg2` (the CNC answered and declined,
|
||||||
|
e.g. `EW_FUNC`) marks the conditions `BadNotSupported` (quality-only, once), logs one Warning, and **leaves driver
|
||||||
|
health alone** — polling continues at the normal cadence. Treating it as comms loss would make the #527 watchdog
|
||||||
|
reconnect a production control every 90 s forever. Only a transport fault (session down, wire fault, malformed
|
||||||
|
reply) degrades.
|
||||||
|
- **Health is the worst of the value path and the alarm path, computed together** (`GetHealth`). A failing alarm
|
||||||
|
read keeps a mixed driver `Degraded` however many value reads succeed (so `/hosts` does not flip per poll), and a
|
||||||
|
good alarm read cannot mask a value-path `Degraded`. `LastSuccessfulRead` is the later of the two paths' last
|
||||||
|
successes, so the watchdog fires only when both have stopped.
|
||||||
|
- **Diffed against what the server was TOLD**, recorded only while a handler is attached. The record lives on the
|
||||||
|
driver instance: a reconnect (same-set re-subscribe) and an in-place re-initialise (Force Reconnect, #630) re-raise
|
||||||
|
nothing. **A deploy that changes the driver's config respawns it** (the planner stops + recreates the driver, a new
|
||||||
|
instance with an empty record), and its first poll sends `Raise` for every still-active alarm. The host treats a
|
||||||
|
`Raise` on a condition it already shows Active **with the same message** as a **re-statement**: the
|
||||||
|
acknowledgement is kept and no second "Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review).
|
||||||
|
The condition message is a deterministic function of the matched set (winner, type code, axes, `[+N more]`), so a
|
||||||
|
true re-statement matches exactly, while an alarm set that changed during the respawn — including one that only
|
||||||
|
LOST a member — reads as a new statement and un-acks (the conservative direction). Only `Retrigger` — or inactive →
|
||||||
|
active — un-acknowledges, which is why a genuinely new CNC alarm in a matched set is sent as `Retrigger`.
|
||||||
|
- **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a
|
||||||
|
**subscribed** alarm tag; devices without one are never polled for alarms.
|
||||||
|
- **Opt-in is the tag.** `AlarmProjection.Enabled` is retained so old configs bind, and has no effect. Before
|
||||||
|
#647 `SubscribeAlarmsAsync` threw while it was false, so an authored alarm tag on a default config stayed
|
||||||
|
gated with a Warning on every reconnect.
|
||||||
|
- **Read-only.** Nothing is ever written to the CNC. Acknowledge and AddComment are applied server-side (native
|
||||||
|
conditions support only those two methods); `AcknowledgeAsync` is a no-op success. The CNC clears an alarm
|
||||||
|
when its cause is reset.
|
||||||
|
|
||||||
|
**Authoring mistakes are skipped and reported** (the driver's skipped-tags ledger + a deploy-time advisory):
|
||||||
|
an `ALARM:` address without the `alarm` object (not a condition, and no value to read), and an `alarm` object
|
||||||
|
on a non-`ALARM:` address (a condition nothing can raise). Reading an `ALARM:` tag's RawPath through the driver
|
||||||
|
returns `BadNotSupported`; writing it, `BadNotWritable`.
|
||||||
|
|
||||||
|
**Known residuals.** Whether the alarm *list* itself flickers at a CNC reset (the status word does, one poll)
|
||||||
|
is unobserved; if the live gate shows it, a clear-debounce knob is the follow-up. Real-machine raise/clear is
|
||||||
|
only observable passively — never induce an alarm on a production control.
|
||||||
|
|
||||||
## Handle recycling
|
## Handle recycling
|
||||||
|
|
||||||
@@ -444,8 +520,10 @@ latency spike once per cadence.
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
|
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
|
||||||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
driver surface via `FakeFocasClient`. `FocasAlarmSelectorTests` pins the `ALARM:` grammar and the inspector;
|
||||||
clear diffing tests.
|
`FocasAlarmProjectionTests` the transitions, `OTHER`, failed-read-keeps-state and the told record. The
|
||||||
|
end-to-end proof that a CNC alarm reaches a condition is `FocasNativeAlarmEndToEndTests` in
|
||||||
|
`Runtime.Tests` (real driver → `DriverHostActor.ForwardNativeAlarm` → `NativeAlarmProjector`).
|
||||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||||
hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
|
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
|
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
|
||||||
@@ -465,7 +543,7 @@ latency spike once per cadence.
|
|||||||
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
|
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
|
||||||
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
|
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
|
||||||
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
|
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
|
||||||
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
|
| An `ALARM:` condition never activates | The tag lacks the `alarm` object, or an `alarm` object sits on a non-`ALARM:` address (both are skipped — check the skipped-tags report); or, on a 30i-series control, a type *name* selects the wrong 16i-era code | Pair the address with the `alarm` object; on a 30i use the raw `ALARM:T<code>` form, reading the code from an `ALARM:*` condition's message |
|
||||||
| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting |
|
| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting |
|
||||||
| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault |
|
| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault |
|
||||||
| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag |
|
| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag |
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|||||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>), plus
|
||||||
|
/// <see cref="FocasAreaKind.Alarm"/> (a selector over the CNC's active alarm list — <c>ALARM:*</c>,
|
||||||
|
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>, Gitea #647).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||||
@@ -35,11 +37,15 @@ public sealed record FocasAddress(
|
|||||||
string? PmcLetter,
|
string? PmcLetter,
|
||||||
int Number,
|
int Number,
|
||||||
int? BitIndex,
|
int? BitIndex,
|
||||||
string? FixedPath = null)
|
string? FixedPath = null,
|
||||||
|
FocasAlarmSelector? AlarmSelector = null)
|
||||||
{
|
{
|
||||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||||
public const string FixedPrefix = "FIXED:";
|
public const string FixedPrefix = "FIXED:";
|
||||||
|
|
||||||
|
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address (Gitea #647).</summary>
|
||||||
|
public const string AlarmPrefix = "ALARM:";
|
||||||
|
|
||||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||||
public string Canonical => Kind switch
|
public string Canonical => Kind switch
|
||||||
{
|
{
|
||||||
@@ -51,6 +57,7 @@ public sealed record FocasAddress(
|
|||||||
: $"PARAM:{Number}/{BitIndex}",
|
: $"PARAM:{Number}/{BitIndex}",
|
||||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||||
|
FocasAreaKind.Alarm => AlarmPrefix + AlarmSelector?.Canonical,
|
||||||
_ => $"?{Number}",
|
_ => $"?{Number}",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +69,26 @@ public sealed record FocasAddress(
|
|||||||
/// <returns>The canonical authored address string.</returns>
|
/// <returns>The canonical authored address string.</returns>
|
||||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||||
|
|
||||||
|
/// <summary>Builds an <see cref="FocasAreaKind.Alarm"/> address over <paramref name="selector"/>.</summary>
|
||||||
|
/// <param name="selector">The alarm selector.</param>
|
||||||
|
/// <returns>The address.</returns>
|
||||||
|
public static FocasAddress ForAlarm(FocasAlarmSelector selector) =>
|
||||||
|
new(FocasAreaKind.Alarm, null, 0, null, AlarmSelector: selector);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why <paramref name="value"/> is not a usable <c>ALARM:</c> address, or <see langword="null"/> when it
|
||||||
|
/// is one (or is not an <c>ALARM:</c> address at all). Shared by the AdminUI editor and the deploy-time
|
||||||
|
/// inspector so both say the same thing about the same string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The authored address.</param>
|
||||||
|
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
|
||||||
|
public static string? DescribeAlarmAddressError(string? value)
|
||||||
|
{
|
||||||
|
var src = value?.Trim() ?? "";
|
||||||
|
if (!src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
return FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..], out _, out var error) ? null : error;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||||
/// <param name="value">The address string to parse.</param>
|
/// <param name="value">The address string to parse.</param>
|
||||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||||
@@ -86,6 +113,14 @@ public sealed record FocasAddress(
|
|||||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alarm selector (Gitea #647). Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||||
|
// valid PMC letter (message display), so ALARM:… must be owned by this rule, not diagnosed as PMC.
|
||||||
|
if (src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var selector = FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..]);
|
||||||
|
return selector is null ? null : ForAlarm(selector);
|
||||||
|
}
|
||||||
|
|
||||||
// PMC path: letter + digits + optional .bit
|
// PMC path: letter + digits + optional .bit
|
||||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||||
var letter = src[0..1].ToUpperInvariant();
|
var letter = src[0..1].ToUpperInvariant();
|
||||||
@@ -175,4 +210,12 @@ public enum FocasAreaKind
|
|||||||
/// nothing in that tree is a writable location on the CNC.
|
/// nothing in that tree is a writable location on the CNC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Fixed,
|
Fixed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A selector over the device's active CNC alarm list (<c>ALARM:*</c>, <c>ALARM:Servo/401-453</c>,
|
||||||
|
/// <c>ALARM:OTHER</c>) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver
|
||||||
|
/// <c>alarm</c> object, which makes it ONE Part 9 condition at its RawPath; it has no value to read
|
||||||
|
/// and nothing to write.
|
||||||
|
/// </summary>
|
||||||
|
Alarm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects (Gitea #647).</summary>
|
||||||
|
public enum FocasAlarmSelectorKind
|
||||||
|
{
|
||||||
|
/// <summary><c>ALARM:*</c> — every active alarm on the tag's device (a device summary condition).</summary>
|
||||||
|
Any,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ALARM:OTHER</c> — active alarms on the device that no <see cref="Type"/> selector on the same
|
||||||
|
/// device matches: the "unmatched" bucket. <see cref="Any"/> and other <see cref="Other"/> tags do not
|
||||||
|
/// claim an alarm, so an <c>ALARM:*</c> summary beside it does not starve it.
|
||||||
|
/// </summary>
|
||||||
|
Other,
|
||||||
|
|
||||||
|
/// <summary><c>ALARM:Servo</c>, <c>ALARM:Servo/401</c>, <c>ALARM:Servo/401-453</c>, <c>ALARM:T4</c>.</summary>
|
||||||
|
Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The selector half of an <c>ALARM:</c> FOCAS address (Gitea #647): which entries of the CNC's flat,
|
||||||
|
/// per-device active-alarm list (<c>cnc_rdalmmsg2</c>) drive the ONE Part 9 condition the raw tag is
|
||||||
|
/// materialised as.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Grammar of the text after <c>ALARM:</c> (case-insensitive, no embedded whitespace):</para>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <listheader><term>Form</term><description>Matches</description></listheader>
|
||||||
|
/// <item><term><c>*</c></term><description>any active alarm on the device</description></item>
|
||||||
|
/// <item><term><c>OTHER</c></term><description>alarms no type selector on the device matches</description></item>
|
||||||
|
/// <item><term><c><TYPE></c></term><description>any alarm of that type</description></item>
|
||||||
|
/// <item><term><c><TYPE>/<n></c></term><description>that type and alarm number</description></item>
|
||||||
|
/// <item><term><c><TYPE>/<lo>-<hi></c></term><description>that type, number in the inclusive range</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <c><TYPE></c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
|
||||||
|
/// or a raw type code <c>T<code></c> (<c>T4</c>). The names resolve through the driver's single
|
||||||
|
/// 16i-era type table — see the ⚠️ on <see cref="FocasAlarmType"/>; the raw-code form is the
|
||||||
|
/// series-independent one and the right choice on a 30i-series control.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One authority.</b> This parser backs the AdminUI editor (through <see cref="FocasAddress.TryParse"/>),
|
||||||
|
/// the deploy-time inspector and the driver's init — an address one of them accepts, all of them accept.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Kind">The selector kind.</param>
|
||||||
|
/// <param name="TypeCode">The FOCAS alarm type code; set only for <see cref="FocasAlarmSelectorKind.Type"/>.</param>
|
||||||
|
/// <param name="TypeName">The canonical type name when the selector named one; <see langword="null"/> for a raw <c>T<code></c>.</param>
|
||||||
|
/// <param name="NumberLow">The lowest alarm number matched, or <see langword="null"/> for every number.</param>
|
||||||
|
/// <param name="NumberHigh">The highest alarm number matched (inclusive); equals <paramref name="NumberLow"/> for a single number.</param>
|
||||||
|
public sealed record FocasAlarmSelector(
|
||||||
|
FocasAlarmSelectorKind Kind,
|
||||||
|
short? TypeCode = null,
|
||||||
|
string? TypeName = null,
|
||||||
|
int? NumberLow = null,
|
||||||
|
int? NumberHigh = null)
|
||||||
|
{
|
||||||
|
/// <summary>The <c>*</c> body.</summary>
|
||||||
|
public const string AnyToken = "*";
|
||||||
|
|
||||||
|
/// <summary>The <c>OTHER</c> body.</summary>
|
||||||
|
public const string OtherToken = "OTHER";
|
||||||
|
|
||||||
|
/// <summary>Gets the canonical selector text (the part after <c>ALARM:</c>).</summary>
|
||||||
|
public string Canonical => Kind switch
|
||||||
|
{
|
||||||
|
FocasAlarmSelectorKind.Any => AnyToken,
|
||||||
|
FocasAlarmSelectorKind.Other => OtherToken,
|
||||||
|
_ => (TypeName ?? $"T{TypeCode}") + (NumberLow is not { } lo
|
||||||
|
? ""
|
||||||
|
: NumberHigh is { } hi && hi != lo ? $"/{lo}-{hi}" : $"/{lo}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this selector matches one CNC alarm on its own terms. <see cref="FocasAlarmSelectorKind.Other"/>
|
||||||
|
/// always answers <see langword="false"/> here: whether an alarm is "unmatched" depends on the other
|
||||||
|
/// selectors on the device, which the caller evaluates.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The alarm's type code.</param>
|
||||||
|
/// <param name="number">The alarm number.</param>
|
||||||
|
/// <returns><see langword="true"/> when the alarm is selected.</returns>
|
||||||
|
public bool Matches(short type, int number) => Kind switch
|
||||||
|
{
|
||||||
|
FocasAlarmSelectorKind.Any => true,
|
||||||
|
FocasAlarmSelectorKind.Type => type == TypeCode
|
||||||
|
&& (NumberLow is not { } lo || (number >= lo && number <= (NumberHigh ?? lo))),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Parses the text after <c>ALARM:</c>, or returns <see langword="null"/>.</summary>
|
||||||
|
/// <param name="body">The selector text.</param>
|
||||||
|
/// <returns>The selector, or <see langword="null"/> when the text is not a valid selector.</returns>
|
||||||
|
public static FocasAlarmSelector? TryParse(string? body) => TryParse(body, out var selector, out _) ? selector : null;
|
||||||
|
|
||||||
|
/// <summary>Parses the text after <c>ALARM:</c>, explaining a refusal.</summary>
|
||||||
|
/// <param name="body">The selector text.</param>
|
||||||
|
/// <param name="selector">The selector when this returns <see langword="true"/>.</param>
|
||||||
|
/// <param name="error">Why the text was refused, when this returns <see langword="false"/>.</param>
|
||||||
|
/// <returns><see langword="true"/> when <paramref name="body"/> is a valid selector.</returns>
|
||||||
|
public static bool TryParse(string? body, out FocasAlarmSelector? selector, out string? error)
|
||||||
|
{
|
||||||
|
selector = null;
|
||||||
|
error = null;
|
||||||
|
var src = (body ?? "").Trim();
|
||||||
|
if (src.Length == 0)
|
||||||
|
{
|
||||||
|
error = "an ALARM: address needs a selector (*, OTHER, a type, type/number or type/lo-hi)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (src.Any(char.IsWhiteSpace))
|
||||||
|
{
|
||||||
|
error = $"ALARM selector '{src}' contains whitespace";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (src == AnyToken)
|
||||||
|
{
|
||||||
|
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Any);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (string.Equals(src, OtherToken, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Other);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slash = src.IndexOf('/');
|
||||||
|
var typeText = slash < 0 ? src : src[..slash];
|
||||||
|
if (!TryParseType(typeText, out var code, out var name))
|
||||||
|
{
|
||||||
|
error = $"'{typeText}' is not a FOCAS alarm type — expected one of {string.Join(", ", FocasAlarmType.Names)}, " +
|
||||||
|
"or a raw type code such as T4";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (slash < 0)
|
||||||
|
{
|
||||||
|
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numbers = src[(slash + 1)..];
|
||||||
|
var dash = numbers.IndexOf('-');
|
||||||
|
if (dash < 0)
|
||||||
|
{
|
||||||
|
if (!TryParseNumber(numbers, out var n))
|
||||||
|
{
|
||||||
|
error = $"'{numbers}' is not an alarm number (a non-negative integer)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, n, n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseNumber(numbers[..dash], out var lo) || !TryParseNumber(numbers[(dash + 1)..], out var hi))
|
||||||
|
{
|
||||||
|
error = $"'{numbers}' is not an alarm number range (lo-hi, non-negative integers)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lo > hi)
|
||||||
|
{
|
||||||
|
error = $"alarm number range '{numbers}' is reversed (lo must not exceed hi)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, lo, hi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseType(string text, out short code, out string? name)
|
||||||
|
{
|
||||||
|
name = null;
|
||||||
|
if (FocasAlarmType.TryResolve(text, out code))
|
||||||
|
{
|
||||||
|
name = FocasAlarmType.NameOf(code);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Raw code: T<digits>. Non-negative and within the wire field's short width.
|
||||||
|
if (text.Length >= 2 && (text[0] == 'T' || text[0] == 't')
|
||||||
|
&& text.Skip(1).All(char.IsAsciiDigit)
|
||||||
|
&& short.TryParse(text.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out code))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
code = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseNumber(string text, out int number) =>
|
||||||
|
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out number);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||||
|
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||||
|
/// <c>cnc_rdalmmsg2</c> (and so the driver's <c>switch (short)</c> statements compile against a
|
||||||
|
/// matching type rather than relying on implicit int→short narrowing on the constants).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>
|
||||||
|
/// (Gitea #647). The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||||
|
/// been confirmed against a FOCAS reference, so it is deliberately NOT encoded here — a guessed code
|
||||||
|
/// would silently route the wrong alarms. On a 30i-series control the names below therefore label
|
||||||
|
/// the raw type code with its 16i meaning, which may be wrong. Every alarm message the driver
|
||||||
|
/// publishes carries the raw code (<c>type 4</c>), and an <c>ALARM:</c> address can name a raw code
|
||||||
|
/// (<c>ALARM:T4</c>) instead of a name, which is the series-independent form.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Moved into the Contracts assembly (from the driver's <c>IFocasClient.cs</c>, same namespace) so the
|
||||||
|
/// <see cref="FocasAlarmSelector"/> grammar — shared by the AdminUI editor and the deploy gate — can
|
||||||
|
/// resolve type names without referencing the runtime driver.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class FocasAlarmType
|
||||||
|
{
|
||||||
|
/// <summary>Pass to the wire's <c>cnc_rdalmmsg2</c> to mean "any type".</summary>
|
||||||
|
public const short All = -1;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_P</c> — parameter / program (P/S) alarms.</summary>
|
||||||
|
public const short Parameter = 0;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_Y</c> — pulse coder (servo encoder) alarms.</summary>
|
||||||
|
public const short PulseCode = 1;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_O</c> — overtravel alarms.</summary>
|
||||||
|
public const short Overtravel = 2;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_H</c> — overheat alarms.</summary>
|
||||||
|
public const short Overheat = 3;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_S</c> — servo alarms.</summary>
|
||||||
|
public const short Servo = 4;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_T</c> — data I/O alarms.</summary>
|
||||||
|
public const short DataIo = 5;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_M</c> — memory check alarms.</summary>
|
||||||
|
public const short MemoryCheck = 6;
|
||||||
|
|
||||||
|
/// <summary><c>ALM_MC</c> — macro alarms (<c>#3000</c>).</summary>
|
||||||
|
public const short MacroAlarm = 13;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The named types an <c>ALARM:</c> address may use, keyed case-insensitively by the constant's
|
||||||
|
/// name. The canonical spelling is the constant name (<c>Servo</c>, <c>MacroAlarm</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyDictionary<string, short> ByName =
|
||||||
|
new Dictionary<string, short>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[nameof(Parameter)] = Parameter,
|
||||||
|
[nameof(PulseCode)] = PulseCode,
|
||||||
|
[nameof(Overtravel)] = Overtravel,
|
||||||
|
[nameof(Overheat)] = Overheat,
|
||||||
|
[nameof(Servo)] = Servo,
|
||||||
|
[nameof(DataIo)] = DataIo,
|
||||||
|
[nameof(MemoryCheck)] = MemoryCheck,
|
||||||
|
[nameof(MacroAlarm)] = MacroAlarm,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Gets the canonical type names an <c>ALARM:</c> address accepts, in code order.</summary>
|
||||||
|
public static IReadOnlyList<string> Names { get; } =
|
||||||
|
[nameof(Parameter), nameof(PulseCode), nameof(Overtravel), nameof(Overheat),
|
||||||
|
nameof(Servo), nameof(DataIo), nameof(MemoryCheck), nameof(MacroAlarm)];
|
||||||
|
|
||||||
|
/// <summary>Resolves a type name (case-insensitive) to its code.</summary>
|
||||||
|
/// <param name="name">The type name, e.g. <c>servo</c>.</param>
|
||||||
|
/// <param name="code">The resolved code.</param>
|
||||||
|
/// <returns><see langword="true"/> when the name is in the table.</returns>
|
||||||
|
public static bool TryResolve(string name, out short code) => ByName.TryGetValue(name, out code);
|
||||||
|
|
||||||
|
/// <summary>The canonical name of a code, or <see langword="null"/> when the code is not in the table.</summary>
|
||||||
|
/// <param name="code">The FOCAS alarm type code.</param>
|
||||||
|
/// <returns>The name, or <see langword="null"/>.</returns>
|
||||||
|
public static string? NameOf(short code) => code switch
|
||||||
|
{
|
||||||
|
Parameter => nameof(Parameter),
|
||||||
|
PulseCode => nameof(PulseCode),
|
||||||
|
Overtravel => nameof(Overtravel),
|
||||||
|
Overheat => nameof(Overheat),
|
||||||
|
Servo => nameof(Servo),
|
||||||
|
DataIo => nameof(DataIo),
|
||||||
|
MemoryCheck => nameof(MemoryCheck),
|
||||||
|
MacroAlarm => nameof(MacroAlarm),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -128,12 +128,19 @@ public sealed class FocasHandleRecycleOptions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags (Gitea #647).
|
||||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Authoring an <c>ALARM:</c> tag IS the opt-in.</b> The projection polls exactly the devices that own a
|
||||||
|
/// subscribed alarm tag, so a driver with none issues no <c>cnc_rdalmmsg2</c> traffic whatever
|
||||||
|
/// <see cref="Enabled"/> says. Before #647, <see cref="Enabled"/> gated the poll and
|
||||||
|
/// <c>SubscribeAlarmsAsync</c> THREW while it was false — so an operator who authored an alarm tag and left
|
||||||
|
/// the default got a feed that stayed gated with a Warning on every reconnect. It is retained so existing
|
||||||
|
/// configs still bind, and no longer has any effect.
|
||||||
|
/// </remarks>
|
||||||
public sealed class FocasAlarmProjectionOptions
|
public sealed class FocasAlarmProjectionOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
/// <summary>Retained for config compatibility; has no effect since Gitea #647 (see the class remarks).</summary>
|
||||||
public bool Enabled { get; init; } = false;
|
public bool Enabled { get; init; } = false;
|
||||||
|
|
||||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||||
@@ -196,13 +203,29 @@ public sealed record FocasDeviceOptions(
|
|||||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Alarm">
|
||||||
|
/// The tag's cross-driver <c>alarm</c> object, when it carries one (Gitea #647) — which makes the host
|
||||||
|
/// materialise it as a Part 9 condition at its RawPath. Only an <c>ALARM:</c> address can drive one.
|
||||||
|
/// </param>
|
||||||
public sealed record FocasTagDefinition(
|
public sealed record FocasTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
string Address,
|
string Address,
|
||||||
FocasDataType DataType,
|
FocasDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
FocasTagAlarm? Alarm = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object (Gitea #647). The host owns the
|
||||||
|
/// rest of it (<c>alarmType</c>, <c>historizeToAveva</c>) through <c>TagConfigIntent</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Severity">
|
||||||
|
/// The authored <c>alarm.severity</c> (an integer), or <see langword="null"/> when the object has none. An
|
||||||
|
/// authored severity is sent as the condition's exact Part 9 severity; without one the severity comes from
|
||||||
|
/// the displayed CNC alarm's type (<c>FocasAlarmConditionMapper</c>).
|
||||||
|
/// </param>
|
||||||
|
public sealed record FocasTagAlarm(int? Severity);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Controls periodic connectivity probing. One <c>cnc_rdcncstat</c> call per
|
/// Controls periodic connectivity probing. One <c>cnc_rdcncstat</c> call per
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ public static class FocasTagDefinitionFactory
|
|||||||
Address: address,
|
Address: address,
|
||||||
DataType: dataType,
|
DataType: dataType,
|
||||||
Writable: writable,
|
Writable: writable,
|
||||||
WriteIdempotent: false);
|
WriteIdempotent: false,
|
||||||
|
Alarm: ReadAlarm(root));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (JsonException) { return false; }
|
catch (JsonException) { return false; }
|
||||||
@@ -72,6 +73,20 @@ public static class FocasTagDefinitionFactory
|
|||||||
catch (InvalidOperationException) { return false; }
|
catch (InvalidOperationException) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tag's cross-driver <c>alarm</c> object (Gitea #647), or <see langword="null"/> when it carries none.
|
||||||
|
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The TagConfig root object.</param>
|
||||||
|
/// <returns>The alarm intent, or <see langword="null"/>.</returns>
|
||||||
|
private static FocasTagAlarm? ReadAlarm(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
int? severity = a.TryGetProperty("severity", out var s) && s.ValueKind == JsonValueKind.Number
|
||||||
|
&& s.TryGetInt32(out var v) ? v : null;
|
||||||
|
return new FocasTagAlarm(severity);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||||
@@ -235,16 +250,37 @@ public static class FocasTagDefinitionFactory
|
|||||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||||
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
||||||
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
||||||
if (root.TryGetProperty("address", out var addr)
|
var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String
|
||||||
&& addr.ValueKind == JsonValueKind.String
|
? addr.GetString()
|
||||||
&& addr.GetString() is { } address
|
: null;
|
||||||
&& !string.IsNullOrWhiteSpace(address)
|
var parsed = string.IsNullOrWhiteSpace(address) ? null : FocasAddress.TryParse(address);
|
||||||
&& FocasAddress.TryParse(address) is null)
|
if (!string.IsNullOrWhiteSpace(address) && parsed is null)
|
||||||
|
{
|
||||||
|
// An ALARM: address gets the selector's own reason — the same text the AdminUI editor shows.
|
||||||
|
advisories.Add(FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||||
|
? $"FOCAS alarm address '{address.Trim()}' is not valid: {alarmError} — the driver will skip " +
|
||||||
|
"this tag at startup and its condition will never activate."
|
||||||
|
: $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||||
|
"PARAM:1020, MACRO:500, FIXED:Identity/SeriesNumber or ALARM:Servo/401) — the driver will skip " +
|
||||||
|
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||||
|
// makes the host materialise a Part 9 condition; the address is the only thing the driver can drive one
|
||||||
|
// from. Either one alone is a tag that does nothing.
|
||||||
|
var hasAlarmObject = root.TryGetProperty("alarm", out var alarm) && alarm.ValueKind == JsonValueKind.Object;
|
||||||
|
if (parsed is { Kind: FocasAreaKind.Alarm } && !hasAlarmObject)
|
||||||
{
|
{
|
||||||
advisories.Add(
|
advisories.Add(
|
||||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
$"FOCAS address '{address!.Trim()}' is an alarm selector but the tag has no 'alarm' object — it is " +
|
||||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
"not materialised as a condition and has no value to read; the driver skips it.");
|
||||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
}
|
||||||
|
else if (parsed is { Kind: not FocasAreaKind.Alarm } && hasAlarmObject)
|
||||||
|
{
|
||||||
|
advisories.Add(
|
||||||
|
$"FOCAS tag carries an 'alarm' object but its address '{address!.Trim()}' is not an ALARM: selector — " +
|
||||||
|
"the condition is materialised but nothing will ever raise it; the driver skips the tag. " +
|
||||||
|
"Use ALARM:*, ALARM:<type>[/<n>[-<m>]] or ALARM:OTHER.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for (Gitea #647).</summary>
|
||||||
|
/// <param name="RawPath">The tag's RawPath — the condition's ConditionId, the only key the host routes on.</param>
|
||||||
|
/// <param name="DeviceHostAddress">The device whose alarm list the tag selects from.</param>
|
||||||
|
/// <param name="Selector">Which alarms on that device drive the condition.</param>
|
||||||
|
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or <see langword="null"/> to derive one from the alarm type.</param>
|
||||||
|
internal sealed record FocasAlarmTag(
|
||||||
|
string RawPath, string DeviceHostAddress, FocasAlarmSelector Selector, int? AuthoredSeverity);
|
||||||
|
|
||||||
|
/// <summary>A CNC alarm's identity: (type, number). Axis is NOT part of it — see <see cref="FocasAlarmMatcher"/>.</summary>
|
||||||
|
/// <param name="Type">The FOCAS alarm type code.</param>
|
||||||
|
/// <param name="Number">The alarm number.</param>
|
||||||
|
internal readonly record struct FocasAlarmKey(short Type, int Number);
|
||||||
|
|
||||||
|
/// <summary>One distinct alarm a condition matched, with every axis it was reported on.</summary>
|
||||||
|
/// <param name="Key">The alarm's identity.</param>
|
||||||
|
/// <param name="Message">The CNC's message text.</param>
|
||||||
|
/// <param name="Axes">The distinct non-zero axes it was reported on, ascending.</param>
|
||||||
|
internal sealed record FocasMatchedAlarm(FocasAlarmKey Key, string Message, IReadOnlyList<short> Axes);
|
||||||
|
|
||||||
|
/// <summary>What one condition sees in one successful alarm-list read.</summary>
|
||||||
|
/// <param name="Matched">The matched alarms in display order — the first is the one the condition shows.</param>
|
||||||
|
/// <param name="ListTruncated">Whether the read may have been cut off at the per-read cap (summary selectors only).</param>
|
||||||
|
internal sealed record FocasConditionView(IReadOnlyList<FocasMatchedAlarm> Matched, bool ListTruncated)
|
||||||
|
{
|
||||||
|
/// <summary>Gets a value indicating whether at least one matching alarm is active.</summary>
|
||||||
|
public bool Active => Matched.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Gets the alarm the condition displays, or <see langword="null"/> when inactive.</summary>
|
||||||
|
public FocasMatchedAlarm? Winner => Matched.Count > 0 ? Matched[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects, per authored <c>ALARM:</c> tag, the matching entries of a device's flat CNC alarm list (Gitea #647).
|
||||||
|
/// Pure — the projection hands it one successful read and gets back what each condition sees.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Identity is (type, number).</b> Cross-family number collisions exist, so the number alone is not
|
||||||
|
/// an identity. The axis is not part of it: one servo alarm number reported on two axes is ONE alarm,
|
||||||
|
/// listing both axes.</item>
|
||||||
|
/// <item><b>Overlap is intended.</b> An alarm drives every tag that selects it — <c>ALARM:Servo</c> and
|
||||||
|
/// <c>ALARM:*</c> both go active for one servo alarm, the way one MTConnect DataItem can back several tags.</item>
|
||||||
|
/// <item><b><c>OTHER</c> is what no TYPE selector on the device claims.</b> <c>*</c> summaries and other
|
||||||
|
/// <c>OTHER</c> tags do not claim anything, so a device summary beside it never starves it.</item>
|
||||||
|
/// <item><b>The displayed alarm is deterministic, never wire order</b>: the most severe (by the type
|
||||||
|
/// table), then ascending (type, number). A CNC that reorders an unchanged list therefore cannot re-raise
|
||||||
|
/// a condition.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class FocasAlarmMatcher
|
||||||
|
{
|
||||||
|
/// <summary>Evaluates one condition against one device's alarm list.</summary>
|
||||||
|
/// <param name="selector">The condition's selector.</param>
|
||||||
|
/// <param name="alarms">The device's active alarms, as read.</param>
|
||||||
|
/// <param name="deviceSelectors">Every authored alarm selector on the same device (for <c>OTHER</c>).</param>
|
||||||
|
/// <param name="listTruncated">Whether the read came back at the per-read cap.</param>
|
||||||
|
/// <returns>What the condition sees.</returns>
|
||||||
|
public static FocasConditionView Evaluate(
|
||||||
|
FocasAlarmSelector selector,
|
||||||
|
IReadOnlyList<FocasActiveAlarm> alarms,
|
||||||
|
IReadOnlyCollection<FocasAlarmSelector> deviceSelectors,
|
||||||
|
bool listTruncated)
|
||||||
|
{
|
||||||
|
var typeSelectors = selector.Kind == FocasAlarmSelectorKind.Other
|
||||||
|
? deviceSelectors.Where(s => s.Kind == FocasAlarmSelectorKind.Type).ToList()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var matched = alarms
|
||||||
|
.Where(a => typeSelectors is null
|
||||||
|
? selector.Matches(a.Type, a.AlarmNumber)
|
||||||
|
: !typeSelectors.Any(s => s.Matches(a.Type, a.AlarmNumber)))
|
||||||
|
.GroupBy(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var records = g.OrderBy(a => a.Axis).ToList();
|
||||||
|
var message = records.Select(a => a.Message?.Trim()).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? "";
|
||||||
|
var axes = records.Select(a => a.Axis).Where(x => x > 0).Distinct().Order().ToList();
|
||||||
|
return new FocasMatchedAlarm(g.Key, message, axes);
|
||||||
|
})
|
||||||
|
.OrderByDescending(m => FocasAlarmConditionMapper.TypeSeverity(m.Key.Type))
|
||||||
|
.ThenBy(m => m.Key.Type)
|
||||||
|
.ThenBy(m => m.Key.Number)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var summary = selector.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other;
|
||||||
|
return new FocasConditionView(matched, listTruncated && summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether any authored selector on the device claims the alarm (<c>*</c>, <c>OTHER</c> or a type match).</summary>
|
||||||
|
/// <param name="alarm">The alarm.</param>
|
||||||
|
/// <param name="deviceSelectors">Every authored alarm selector on the device.</param>
|
||||||
|
/// <returns><see langword="true"/> when some condition on the device will show it.</returns>
|
||||||
|
public static bool IsCovered(FocasActiveAlarm alarm, IReadOnlyCollection<FocasAlarmSelector> deviceSelectors) =>
|
||||||
|
deviceSelectors.Any(s => s.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other
|
||||||
|
|| s.Matches(alarm.Type, alarm.AlarmNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the server was last TOLD about one FOCAS condition (Gitea #647) — the diff baseline that keeps a
|
||||||
|
/// reconnect, a re-subscribe or a reordered list from re-raising (and so un-acknowledging) an alarm.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Active">Whether the condition was last told active.</param>
|
||||||
|
/// <param name="Keys">The matched set as of the last evaluation.</param>
|
||||||
|
/// <param name="Winner">The alarm the condition was last told it displays.</param>
|
||||||
|
/// <param name="Message">The message last sent.</param>
|
||||||
|
/// <param name="Severity">The bucket last sent.</param>
|
||||||
|
/// <param name="OpcUaSeverity">The exact severity last sent, if any.</param>
|
||||||
|
internal sealed record FocasToldCondition(
|
||||||
|
bool Active,
|
||||||
|
IReadOnlySet<FocasAlarmKey> Keys,
|
||||||
|
FocasAlarmKey? Winner,
|
||||||
|
string Message,
|
||||||
|
AlarmSeverity Severity,
|
||||||
|
int? OpcUaSeverity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The FOCAS alarm list → OPC UA Part 9 transition, as one pure function (Gitea #647): given what the server was
|
||||||
|
/// last told about a condition and what the CNC says now, the single <see cref="AlarmEventArgs"/> to raise — or
|
||||||
|
/// none — and what the server has been told afterwards.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <listheader><term>Told → now</term><description>Event</description></listheader>
|
||||||
|
/// <item><term>inactive → active</term><description><see cref="AlarmTransitionKind.Raise"/></description></item>
|
||||||
|
/// <item><term>active → a key not in the told set appears</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||||
|
/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges</description></item>
|
||||||
|
/// <item><term>active → the displayed alarm left, others remain</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||||
|
/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen)</description></item>
|
||||||
|
/// <item><term>active → a NON-displayed member left</term><description>nothing (no message-only events)</description></item>
|
||||||
|
/// <item><term>active → nothing matches</term><description><see cref="AlarmTransitionKind.Clear"/></description></item>
|
||||||
|
/// <item><term>unchanged</term><description>nothing</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Severity, explicitly.</b> An authored <c>alarm.severity</c> is sent as
|
||||||
|
/// <see cref="AlarmEventArgs.OpcUaSeverity"/> (the host clamps it to 1..1000), with the nearest bucket in
|
||||||
|
/// <see cref="AlarmEventArgs.Severity"/> (<c><400</c> Low, <c><600</c> Medium, <c><800</c> High, else
|
||||||
|
/// Critical). Without one, the bucket comes from the displayed alarm's type through <see cref="TypeSeverity"/>
|
||||||
|
/// — Overtravel / Servo / PulseCode Critical (host: 900), Parameter / MacroAlarm Medium (500), everything
|
||||||
|
/// else including unknown codes High (700) — and no exact severity is sent. The type table is the 16i-era
|
||||||
|
/// one (see <see cref="FocasAlarmType"/>), so on a 30i-series control author a severity.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class FocasAlarmConditionMapper
|
||||||
|
{
|
||||||
|
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="view"/>, and the new record.</summary>
|
||||||
|
/// <param name="told">What the server was last told, or <see langword="null"/> when nothing yet.</param>
|
||||||
|
/// <param name="view">What the condition sees now.</param>
|
||||||
|
/// <param name="tag">The condition's tag.</param>
|
||||||
|
/// <param name="handle">The alarm subscription the event belongs to.</param>
|
||||||
|
/// <param name="nowUtc">The transition timestamp.</param>
|
||||||
|
/// <returns>The event (or <see langword="null"/>) and the new told record.</returns>
|
||||||
|
public static (AlarmEventArgs? Event, FocasToldCondition Told) Transition(
|
||||||
|
FocasToldCondition? told,
|
||||||
|
FocasConditionView view,
|
||||||
|
FocasAlarmTag tag,
|
||||||
|
IAlarmSubscriptionHandle handle,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var keys = view.Matched.Select(m => m.Key).ToHashSet();
|
||||||
|
|
||||||
|
if (!view.Active)
|
||||||
|
{
|
||||||
|
if (told is not { Active: true })
|
||||||
|
return (null, told ?? new FocasToldCondition(false, keys, null, "", AlarmSeverity.Low, null));
|
||||||
|
var cleared = told with { Active = false, Keys = keys, Winner = null };
|
||||||
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}",
|
||||||
|
told.Winner?.Type, told.Severity, told.OpcUaSeverity), cleared);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winner = view.Winner!;
|
||||||
|
var (bucket, exact) = SeverityOf(tag, winner.Key.Type);
|
||||||
|
var message = MessageOf(view);
|
||||||
|
var raised = new FocasToldCondition(true, keys, winner.Key, message, bucket, exact);
|
||||||
|
|
||||||
|
if (told is not { Active: true })
|
||||||
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, winner.Key.Type, bucket, exact), raised);
|
||||||
|
|
||||||
|
var newKey = keys.Any(k => !told.Keys.Contains(k));
|
||||||
|
if (newKey || told.Winner != winner.Key)
|
||||||
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, winner.Key.Type, bucket, exact), raised);
|
||||||
|
|
||||||
|
// Same statement: keep what the server was shown, but track the membership so a key that leaves and
|
||||||
|
// later comes back is new again.
|
||||||
|
return (null, told with { Keys = keys });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The severity bucket of a FOCAS alarm type (the 16i-era table — see the class remarks).</summary>
|
||||||
|
/// <param name="type">The FOCAS alarm type code.</param>
|
||||||
|
/// <returns>The bucket.</returns>
|
||||||
|
internal static AlarmSeverity TypeSeverity(short type) => type switch
|
||||||
|
{
|
||||||
|
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||||
|
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||||
|
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||||
|
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||||
|
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||||
|
_ => AlarmSeverity.High,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
|
||||||
|
/// <param name="severity">The exact severity.</param>
|
||||||
|
/// <returns>The bucket.</returns>
|
||||||
|
internal static AlarmSeverity BucketOf(int severity) => severity switch
|
||||||
|
{
|
||||||
|
< 400 => AlarmSeverity.Low,
|
||||||
|
< 600 => AlarmSeverity.Medium,
|
||||||
|
< 800 => AlarmSeverity.High,
|
||||||
|
_ => AlarmSeverity.Critical,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The human-readable category of a FOCAS type: its table name, else <c>Type{code}</c>.</summary>
|
||||||
|
/// <param name="type">The FOCAS alarm type code.</param>
|
||||||
|
/// <returns>The category.</returns>
|
||||||
|
internal static string TypeLabel(short type) => FocasAlarmType.NameOf(type) ?? $"Type{type}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The condition's message: the displayed alarm's number and text, its raw type code (so a 30i-series
|
||||||
|
/// operator can author a <c>T<code></c> selector from what they see) and axes, then how many other
|
||||||
|
/// alarms the condition also matches, and a truncation note on a summary condition.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="view">What the condition sees; must be active.</param>
|
||||||
|
/// <returns>e.g. <c>401 SERVO V-READY OFF (type 4 Servo, axis 1, 2) [+2 more]</c>.</returns>
|
||||||
|
internal static string MessageOf(FocasConditionView view)
|
||||||
|
{
|
||||||
|
var w = view.Winner!;
|
||||||
|
var b = new StringBuilder();
|
||||||
|
b.Append(w.Key.Number);
|
||||||
|
if (w.Message.Length > 0) b.Append(' ').Append(w.Message);
|
||||||
|
b.Append(" (type ").Append(w.Key.Type);
|
||||||
|
if (FocasAlarmType.NameOf(w.Key.Type) is { } name) b.Append(' ').Append(name);
|
||||||
|
if (w.Axes.Count > 0) b.Append(w.Axes.Count == 1 ? ", axis " : ", axes ").Append(string.Join(", ", w.Axes));
|
||||||
|
b.Append(')');
|
||||||
|
if (view.Matched.Count > 1) b.Append(" [+").Append(view.Matched.Count - 1).Append(" more]");
|
||||||
|
if (view.ListTruncated) b.Append(" [alarm list truncated]");
|
||||||
|
return b.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (AlarmSeverity Bucket, int? Exact) SeverityOf(FocasAlarmTag tag, short type) =>
|
||||||
|
tag.AuthoredSeverity is { } authored
|
||||||
|
? (BucketOf(authored), authored)
|
||||||
|
: (TypeSeverity(type), null);
|
||||||
|
|
||||||
|
private static AlarmEventArgs Build(
|
||||||
|
FocasAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, AlarmTransitionKind kind,
|
||||||
|
string message, short? type, AlarmSeverity bucket, int? exact) =>
|
||||||
|
new(handle,
|
||||||
|
SourceNodeId: tag.DeviceHostAddress,
|
||||||
|
ConditionId: tag.RawPath,
|
||||||
|
AlarmType: type is { } t ? TypeLabel(t) : "CncAlarm",
|
||||||
|
Message: message,
|
||||||
|
Severity: bucket,
|
||||||
|
SourceTimestampUtc: nowUtc,
|
||||||
|
Kind: kind,
|
||||||
|
OpcUaSeverity: exact);
|
||||||
|
}
|
||||||
@@ -5,196 +5,215 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
|
/// Polls the CNC active-alarm list (<c>cnc_rdalmmsg2</c>) of every device that owns a subscribed
|
||||||
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
|
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag (Gitea #647).
|
||||||
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
|
|
||||||
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
|
|
||||||
/// <c>Type</c>) keyed active-alarm set between ticks.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
|
/// <para>
|
||||||
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
|
/// <b>Which tags are conditions.</b> The server decides: a raw tag whose <c>TagConfig</c> carries the
|
||||||
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
|
/// cross-driver <c>alarm</c> object is materialised as a condition at its RawPath, and those RawPaths
|
||||||
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
|
/// arrive through <see cref="SubscribeAsync"/>. Every event uses the RawPath as
|
||||||
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
|
/// <see cref="AlarmEventArgs.ConditionId"/> — the exact key the host routes on — and carries a
|
||||||
/// projection will skip devices not listed.
|
/// <see cref="AlarmEventArgs.Kind"/>, without which the host's projector keeps the prior state. Before
|
||||||
|
/// #647 this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
|
||||||
|
/// <c>"{host}#{Type}:{Number}"</c> with no kind (so nothing would have routed anyway).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A failed read is not an empty list.</b> <see cref="IFocasClient.ReadAlarmsAsync"/> throws on any
|
||||||
|
/// failure; the device is then skipped for that tick and every condition on it keeps its state. The
|
||||||
|
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss (#477).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect #626
|
||||||
|
/// pattern), recorded only while an <c>OnAlarmEvent</c> handler is attached. The record lives on this
|
||||||
|
/// object, which lives as long as the driver: it survives a re-subscribe with the same set (a reconnect),
|
||||||
|
/// a re-initialise and a shutdown/re-init cycle — the server's condition nodes outlive all three, and
|
||||||
|
/// re-raising there would un-acknowledge an acknowledged alarm. A changed subscribed set prunes only the
|
||||||
|
/// RawPaths that LEFT it (the server carries every survivor across the rebuild, #641).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One poll loop</b>, owned by the current subscription. Evaluation and delivery run under
|
||||||
|
/// <see cref="_alarmLock"/> and re-check that the subscription is still current, so a superseded loop
|
||||||
|
/// that is still finishing its last read can never emit — the duplicate-per-reconnect window the old
|
||||||
|
/// design documented is closed. The only production handler is <c>DriverInstanceActor</c>'s
|
||||||
|
/// non-blocking <c>Tell</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para><b>Read-only.</b> FOCAS has no alarm acknowledgement; the Part 9 Acknowledge is applied server-side.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly FocasDriver _driver;
|
private readonly FocasDriver _driver;
|
||||||
private readonly TimeSpan _pollInterval;
|
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly Dictionary<long, Subscription> _subs = new();
|
|
||||||
private readonly Lock _subsLock = new();
|
/// <summary>Serializes evaluation + delivery + the told record. Never held across an await.</summary>
|
||||||
|
private readonly Lock _alarmLock = new();
|
||||||
|
|
||||||
|
/// <summary>ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||||
|
private readonly Dictionary<string, FocasToldCondition> _told = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Per device: the alarm keys already logged as matched by no tag. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||||
|
private readonly Dictionary<string, HashSet<FocasAlarmKey>> _unmatchedLogged = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Devices whose last read came back at the per-read cap. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||||
|
private readonly HashSet<string> _truncationWarned = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Devices whose alarm read is currently failing → the quality that failure maps to (one Warning per episode
|
||||||
|
/// and per kind: a refusal turning into a transport fault warns again). Guarded by <see cref="_alarmLock"/>.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, uint> _failingDevices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ConditionIds this projection has annotated <c>BadNoCommunication</c> and not yet lifted. Guarded by
|
||||||
|
/// <see cref="_alarmLock"/>. Kept apart from <see cref="_told"/>: a quality annotation never touches a
|
||||||
|
/// condition's Active/Acked state.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, uint> _qualityBadTold = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Canonical <c>Opc.Ua.StatusCodes.Good</c>.</summary>
|
||||||
|
private const uint QualityGood = 0x00000000u;
|
||||||
|
|
||||||
|
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c> — the CNC could not be reached.</summary>
|
||||||
|
private const uint QualityBadNoCommunication = 0x80310000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an alarm-read failure is a CNC REFUSAL (Gitea #647 re-review R2): the CNC answered with a non-OK
|
||||||
|
/// FOCAS return code, so the transport is fine and the control simply will not serve <c>cnc_rdalmmsg2</c>
|
||||||
|
/// (e.g. <c>EW_FUNC</c>, as <c>cnc_rdparam</c> does on the Makino). That is a capability answer, not comms
|
||||||
|
/// loss: it must NOT degrade driver health, or an alarm-only driver on such a control would be reconnected by
|
||||||
|
/// the #527 watchdog every 90 s forever. Everything else (session down, wire fault, malformed reply, backoff)
|
||||||
|
/// is a transport failure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error">The alarm-read failure.</param>
|
||||||
|
/// <returns><see langword="true"/> for a refusal.</returns>
|
||||||
|
internal static bool IsCncRefusal(Exception error) => error is Wire.FocasWireException { Rc: not null };
|
||||||
|
|
||||||
|
/// <summary>Serializes loop start/stop.</summary>
|
||||||
|
private readonly SemaphoreSlim _loopGate = new(1, 1);
|
||||||
|
|
||||||
|
private Subscription? _current;
|
||||||
|
private CancellationTokenSource? _loopCts;
|
||||||
|
private Task _loop = Task.CompletedTask;
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
|
|
||||||
/// <summary>Initializes a new FOCAS alarm projection.</summary>
|
/// <summary>Initializes a new FOCAS alarm projection.</summary>
|
||||||
/// <param name="driver">FOCAS driver instance.</param>
|
/// <param name="driver">The owning driver.</param>
|
||||||
/// <param name="pollInterval">Polling interval.</param>
|
|
||||||
/// <param name="logger">Optional logger.</param>
|
/// <param name="logger">Optional logger.</param>
|
||||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
public FocasAlarmProjection(FocasDriver driver, ILogger? logger = null)
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_pollInterval = pollInterval;
|
|
||||||
_logger = logger ?? NullLogger.Instance;
|
_logger = logger ?? NullLogger.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Subscribes to alarms from the specified device sources.</summary>
|
/// <summary>Gets the live subscription, or <see langword="null"/>. Test seam.</summary>
|
||||||
/// <param name="sourceNodeIds">Source node IDs to listen to.</param>
|
internal Subscription? Current => Volatile.Read(ref _current);
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>A task returning the alarm subscription handle.</returns>
|
|
||||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
|
||||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var id = Interlocked.Increment(ref _nextId);
|
|
||||||
var handle = new FocasAlarmSubscriptionHandle(id);
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
// Empty filter = listen to every configured device. Otherwise only devices whose
|
|
||||||
// host address appears in sourceNodeIds are polled.
|
|
||||||
var filter = sourceNodeIds.Count == 0
|
|
||||||
? null
|
|
||||||
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var sub = new Subscription(handle, filter, cts);
|
|
||||||
|
|
||||||
// Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
|
|
||||||
// alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
|
|
||||||
// Connected→Reconnecting WITHOUT calling Unsubscribe), and this projection is reused across
|
|
||||||
// every in-place reconnect, so each SubscribeAsync would otherwise leak a live, never-
|
|
||||||
// cancelled poll loop. There is exactly one consumer per driver instance, so retiring all
|
|
||||||
// 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.
|
|
||||||
List<Subscription> stale;
|
|
||||||
lock (_subsLock)
|
|
||||||
{
|
|
||||||
stale = [.. _subs.Values];
|
|
||||||
_subs.Clear();
|
|
||||||
_subs[id] = sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
|
||||||
|
|
||||||
// Retire superseded loops out-of-band so the new subscription's return isn't blocked on a
|
|
||||||
// full poll interval (awaiting a loop means waiting for it to exit its Task.Delay). The
|
|
||||||
// loops already catch internally; RetireAsync still wraps every await defensively.
|
|
||||||
foreach (var old in stale) _ = RetireAsync(old);
|
|
||||||
|
|
||||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
|
/// Replaces the live subscription with one over <paramref name="sourceNodeIds"/> (alarm-tag RawPaths) and
|
||||||
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
|
/// (re)starts the poll loop. Collapses to ONE subscription: the owning <c>DriverInstanceActor</c> is the
|
||||||
/// unobserved exception can never escape (the loops already swallow their own).
|
/// only caller and re-subscribes on every Connected entry.
|
||||||
/// <para>
|
|
||||||
/// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight
|
|
||||||
/// read + tick complete and it observes cancellation at the next <c>Task.Delay</c>. If a device
|
|
||||||
/// transition lands in that one-tick window, both the old and new loops can fire it once —
|
|
||||||
/// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old
|
|
||||||
/// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on
|
|
||||||
/// <c>ConditionId</c>, so this is absorbed. Awaiting the retire before starting the new loop
|
|
||||||
/// would close the window at the cost of blocking the subscribe for one read latency.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
|
/// <param name="sourceNodeIds">The alarm-bearing raw tags' RawPaths.</param>
|
||||||
private async Task RetireAsync(Subscription sub)
|
/// <returns>The subscription handle.</returns>
|
||||||
|
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(IReadOnlyList<string> sourceNodeIds)
|
||||||
{
|
{
|
||||||
try { sub.Cts.Cancel(); }
|
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); }
|
var refs = new HashSet<string>(StringComparer.Ordinal);
|
||||||
try { await sub.Loop.ConfigureAwait(false); }
|
foreach (var r in sourceNodeIds)
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); }
|
if (!string.IsNullOrWhiteSpace(r)) refs.Add(r);
|
||||||
try { sub.Cts.Dispose(); }
|
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); }
|
var sub = new Subscription(new FocasAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
|
||||||
|
lock (_alarmLock)
|
||||||
|
{
|
||||||
|
// Forget only the conditions that LEFT the set (#641) — the SAME set (a reconnect) prunes nothing.
|
||||||
|
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone);
|
||||||
|
// A (re)subscribe follows a reconnect, where the host re-annotates every condition Good (#477); forget
|
||||||
|
// which conditions this projection marked Bad, so a still-failing device re-asserts its Bad.
|
||||||
|
_qualityBadTold.Clear();
|
||||||
|
Volatile.Write(ref _current, sub);
|
||||||
|
}
|
||||||
|
await RestartLoopAsync().ConfigureAwait(false);
|
||||||
|
return sub.Handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Unsubscribes from an alarm subscription.</summary>
|
/// <summary>Drops the live subscription when <paramref name="handle"/> is it; a superseded handle is a no-op.</summary>
|
||||||
/// <param name="handle">Alarm subscription handle.</param>
|
/// <param name="handle">The handle returned by <see cref="SubscribeAsync"/>.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>A task representing the asynchronous operation.</returns>
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle)
|
||||||
{
|
{
|
||||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
lock (_alarmLock)
|
||||||
Subscription? sub;
|
|
||||||
lock (_subsLock)
|
|
||||||
{
|
{
|
||||||
if (!_subs.Remove(h.Id, out sub)) return;
|
if (Volatile.Read(ref _current) is not { } live || !ReferenceEquals(live.Handle, handle)) return;
|
||||||
|
Volatile.Write(ref _current, null);
|
||||||
}
|
}
|
||||||
try { sub.Cts.Cancel(); }
|
await StopLoopAsync().ConfigureAwait(false);
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); }
|
|
||||||
try { await sub.Loop.ConfigureAwait(false); }
|
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); }
|
|
||||||
sub.Cts.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
|
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying condition resolves.
|
||||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
/// The Part 9 Acknowledge is applied server-side; this is the no-op success the host routes it to, so
|
||||||
/// than surfacing a confusing "not supported" error to the operator.
|
/// nothing is ever written to the CNC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="acknowledgements">Alarms to acknowledge.</param>
|
/// <param name="acknowledgements">Alarms to acknowledge.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>A completed task.</returns>
|
/// <returns>A completed task.</returns>
|
||||||
public Task AcknowledgeAsync(
|
public static Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements)
|
||||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
|
||||||
Task.CompletedTask;
|
|
||||||
|
|
||||||
/// <summary>Disposes the alarm projection.</summary>
|
|
||||||
/// <returns>A task representing the asynchronous operation.</returns>
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
{
|
||||||
List<Subscription> snap;
|
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
return Task.CompletedTask;
|
||||||
foreach (var sub in snap)
|
|
||||||
{
|
|
||||||
try { sub.Cts.Cancel(); }
|
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); }
|
|
||||||
try { await sub.Loop.ConfigureAwait(false); }
|
|
||||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); }
|
|
||||||
sub.Cts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
|
/// Stops the poll loop but KEEPS the subscription and the told record — the device/tag tables are about
|
||||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
/// to be rebuilt (re-initialise, shutdown). <see cref="ResumeAsync"/> restarts it.
|
||||||
/// the full Task.Run loop.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sub">Active subscription.</param>
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
/// <param name="deviceHostAddress">Device host address.</param>
|
public Task PauseAsync() => StopLoopAsync();
|
||||||
/// <param name="current">Current alarms from the device.</param>
|
|
||||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
/// <summary>Restarts the poll loop when a subscription is live — after the driver's tables are rebuilt.</summary>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
private async Task RestartLoopAsync()
|
||||||
{
|
{
|
||||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||||
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
|
try
|
||||||
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
|
|
||||||
|
|
||||||
foreach (var a in current)
|
|
||||||
{
|
{
|
||||||
if (prevKeys.Contains(AlarmKey(a))) continue;
|
await StopLoopCoreAsync().ConfigureAwait(false);
|
||||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
if (Volatile.Read(ref _current) is not { } sub) return;
|
||||||
sub.Handle,
|
var cts = new CancellationTokenSource();
|
||||||
SourceNodeId: deviceHostAddress,
|
_loopCts = cts;
|
||||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
_loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), CancellationToken.None);
|
||||||
AlarmType: MapAlarmType(a.Type),
|
|
||||||
Message: a.Message,
|
|
||||||
Severity: MapSeverity(a.Type),
|
|
||||||
SourceTimestampUtc: DateTime.UtcNow));
|
|
||||||
}
|
}
|
||||||
|
finally { _loopGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var a in prev)
|
private async Task StopLoopAsync()
|
||||||
|
{
|
||||||
|
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||||
|
try { await StopLoopCoreAsync().ConfigureAwait(false); }
|
||||||
|
finally { _loopGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels and joins the loop (bounded). Caller holds <see cref="_loopGate"/>.</summary>
|
||||||
|
private async Task StopLoopCoreAsync()
|
||||||
|
{
|
||||||
|
if (_loopCts is not { } cts) return;
|
||||||
|
_loopCts = null;
|
||||||
|
try { cts.Cancel(); }
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling the FOCAS alarm poll loop failed"); }
|
||||||
|
var loop = _loop;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (nowKeys.Contains(AlarmKey(a))) continue;
|
var finished = await Task.WhenAny(loop, Task.Delay(FocasDriver.LoopJoinTimeout)).ConfigureAwait(false);
|
||||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
if (finished != loop)
|
||||||
sub.Handle,
|
_logger.LogWarning("FOCAS alarm poll loop did not stop within {Timeout}; it cannot emit once superseded",
|
||||||
SourceNodeId: deviceHostAddress,
|
FocasDriver.LoopJoinTimeout);
|
||||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
|
||||||
AlarmType: MapAlarmType(a.Type),
|
|
||||||
Message: $"{a.Message} (cleared)",
|
|
||||||
Severity: MapSeverity(a.Type),
|
|
||||||
SourceTimestampUtc: DateTime.UtcNow));
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting the FOCAS alarm poll loop failed"); }
|
||||||
sub.LastByDevice[deviceHostAddress] = [.. current];
|
cts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||||
@@ -203,78 +222,234 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
|
await PollOnceAsync(sub, ct).ConfigureAwait(false);
|
||||||
{
|
|
||||||
Tick(sub, host, alarms);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
/* per-tick failures are non-fatal — next tick retries */
|
|
||||||
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
|
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
try { await Task.Delay(_driver.AlarmPollInterval, ct).ConfigureAwait(false); }
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
/// <summary>One pass: read each device that owns a subscribed alarm tag, and evaluate its conditions.</summary>
|
||||||
|
/// <param name="sub">The subscription the pass serves.</param>
|
||||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
/// <param name="ct">Cancellation.</param>
|
||||||
/// <param name="type">FOCAS alarm type.</param>
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
/// <returns>The mapped alarm type string.</returns>
|
internal async Task PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||||
internal static string MapAlarmType(short type) => type switch
|
|
||||||
{
|
{
|
||||||
FocasAlarmType.Parameter => "Parameter",
|
var tags = _driver.AlarmTags;
|
||||||
FocasAlarmType.PulseCode => "PulseCode",
|
var devices = sub.References
|
||||||
FocasAlarmType.Overtravel => "Overtravel",
|
.Select(r => tags.GetValueOrDefault(r))
|
||||||
FocasAlarmType.Overheat => "Overheat",
|
.OfType<FocasAlarmTag>()
|
||||||
FocasAlarmType.Servo => "Servo",
|
.Select(t => t.DeviceHostAddress)
|
||||||
FocasAlarmType.DataIo => "DataIo",
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
FocasAlarmType.MemoryCheck => "MemoryCheck",
|
.ToList();
|
||||||
FocasAlarmType.MacroAlarm => "MacroAlarm",
|
|
||||||
_ => $"Type{type}",
|
foreach (var host in devices)
|
||||||
};
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
IReadOnlyList<FocasActiveAlarm> alarms;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
alarms = await _driver.ReadDeviceAlarmsAsync(host, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Not "no alarms": keep every condition on this device exactly as the server was last told, but say
|
||||||
|
// why it cannot be read. A transport failure reaches driver health (an alarm-only driver has no other
|
||||||
|
// read to trip the comms-loss watchdog with); a CNC refusal does not (R2). Polling continues at the
|
||||||
|
// normal cadence either way.
|
||||||
|
if (!IsCncRefusal(ex)) _driver.NoteAlarmReadFailed(host, ex);
|
||||||
|
else _driver.NoteAlarmReadRefused(host); // the CNC answered: the transport to it is fine
|
||||||
|
TickFailed(sub, host, tags, ex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_driver.NoteAlarmReadSucceeded(host);
|
||||||
|
Tick(sub, host, alarms, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
|
/// Evaluates every subscribed condition on <paramref name="deviceHostAddress"/> against one SUCCESSFUL read
|
||||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
/// and delivers what changed. Extracted so tests can drive a tick without the loop.
|
||||||
/// at High (everything else on a CNC is safety-relevant).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type">FOCAS alarm type.</param>
|
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
|
||||||
/// <returns>The mapped alarm severity.</returns>
|
/// <param name="deviceHostAddress">The device read.</param>
|
||||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
/// <param name="alarms">Its active alarms.</param>
|
||||||
|
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
|
||||||
|
internal void Tick(
|
||||||
|
Subscription sub,
|
||||||
|
string deviceHostAddress,
|
||||||
|
IReadOnlyList<FocasActiveAlarm> alarms,
|
||||||
|
IReadOnlyDictionary<string, FocasAlarmTag> tags)
|
||||||
{
|
{
|
||||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
var deviceTags = tags.Values
|
||||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
.Where(t => string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
.ToList();
|
||||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
var deviceSelectors = deviceTags.Select(t => t.Selector).ToList();
|
||||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
var truncated = alarms.Count >= Wire.WireFocasClient.MaxAlarmsPerRead;
|
||||||
_ => AlarmSeverity.High,
|
var now = DateTime.UtcNow;
|
||||||
};
|
|
||||||
|
|
||||||
internal sealed class Subscription(
|
lock (_alarmLock)
|
||||||
FocasAlarmSubscriptionHandle handle,
|
{
|
||||||
HashSet<string>? deviceFilter,
|
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
|
||||||
CancellationTokenSource cts)
|
|
||||||
{
|
NoteTruncation(deviceHostAddress, truncated, alarms.Count);
|
||||||
/// <summary>Gets the subscription handle.</summary>
|
NoteUnmatched(deviceHostAddress, alarms, deviceSelectors);
|
||||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
|
||||||
/// <summary>Gets the device filter.</summary>
|
// Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that
|
||||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
// follows still sees the difference and delivers it.
|
||||||
/// <summary>Gets the cancellation token source.</summary>
|
var handlers = _driver.AlarmHandlers;
|
||||||
public CancellationTokenSource Cts { get; } = cts;
|
if (handlers is null) return;
|
||||||
/// <summary>Gets or sets the polling loop task.</summary>
|
|
||||||
public Task Loop { get; set; } = Task.CompletedTask;
|
if (_failingDevices.Remove(deviceHostAddress, out _))
|
||||||
/// <summary>Gets the last seen alarms by device.</summary>
|
_logger.LogInformation("FOCAS alarm read for {Host} recovered", deviceHostAddress);
|
||||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath)))
|
||||||
|
{
|
||||||
|
// Lift a Bad this projection put there BEFORE the normal diff, so a recovery that also changed the
|
||||||
|
// alarm state (cleared during the outage) lands as Good then Clear.
|
||||||
|
if (_qualityBadTold.Remove(tag.RawPath, out _))
|
||||||
|
Deliver(handlers, QualityOnly(tag, sub.Handle, QualityGood, now), tag.RawPath);
|
||||||
|
|
||||||
|
var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated);
|
||||||
|
_told.TryGetValue(tag.RawPath, out var told);
|
||||||
|
var (evt, nowTold) = FocasAlarmConditionMapper.Transition(told, view, tag, sub.Handle, now);
|
||||||
|
_told[tag.RawPath] = nowTold;
|
||||||
|
if (evt is null) continue;
|
||||||
|
Deliver(handlers, evt, tag.RawPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A FAILED read of <paramref name="deviceHostAddress"/> (Gitea #647 review): conditions keep their state, but
|
||||||
|
/// each subscribed one is annotated once per outage through the #626 quality-only path (no Active/Acked change,
|
||||||
|
/// no <c>/alerts</c> row) — <c>BadNoCommunication</c> for a transport failure, <c>BadNotSupported</c> for a CNC
|
||||||
|
/// refusal (R2, <see cref="IsCncRefusal"/>) — and the first failure of the episode (per kind) logs one
|
||||||
|
/// Warning. The next successful read lifts the Bad in <see cref="Tick"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
|
||||||
|
/// <param name="deviceHostAddress">The device whose read failed.</param>
|
||||||
|
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
|
||||||
|
/// <param name="error">Why the read failed.</param>
|
||||||
|
internal void TickFailed(
|
||||||
|
Subscription sub, string deviceHostAddress, IReadOnlyDictionary<string, FocasAlarmTag> tags, Exception error)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
lock (_alarmLock)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
|
||||||
|
|
||||||
|
var refusal = IsCncRefusal(error);
|
||||||
|
var quality = refusal ? FocasStatusMapper.BadNotSupported : QualityBadNoCommunication;
|
||||||
|
if (!_failingDevices.TryGetValue(deviceHostAddress, out var was) || was != quality)
|
||||||
|
{
|
||||||
|
_failingDevices[deviceHostAddress] = quality;
|
||||||
|
if (refusal)
|
||||||
|
_logger.LogWarning(error,
|
||||||
|
"FOCAS CNC {Host} refuses the alarm read (cnc_rdalmmsg2); its alarm conditions keep their state " +
|
||||||
|
"and are marked BadNotSupported. This is a capability answer, not comms loss — driver health is " +
|
||||||
|
"unaffected and polling continues", deviceHostAddress);
|
||||||
|
else
|
||||||
|
_logger.LogWarning(error,
|
||||||
|
"FOCAS alarm read (cnc_rdalmmsg2) failed for {Host}; its alarm conditions keep their state and " +
|
||||||
|
"are marked BadNoCommunication until a read succeeds", deviceHostAddress);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_logger.LogDebug(error, "FOCAS alarm read for {Host} still failing", deviceHostAddress);
|
||||||
|
|
||||||
|
// As for transitions: with nobody listening, record nothing, so the next failure tells it.
|
||||||
|
var handlers = _driver.AlarmHandlers;
|
||||||
|
if (handlers is null) return;
|
||||||
|
|
||||||
|
foreach (var tag in tags.Values.Where(t =>
|
||||||
|
string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& sub.References.Contains(t.RawPath)))
|
||||||
|
{
|
||||||
|
if (_qualityBadTold.TryGetValue(tag.RawPath, out var told) && told == quality) continue;
|
||||||
|
_qualityBadTold[tag.RawPath] = quality;
|
||||||
|
Deliver(handlers, QualityOnly(tag, sub.Handle, quality, now), tag.RawPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A quality-only annotation (#626): <see cref="AlarmTransitionKind.Unspecified"/> + a status code.</summary>
|
||||||
|
private AlarmEventArgs QualityOnly(FocasAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
|
||||||
|
{
|
||||||
|
_told.TryGetValue(tag.RawPath, out var told);
|
||||||
|
return new AlarmEventArgs(handle,
|
||||||
|
SourceNodeId: tag.DeviceHostAddress,
|
||||||
|
ConditionId: tag.RawPath,
|
||||||
|
AlarmType: told?.Winner is { } w ? FocasAlarmConditionMapper.TypeLabel(w.Type) : "CncAlarm",
|
||||||
|
Message: told?.Message ?? "",
|
||||||
|
Severity: told?.Severity ?? AlarmSeverity.Low,
|
||||||
|
SourceTimestampUtc: now,
|
||||||
|
Kind: AlarmTransitionKind.Unspecified,
|
||||||
|
OpcUaSeverity: told?.OpcUaSeverity,
|
||||||
|
QualityStatusCode: quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invokes every handler, isolating a faulting one. Caller holds the lock.</summary>
|
||||||
|
private void Deliver(EventHandler<AlarmEventArgs> handlers, AlarmEventArgs evt, string conditionId)
|
||||||
|
{
|
||||||
|
foreach (var registration in handlers.GetInvocationList())
|
||||||
|
{
|
||||||
|
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(_driver, evt); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"FOCAS driver caught a fault from an alarm subscriber for {ConditionId}; delivery continues.",
|
||||||
|
conditionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Warns once (per episode) when a read came back at the per-read cap. Caller holds the lock.</summary>
|
||||||
|
private void NoteTruncation(string host, bool truncated, int count)
|
||||||
|
{
|
||||||
|
if (!truncated)
|
||||||
|
{
|
||||||
|
_truncationWarned.Remove(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_truncationWarned.Add(host))
|
||||||
|
_logger.LogWarning(
|
||||||
|
"FOCAS device {Host} returned {Count} active alarms — the per-read cap; more may be active and unseen. " +
|
||||||
|
"ALARM:* / ALARM:OTHER conditions on it are marked '[alarm list truncated]'.", host, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs (once per alarm) an active alarm that no condition on its device will show — no <c>*</c>, no
|
||||||
|
/// <c>OTHER</c> and no matching type selector. Caller holds the lock.
|
||||||
|
/// </summary>
|
||||||
|
private void NoteUnmatched(string host, IReadOnlyList<FocasActiveAlarm> alarms, IReadOnlyCollection<FocasAlarmSelector> selectors)
|
||||||
|
{
|
||||||
|
var uncovered = alarms.Where(a => !FocasAlarmMatcher.IsCovered(a, selectors))
|
||||||
|
.Select(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
||||||
|
.ToHashSet();
|
||||||
|
if (!_unmatchedLogged.TryGetValue(host, out var logged))
|
||||||
|
_unmatchedLogged[host] = logged = [];
|
||||||
|
foreach (var key in uncovered.Where(k => !logged.Contains(k)))
|
||||||
|
_logger.LogInformation(
|
||||||
|
"FOCAS device {Host} has active alarm {Number} (type {Type}) that no ALARM: tag selects — author " +
|
||||||
|
"ALARM:OTHER or ALARM:* on the device to surface it.", host, key.Number, key.Type);
|
||||||
|
// Remember exactly the currently-uncovered set, so an alarm that clears and comes back is logged again.
|
||||||
|
_unmatchedLogged[host] = uncovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
|
||||||
|
/// <param name="Handle">The handle.</param>
|
||||||
|
/// <param name="References">The subscribed RawPaths.</param>
|
||||||
|
internal sealed record Subscription(FocasAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
/// <summary>Handle returned by <see cref="FocasDriver.SubscribeAlarmsAsync"/>.</summary>
|
||||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public static class FocasCapabilityMatrix
|
|||||||
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
||||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||||
FocasAreaKind.Fixed => null,
|
FocasAreaKind.Fixed => null,
|
||||||
|
// An ALARM: selector reads the device's whole active-alarm list (cnc_rdalmmsg2), which every
|
||||||
|
// series exposes; the selector filters it driver-side, so there is no range to check (#647).
|
||||||
|
FocasAreaKind.Alarm => null,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
|
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
|
||||||
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
|
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
|
||||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
// Built whole in InitializeAsync and SWAPPED in (Gitea #647 review): the #630-retained poll engine keeps calling
|
||||||
|
// ReadAsync during a re-initialise, and a table cleared and refilled in place reads BadNodeIdUnknown in between.
|
||||||
|
private Dictionary<string, FocasTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||||
// Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single
|
// Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single
|
||||||
// authored-table hit. A miss is a miss — the driver surfaces BadNodeIdUnknown, never a blob-parse.
|
// authored-table hit. A miss is a miss — the driver surfaces BadNodeIdUnknown, never a blob-parse.
|
||||||
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
|
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
|
||||||
@@ -56,7 +58,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// call. ConcurrentDictionary is required because the poll loop
|
// call. ConcurrentDictionary is required because the poll loop
|
||||||
// (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver-
|
// (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver-
|
||||||
// produced equipment tags are encountered for the first time.
|
// produced equipment tags are encountered for the first time.
|
||||||
private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
|
private ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
|
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
|
||||||
@@ -73,7 +75,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// as the way to publish it without authoring anything.
|
/// as the way to publish it without authoring anything.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool _fixedTreeActive;
|
private bool _fixedTreeActive;
|
||||||
private FocasAlarmProjection? _alarmProjection;
|
/// <summary>
|
||||||
|
/// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because
|
||||||
|
/// it holds what the server was last told about every condition, and the server's condition nodes outlive
|
||||||
|
/// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm.
|
||||||
|
/// </summary>
|
||||||
|
private readonly FocasAlarmProjection _alarmProjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RawPath → authored <c>ALARM:</c> tag (Gitea #647). Built in <see cref="InitializeAsync"/> and swapped in
|
||||||
|
/// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter
|
||||||
|
/// <see cref="_tagsByRawPath"/>: they have no value to read.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyDictionary<string, FocasAlarmTag> _alarmTags =
|
||||||
|
new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
||||||
// record is immutable so there is no torn-read risk on the object itself.
|
// record is immutable so there is no torn-read risk on the object itself.
|
||||||
@@ -113,8 +128,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||||
_rebind = rebind;
|
_rebind = rebind;
|
||||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||||
|
_alarmProjection = new FocasAlarmProjection(this, _logger);
|
||||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
r => Volatile.Read(ref _tagsByRawPath).TryGetValue(r, out var t) ? t : null);
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
@@ -184,6 +200,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
|
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
|
||||||
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||||
|
ResetAlarmHealth();
|
||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -218,6 +235,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
||||||
// that fails fast (Driver.FOCAS-003).
|
// that fails fast (Driver.FOCAS-003).
|
||||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||||
|
// A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config
|
||||||
|
// removed — or moved between the value and alarm tables (#647) — must not survive in the old one.
|
||||||
|
// Built into fresh tables and swapped in below, never cleared in place under a live reader.
|
||||||
|
var tagsByRawPath = new Dictionary<string, FocasTagDefinition>(StringComparer.Ordinal);
|
||||||
|
var parsedAddresses = new ConcurrentDictionary<string, FocasAddress>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var alarmTags = new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||||
foreach (var entry in _options.RawTags)
|
foreach (var entry in _options.RawTags)
|
||||||
{
|
{
|
||||||
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||||
@@ -260,7 +283,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
entry.RawPath, def.Address, reason, _driverInstanceId);
|
entry.RawPath, def.Address, reason, _driverInstanceId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_tagsByRawPath[entry.RawPath] = def with
|
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object
|
||||||
|
// is what makes the host materialise a condition at this RawPath, the address is the only thing
|
||||||
|
// this driver can drive one from. Either alone does nothing, so it is skipped and reported.
|
||||||
|
if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null)
|
||||||
|
{
|
||||||
|
if (parsed.Kind == FocasAreaKind.Alarm && def.Alarm is not null)
|
||||||
|
{
|
||||||
|
alarmTags[entry.RawPath] = new FocasAlarmTag(
|
||||||
|
entry.RawPath, deviceHost, parsed.AlarmSelector!, def.Alarm.Severity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var why = parsed.Kind == FocasAreaKind.Alarm
|
||||||
|
? $"'{def.Address}' is an alarm selector but the tag has no 'alarm' object"
|
||||||
|
: $"the tag carries an 'alarm' object but '{def.Address}' is not an ALARM: selector";
|
||||||
|
_skippedTags.Skip(entry.RawPath, why);
|
||||||
|
_logger.LogWarning("FOCAS tag {RawPath}: {Reason}; skipping. Driver={DriverInstanceId}",
|
||||||
|
entry.RawPath, why, _driverInstanceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tagsByRawPath[entry.RawPath] = def with
|
||||||
{
|
{
|
||||||
DeviceHostAddress = deviceHost,
|
DeviceHostAddress = deviceHost,
|
||||||
WriteIdempotent = entry.WriteIdempotent,
|
WriteIdempotent = entry.WriteIdempotent,
|
||||||
@@ -268,12 +310,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
|
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
|
||||||
// hot-path call. The address string has already been validated by FocasAddress.TryParse
|
// hot-path call. The address string has already been validated by FocasAddress.TryParse
|
||||||
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
|
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
|
||||||
_parsedAddressesByTagName[entry.RawPath] = parsed;
|
parsedAddresses[entry.RawPath] = parsed;
|
||||||
}
|
}
|
||||||
// Publish the skip tally only once the whole table is built. An unknown-device throw above
|
// Publish the skip tally only once the whole table is built. An unknown-device throw above
|
||||||
// leaves the previous snapshot standing rather than a truncated one that would read as
|
// leaves the previous snapshot standing rather than a truncated one that would read as
|
||||||
// "we now skip fewer tags".
|
// "we now skip fewer tags".
|
||||||
_skippedTags.Commit();
|
_skippedTags.Commit();
|
||||||
|
Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses);
|
||||||
|
Volatile.Write(ref _tagsByRawPath, tagsByRawPath);
|
||||||
|
Volatile.Write(ref _alarmTags, alarmTags);
|
||||||
|
|
||||||
if (_options.Probe.Enabled)
|
if (_options.Probe.Enabled)
|
||||||
{
|
{
|
||||||
@@ -295,9 +340,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_options.AlarmProjection.Enabled)
|
|
||||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
|
||||||
|
|
||||||
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
||||||
// config flag defaults off and the operator who committed those tags has already said
|
// config flag defaults off and the operator who committed those tags has already said
|
||||||
// what they want.
|
// what they want.
|
||||||
@@ -313,6 +355,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A
|
||||||
|
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
|
||||||
|
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -329,11 +375,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ReleaseDeviceStatesAsync()
|
private async Task ReleaseDeviceStatesAsync()
|
||||||
{
|
{
|
||||||
if (_alarmProjection is { } proj)
|
// Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild.
|
||||||
{
|
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||||
await proj.DisposeAsync().ConfigureAwait(false);
|
|
||||||
_alarmProjection = null;
|
|
||||||
}
|
|
||||||
if (_devices.Count == 0) return;
|
if (_devices.Count == 0) return;
|
||||||
var loops = new List<Task>();
|
var loops = new List<Task>();
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
@@ -386,11 +429,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
if (_alarmProjection is { } proj)
|
// Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it.
|
||||||
{
|
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||||
await proj.DisposeAsync().ConfigureAwait(false);
|
|
||||||
_alarmProjection = null;
|
|
||||||
}
|
|
||||||
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
||||||
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
|
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
|
||||||
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
|
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
|
||||||
@@ -431,15 +471,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
state.DisposeClient();
|
state.DisposeClient();
|
||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByRawPath.Clear();
|
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, FocasTagDefinition>(StringComparer.Ordinal));
|
||||||
|
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
|
||||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||||
_parsedAddressesByTagName.Clear();
|
_parsedAddressesByTagName.Clear();
|
||||||
_fixedTreeActive = false;
|
_fixedTreeActive = false;
|
||||||
|
ResetAlarmHealth();
|
||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public DriverHealth GetHealth() => Volatile.Read(ref _health);
|
/// <remarks>
|
||||||
|
/// The worst of the value path (<see cref="_health"/>) and the alarm path (<see cref="_alarmTransportFailures"/>),
|
||||||
|
/// computed together (Gitea #647 re-review R3). <see cref="DriverHealth.LastSuccessfulRead"/> is the later of
|
||||||
|
/// the two paths' last successes: while either path still reads, the comms-loss watchdog stays quiet; when both
|
||||||
|
/// have stopped it is frozen. Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged.
|
||||||
|
/// </remarks>
|
||||||
|
public DriverHealth GetHealth()
|
||||||
|
{
|
||||||
|
var value = Volatile.Read(ref _health);
|
||||||
|
var ticks = Interlocked.Read(ref _alarmLastSuccessTicks);
|
||||||
|
DateTime? alarmLast = ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||||
|
var last = value.LastSuccessfulRead is { } v && (alarmLast is null || v >= alarmLast) ? v : alarmLast;
|
||||||
|
|
||||||
|
if (value.State is not (DriverState.Healthy or DriverState.Degraded)) return value;
|
||||||
|
if (value.State == DriverState.Healthy && !_alarmTransportFailures.IsEmpty)
|
||||||
|
{
|
||||||
|
var reason = _alarmTransportFailures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).First().Value;
|
||||||
|
return new DriverHealth(DriverState.Degraded, last, reason);
|
||||||
|
}
|
||||||
|
return value.LastSuccessfulRead == last ? value : value with { LastSuccessfulRead = last };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Gets the number of configured devices.</summary>
|
/// <summary>Gets the number of configured devices.</summary>
|
||||||
internal int DeviceCount => _devices.Count;
|
internal int DeviceCount => _devices.Count;
|
||||||
@@ -523,6 +585,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read.
|
||||||
|
if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_resolver.TryResolve(reference, out var def))
|
if (!_resolver.TryResolve(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
@@ -722,6 +791,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
for (var i = 0; i < writes.Count; i++)
|
for (var i = 0; i < writes.Count; i++)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
var w = writes[i];
|
||||||
|
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647).
|
||||||
|
if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!_resolver.TryResolve(w.FullReference, out var def))
|
if (!_resolver.TryResolve(w.FullReference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||||
@@ -1613,57 +1688,100 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// ---- IAlarmSource ----
|
// ---- IAlarmSource ----
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm
|
||||||
|
/// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647).
|
||||||
|
/// Never throws for a disabled flag: authoring an <c>ALARM:</c> tag is the opt-in, and a RawPath that is
|
||||||
|
/// not an <c>ALARM:</c> tag of this driver is simply never raised.
|
||||||
|
/// </remarks>
|
||||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
||||||
{
|
_alarmProjection.SubscribeAsync(sourceNodeIds);
|
||||||
if (_alarmProjection is null)
|
|
||||||
throw new NotSupportedException(
|
|
||||||
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
|
|
||||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
_alarmProjection.UnsubscribeAsync(handle);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>Read-only: nothing is written to the CNC. See <see cref="FocasAlarmProjection.AcknowledgeAsync"/>.</remarks>
|
||||||
public Task AcknowledgeAsync(
|
public Task AcknowledgeAsync(
|
||||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
FocasAlarmProjection.AcknowledgeAsync(acknowledgements);
|
||||||
|
|
||||||
|
/// <summary>Gets the attached alarm handlers, or <see langword="null"/> when nobody is listening.</summary>
|
||||||
|
internal EventHandler<AlarmEventArgs>? AlarmHandlers => OnAlarmEvent;
|
||||||
|
|
||||||
|
/// <summary>Gets the authored <c>ALARM:</c> tags (RawPath → tag). Test seam + projection input.</summary>
|
||||||
|
internal IReadOnlyDictionary<string, FocasAlarmTag> AlarmTags => Volatile.Read(ref _alarmTags);
|
||||||
|
|
||||||
|
/// <summary>Gets the alarm projection. Test seam.</summary>
|
||||||
|
internal FocasAlarmProjection AlarmProjection => _alarmProjection;
|
||||||
|
|
||||||
|
/// <summary>Gets the alarm poll cadence — one <c>cnc_rdalmmsg2</c> per alarm-owning device per tick.</summary>
|
||||||
|
internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval;
|
||||||
|
|
||||||
/// <summary>Raises an alarm event with the provided arguments.</summary>
|
|
||||||
/// <param name="args">The alarm event arguments.</param>
|
|
||||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
|
/// The ALARM-path half of driver health (Gitea #647 re-review R3): device host → why its alarm read is failing
|
||||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
/// at the TRANSPORT. Kept apart from <see cref="_health"/> (the value path) and combined with it in
|
||||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
/// <see cref="GetHealth"/>, so neither path can overwrite the other: a good value read cannot mask a failing
|
||||||
|
/// alarm read (and flip /hosts back and forth every poll), and a good alarm read cannot mask a value-path
|
||||||
|
/// Degraded. A CNC refusal never lands here (R2 — see <see cref="FocasAlarmProjection.IsCncRefusal"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
|
private readonly ConcurrentDictionary<string, string> _alarmTransportFailures = new(StringComparer.OrdinalIgnoreCase);
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
|
/// <summary>UTC ticks of the last successful alarm read (0 = none) — the alarm path's LastSuccessfulRead.</summary>
|
||||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
private long _alarmLastSuccessTicks;
|
||||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
|
||||||
|
/// <summary>
|
||||||
|
/// A successful alarm read of <paramref name="hostAddress"/>: clears that device's alarm-path failure and
|
||||||
|
/// advances the alarm path's LastSuccessfulRead, so an alarm-only driver's health tracks its CNC.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hostAddress">The device read.</param>
|
||||||
|
internal void NoteAlarmReadSucceeded(string hostAddress)
|
||||||
{
|
{
|
||||||
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
|
_alarmTransportFailures.TryRemove(hostAddress, out _);
|
||||||
foreach (var state in _devices.Values)
|
Interlocked.Exchange(ref _alarmLastSuccessTicks, DateTime.UtcNow.Ticks);
|
||||||
{
|
}
|
||||||
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
|
|
||||||
try
|
/// <summary>
|
||||||
{
|
/// The CNC REFUSED the alarm read (R2): the transport is fine, so any alarm-path transport failure of the
|
||||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
/// device is over — but nothing was read, so LastSuccessfulRead does not advance.
|
||||||
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
/// </summary>
|
||||||
result.Add((state.Options.HostAddress, alarms));
|
/// <param name="hostAddress">The device that answered.</param>
|
||||||
}
|
internal void NoteAlarmReadRefused(string hostAddress) => _alarmTransportFailures.TryRemove(hostAddress, out _);
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
|
||||||
catch (Exception ex)
|
/// <summary>
|
||||||
{
|
/// A TRANSPORT failure of an alarm read: the alarm path is Degraded until the device's next good read, and its
|
||||||
/* surface a device-local fault on the next tick */
|
/// LastSuccessfulRead stays frozen — the conjunction the comms-loss watchdog (#527) trips on, which a driver
|
||||||
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
|
/// whose only tags are <c>ALARM:</c> tags otherwise never reached (Gitea #647 review).
|
||||||
}
|
/// </summary>
|
||||||
}
|
/// <param name="hostAddress">The device whose alarm read failed.</param>
|
||||||
return result;
|
/// <param name="error">Why.</param>
|
||||||
|
internal void NoteAlarmReadFailed(string hostAddress, Exception error) =>
|
||||||
|
_alarmTransportFailures[hostAddress] = $"FOCAS alarm read failed for {hostAddress}: {error.Message}";
|
||||||
|
|
||||||
|
/// <summary>Clears the alarm-path health (a fresh device set at initialise / shutdown).</summary>
|
||||||
|
private void ResetAlarmHealth()
|
||||||
|
{
|
||||||
|
_alarmTransportFailures.Clear();
|
||||||
|
Interlocked.Exchange(ref _alarmLastSuccessTicks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
|
||||||
|
/// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
|
||||||
|
/// list (Gitea #647).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hostAddress">The device host address.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The device's active alarms.</returns>
|
||||||
|
internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(hostAddress, out var state))
|
||||||
|
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
|
||||||
|
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||||
|
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- IPerCallHostResolver ----
|
// ---- IPerCallHostResolver ----
|
||||||
|
|||||||
@@ -65,12 +65,18 @@ public interface IFocasClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
||||||
/// zero-or-more active alarms. Null / empty list means "no alarms currently
|
/// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is
|
||||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
/// active". The alarm projection polls this per device and diffs it into Part 9
|
||||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
|
||||||
|
/// <para>
|
||||||
|
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
|
||||||
|
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
|
||||||
|
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||||
|
/// <exception cref="Exception">Any failure to obtain the list — the session is down, the CNC refused, the wire faulted.</exception>
|
||||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -446,27 +452,3 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IFocasClient Create() => throw new NotSupportedException(Message);
|
public IFocasClient Create() => throw new NotSupportedException(Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
|
||||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
|
||||||
/// <c>cnc_rdalmmsg2</c> (and so <see cref="FocasAlarmProjection"/>'s <c>switch (short)</c>
|
|
||||||
/// statements compile against a matching type rather than relying on implicit int→short
|
|
||||||
/// narrowing on the constants).
|
|
||||||
/// </remarks>
|
|
||||||
public static class FocasAlarmType
|
|
||||||
{
|
|
||||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
|
||||||
public const short All = -1;
|
|
||||||
public const short Parameter = 0; // ALM_P
|
|
||||||
public const short PulseCode = 1; // ALM_Y (servo)
|
|
||||||
public const short Overtravel = 2; // ALM_O
|
|
||||||
public const short Overheat = 3; // ALM_H
|
|
||||||
public const short Servo = 4; // ALM_S
|
|
||||||
public const short DataIo = 5; // ALM_T
|
|
||||||
public const short MemoryCheck = 6; // ALM_M
|
|
||||||
public const short MacroAlarm = 13; // ALM_MC
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1101,10 +1101,24 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
return new WireProgramName(name, number);
|
return new WireProgramName(name, number);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
|
/// <summary>
|
||||||
=> payload.Length % 76 == 0
|
/// Decode a <c>cnc_rdalmmsg2</c> payload: the 76-byte vendor stride, else the 80-byte legacy one. An empty
|
||||||
? ParseVendorAlarms(payload, count)
|
/// payload is "no alarms active". A non-empty payload that fits NEITHER stride is malformed and throws
|
||||||
: ParseLegacyWireAlarms(payload, count);
|
/// (Gitea #647 review): it used to fall into the legacy parser, decode zero records, and so read as "no
|
||||||
|
/// alarms" — a mass Clear of every condition on the device, then an un-acknowledged re-raise next poll.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="payload">The reply payload.</param>
|
||||||
|
/// <param name="count">The requested record count.</param>
|
||||||
|
/// <returns>The decoded alarms.</returns>
|
||||||
|
/// <exception cref="FocasWireException">The payload length matches neither record layout.</exception>
|
||||||
|
internal static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
|
||||||
|
{
|
||||||
|
if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
|
||||||
|
if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
|
||||||
|
throw new FocasWireException(
|
||||||
|
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
|
||||||
|
"record layout; refusing to read it as an empty alarm list.", rc: null);
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
|
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,19 +115,21 @@ public sealed class WireFocasClient : IFocasClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Gitea #647: every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
||||||
|
/// alike. This used to answer <c>[]</c> for all three, which the alarm projection could not tell
|
||||||
|
/// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
||||||
|
/// un-acknowledged. <see cref="FocasWireException"/> propagates as-is; a refusal (non-OK rc) is thrown carrying its <c>Rc</c>.
|
||||||
|
/// </remarks>
|
||||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_wire.IsConnected) return [];
|
RequireConnected();
|
||||||
try
|
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
// A non-OK return code is the CNC REFUSING the call over a healthy transport — thrown carrying the rc, so
|
||||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
|
// the alarm projection can tell it from comms loss and keep it out of driver health (#647 re-review R2).
|
||||||
if (!result.IsOk || result.Value is null) return [];
|
if (!result.IsOk)
|
||||||
return result.Value.Select(Map).ToList();
|
throw new FocasWireException($"cnc_rdalmmsg2 refused by the CNC (EW_{result.Rc}).", result.Rc);
|
||||||
}
|
return (result.Value ?? []).Select(Map).ToList();
|
||||||
catch (FocasWireException)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static FocasActiveAlarm Map(WireAlarm a) => new(
|
static FocasActiveAlarm Map(WireAlarm a) => new(
|
||||||
AlarmNumber: a.AlarmNumber,
|
AlarmNumber: a.AlarmNumber,
|
||||||
@@ -468,6 +470,12 @@ public sealed class WireFocasClient : IFocasClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records requested per <c>cnc_rdalmmsg2</c> call. A reply carrying exactly this many may have been
|
||||||
|
/// truncated by the CNC; the alarm projection warns about it (Gitea #647).
|
||||||
|
/// </summary>
|
||||||
|
internal const short MaxAlarmsPerRead = 32;
|
||||||
|
|
||||||
private void RequireConnected()
|
private void RequireConnected()
|
||||||
{
|
{
|
||||||
if (!_wire.IsConnected)
|
if (!_wire.IsConnected)
|
||||||
|
|||||||
@@ -171,14 +171,18 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
|||||||
/// Decode one entry of the initial active-alarm snapshot. Each currently-active
|
/// Decode one entry of the initial active-alarm snapshot. Each currently-active
|
||||||
/// alarm is surfaced as a transition so the OPC UA Part 9 condition layer sees
|
/// alarm is surfaced as a transition so the OPC UA Part 9 condition layer sees
|
||||||
/// the alarm's present state on (re)connect: an unacknowledged active alarm as
|
/// the alarm's present state on (re)connect: an unacknowledged active alarm as
|
||||||
/// a <see cref="GalaxyAlarmTransitionKind.Raise"/>, an acknowledged one as a
|
/// a <see cref="GalaxyAlarmTransitionKind.Retrigger"/>, an acknowledged one as a
|
||||||
/// <see cref="GalaxyAlarmTransitionKind.Acknowledge"/>.
|
/// <see cref="GalaxyAlarmTransitionKind.Acknowledge"/>.
|
||||||
|
/// <para>Retrigger, not Raise (Gitea #647 review): the host treats a Raise on a condition it already
|
||||||
|
/// shows Active as a re-statement that KEEPS the acknowledgement. A snapshot re-states upstream state and
|
||||||
|
/// the upstream's ack state is authoritative, so an unacked entry must un-ack a host condition still
|
||||||
|
/// holding an ack from before the reconnect.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DispatchSnapshotEntry(ActiveAlarmSnapshot snapshot)
|
private void DispatchSnapshotEntry(ActiveAlarmSnapshot snapshot)
|
||||||
{
|
{
|
||||||
var kind = snapshot.CurrentState switch
|
var kind = snapshot.CurrentState switch
|
||||||
{
|
{
|
||||||
AlarmConditionState.Active => GalaxyAlarmTransitionKind.Raise,
|
AlarmConditionState.Active => GalaxyAlarmTransitionKind.Retrigger,
|
||||||
AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.Acknowledge,
|
AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.Acknowledge,
|
||||||
AlarmConditionState.Inactive => GalaxyAlarmTransitionKind.Clear,
|
AlarmConditionState.Inactive => GalaxyAlarmTransitionKind.Clear,
|
||||||
_ => GalaxyAlarmTransitionKind.Unspecified,
|
_ => GalaxyAlarmTransitionKind.Unspecified,
|
||||||
|
|||||||
@@ -1942,12 +1942,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
|
|
||||||
// Fresh prior = the host projector's own default (inactive, acked).
|
// Fresh prior = the host projector's own default (inactive, acked).
|
||||||
var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity);
|
var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity);
|
||||||
var prior = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p)
|
var priorKnown = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p);
|
||||||
? p
|
var prior = priorKnown ? p! : new AlarmToldState(false, true, null, null);
|
||||||
: new AlarmToldState(false, true, null, null);
|
|
||||||
_alarmStateByRawPath[e.ConditionId] = told;
|
_alarmStateByRawPath[e.ConditionId] = told;
|
||||||
|
|
||||||
var kinds = AlarmTransitions((prior.Active, prior.Acked), target);
|
var kinds = AlarmTransitions((prior.Active, prior.Acked), target, priorKnown);
|
||||||
if (kinds.Count == 0)
|
if (kinds.Count == 0)
|
||||||
{
|
{
|
||||||
// Same state: a message/severity change still reaches the host; an identical copy does not.
|
// Same state: a message/severity change still reaches the host; an identical copy does not.
|
||||||
@@ -2066,18 +2065,34 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
||||||
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked, Acknowledge ⇒ acked,
|
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
|
||||||
/// Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
/// 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).
|
/// 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).
|
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="prior">The state last told to the host.</param>
|
/// <param name="prior">The state last told to the host.</param>
|
||||||
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
|
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
|
||||||
|
/// <param name="priorKnown">
|
||||||
|
/// False when this driver instance has told the host nothing about the condition yet (first sight after a
|
||||||
|
/// start or a deploy respawn), so <paramref name="prior"/> is only the projector's default. Gitea #647 review:
|
||||||
|
/// the host now treats a Raise on an already-active condition as a re-statement that KEEPS its ack, so an
|
||||||
|
/// upstream that says <i>unacknowledged</i> must then be told with <see cref="AlarmTransitionKind.Retrigger"/>
|
||||||
|
/// — the upstream's ack state is authoritative, and the host may be holding an ack from before the respawn.
|
||||||
|
/// </param>
|
||||||
/// <returns>The kinds to emit, in order.</returns>
|
/// <returns>The kinds to emit, in order.</returns>
|
||||||
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
|
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
|
||||||
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
|
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
|
||||||
{
|
{
|
||||||
if (prior == target) return [];
|
if (prior == target) return [];
|
||||||
|
var kinds = Transitions(prior, target);
|
||||||
|
return priorKnown || target.Acked
|
||||||
|
? kinds
|
||||||
|
: [.. kinds.Select(k => k == AlarmTransitionKind.Raise ? AlarmTransitionKind.Retrigger : k)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AlarmTransitionKind> Transitions(
|
||||||
|
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
|
||||||
|
{
|
||||||
return target switch
|
return target switch
|
||||||
{
|
{
|
||||||
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
|
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
|
||||||
|
|||||||
+2
-2
@@ -66,13 +66,13 @@
|
|||||||
class="form-check-input" />
|
class="form-check-input" />
|
||||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
<div class="form-text">Legacy flag, no longer required (#647): authoring an <code>ALARM:</code> tag with native-alarm settings is the opt-in.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||||
class="form-control form-control-sm" />
|
class="form-control form-control-sm" />
|
||||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
<div class="form-text">One cnc_rdalmmsg2 call per tick for each device that owns an <code>ALARM:</code> tag. Default 2 s.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+53
-10
@@ -1,6 +1,7 @@
|
|||||||
@* FOCAS address picker:
|
@* FOCAS address picker:
|
||||||
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
||||||
(PARAM:1020) and macro variable (MACRO:500) — always available.
|
(PARAM:1020) and macro variable (MACRO:500) — plus a CNC alarm selector (ALARM:*,
|
||||||
|
ALARM:Servo/401-453, ALARM:OTHER — Gitea #647) — always available.
|
||||||
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||||
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
||||||
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
||||||
@@ -27,9 +28,41 @@
|
|||||||
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
||||||
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
||||||
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
||||||
|
<option value="@FocasManualAddressKind.Alarm">CNC alarm (condition)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (_kind == FocasManualAddressKind.Pmc)
|
@if (_kind == FocasManualAddressKind.Alarm)
|
||||||
|
{
|
||||||
|
@* Gitea #647: an ALARM: selector — one Part 9 condition per tag. The tag must also carry the native-alarm
|
||||||
|
object (alarm section of the tag modal), or it is neither a condition nor a value. *@
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Alarms</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_alarmScope" @bind:after="OnChangedAsync">
|
||||||
|
@foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||||
|
{
|
||||||
|
<option value="@scope">@(scope == "*" ? "* (any on device)" : scope == "OTHER" ? "OTHER (unmatched)" : scope)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (_alarmScope is not ("*" or "OTHER"))
|
||||||
|
{
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Number <span class="text-muted small">(optional)</span></label>
|
||||||
|
<input type="number" class="form-control form-control-sm" min="0" placeholder="any"
|
||||||
|
@bind="_alarmLow" @bind:after="OnChangedAsync" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">to <span class="text-muted small">(optional)</span></label>
|
||||||
|
<input type="number" class="form-control form-control-sm" min="0" placeholder="single"
|
||||||
|
@bind="_alarmHigh" @bind:after="OnChangedAsync" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="col-12 small text-muted">
|
||||||
|
Needs the tag's native-alarm settings enabled. Type names follow the 16i-era FOCAS table; on a
|
||||||
|
30i-series control type a raw code (e.g. <code>ALARM:T9</code>) into the address field instead.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_kind == FocasManualAddressKind.Pmc)
|
||||||
{
|
{
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Area</label>
|
<label class="form-label">Area</label>
|
||||||
@@ -41,12 +74,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="col-md-3">
|
@if (_kind != FocasManualAddressKind.Alarm)
|
||||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
{
|
||||||
<input type="number" class="form-control form-control-sm" min="0"
|
<div class="col-md-3">
|
||||||
@bind="_number" @bind:after="OnChangedAsync" />
|
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||||
</div>
|
<input type="number" class="form-control form-control-sm" min="0"
|
||||||
@if (_kind != FocasManualAddressKind.Macro)
|
@bind="_number" @bind:after="OnChangedAsync" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (_kind is not (FocasManualAddressKind.Macro or FocasManualAddressKind.Alarm))
|
||||||
{
|
{
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
@@ -122,6 +158,9 @@
|
|||||||
private string _pmcLetter = "R";
|
private string _pmcLetter = "R";
|
||||||
private int _number = 0;
|
private int _number = 0;
|
||||||
private int? _bitIndex;
|
private int? _bitIndex;
|
||||||
|
private string _alarmScope = "*";
|
||||||
|
private int? _alarmLow;
|
||||||
|
private int? _alarmHigh;
|
||||||
private string _built = "";
|
private string _built = "";
|
||||||
|
|
||||||
private Guid _token = Guid.Empty;
|
private Guid _token = Guid.Empty;
|
||||||
@@ -135,7 +174,7 @@
|
|||||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||||
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||||
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
_built = Compose();
|
||||||
await CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,10 +216,14 @@
|
|||||||
|
|
||||||
private async Task OnChangedAsync()
|
private async Task OnChangedAsync()
|
||||||
{
|
{
|
||||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
_built = Compose();
|
||||||
await CurrentAddressChanged.InvokeAsync(_built);
|
await CurrentAddressChanged.InvokeAsync(_built);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string Compose() => _kind == FocasManualAddressKind.Alarm
|
||||||
|
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
|
||||||
|
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||||
|
|
||||||
private string TruncatedError() =>
|
private string TruncatedError() =>
|
||||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||||
|
|
||||||
|
|||||||
+34
@@ -18,6 +18,12 @@ public enum FocasManualAddressKind
|
|||||||
|
|
||||||
/// <summary>Macro variable number.</summary>
|
/// <summary>Macro variable number.</summary>
|
||||||
Macro,
|
Macro,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A selector over the CNC's active alarm list (<c>ALARM:</c>, Gitea #647). Only useful on a tag that also
|
||||||
|
/// carries the native-alarm object — the pair is what becomes a Part 9 condition.
|
||||||
|
/// </summary>
|
||||||
|
Alarm,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -43,6 +49,34 @@ public static class FocasAddressBuilder
|
|||||||
public static IReadOnlyList<string> PmcLetters { get; } =
|
public static IReadOnlyList<string> PmcLetters { get; } =
|
||||||
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The alarm scopes the picker offers, in order: the device summary (<c>*</c>), the unmatched bucket
|
||||||
|
/// (<c>OTHER</c>), then every named alarm type. A raw <c>T<code></c> type is typed by hand.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> AlarmScopes { get; } =
|
||||||
|
[FocasAlarmSelector.AnyToken, FocasAlarmSelector.OtherToken, .. FocasAlarmType.Names];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes a canonical <c>ALARM:</c> address, or an empty string when the parts are not a selector the
|
||||||
|
/// driver accepts. Numbers apply to a type scope only; <paramref name="numberHigh"/> without
|
||||||
|
/// <paramref name="numberLow"/> is ignored, and a range equal at both ends collapses to one number.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">An entry of <see cref="AlarmScopes"/>, or a raw type such as <c>T9</c>.</param>
|
||||||
|
/// <param name="numberLow">The alarm number, or the low end of a range; null for every number.</param>
|
||||||
|
/// <param name="numberHigh">The high end of a range; null for a single number.</param>
|
||||||
|
/// <returns>The canonical address, or an empty string.</returns>
|
||||||
|
public static string BuildAlarm(string? scope, int? numberLow, int? numberHigh)
|
||||||
|
{
|
||||||
|
var body = (scope ?? "").Trim();
|
||||||
|
var isType = body.Length > 0 && body != FocasAlarmSelector.AnyToken
|
||||||
|
&& !string.Equals(body, FocasAlarmSelector.OtherToken, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (isType && numberLow is { } lo)
|
||||||
|
body += numberHigh is { } hi ? $"/{lo}-{hi}" : $"/{lo}";
|
||||||
|
|
||||||
|
// The same round-trip as Build: the parser decides, and an unusable selector shows as "no address yet".
|
||||||
|
return FocasAddress.TryParse(FocasAddress.AlarmPrefix + body)?.Canonical ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
||||||
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public sealed class FocasTagConfigModel
|
|||||||
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||||
public string DeviceHostAddress { get; set; } = "";
|
public string DeviceHostAddress { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
|
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>,
|
||||||
|
/// <c>ALARM:Servo/401</c>. Required.</summary>
|
||||||
public string Address { get; set; } = "";
|
public string Address { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>FOCAS atomic data type.</summary>
|
/// <summary>FOCAS atomic data type.</summary>
|
||||||
@@ -98,8 +99,12 @@ public sealed class FocasTagConfigModel
|
|||||||
/// <param name="address">The rejected address.</param>
|
/// <param name="address">The rejected address.</param>
|
||||||
/// <returns>The message.</returns>
|
/// <returns>The message.</returns>
|
||||||
internal static string DescribeUnparseableAddress(string address) =>
|
internal static string DescribeUnparseableAddress(string address) =>
|
||||||
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives (#647).
|
||||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
|
FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||||
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
|
? $"'{address.Trim()}' is not a valid FOCAS alarm address: {alarmError}."
|
||||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
: $"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||||
|
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), a fixed-tree " +
|
||||||
|
"path (FIXED:Identity/SeriesNumber), or a CNC alarm selector (ALARM:*, ALARM:Servo/401-453, " +
|
||||||
|
"ALARM:OTHER). A browse full reference including the device host is " +
|
||||||
|
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1877,12 +1877,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
foreach (var n in nodeIds)
|
foreach (var n in nodeIds)
|
||||||
{
|
{
|
||||||
var nodeId = n.NodeId;
|
var nodeId = n.NodeId;
|
||||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out var restatement);
|
||||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||||
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||||
|
|
||||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A re-statement
|
||||||
if (!serviceAlertsAsPrimary) continue;
|
// (Gitea #647 review: a Raise on an already-active condition, sent by a driver a deploy respawned) is
|
||||||
|
// not a transition — the condition was already Active, so a second "Activated" row would be a duplicate.
|
||||||
|
if (!serviceAlertsAsPrimary || restatement) continue;
|
||||||
|
|
||||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||||
|
|||||||
@@ -12,25 +12,48 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NativeAlarmProjector
|
public sealed class NativeAlarmProjector
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, (bool Active, bool Acked)> _prior = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, (bool Active, bool Acked, string? Message)> _prior = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
||||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||||
/// <param name="e">The native alarm transition.</param>
|
/// <param name="e">The native alarm transition.</param>
|
||||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> (Gitea #647 review): a
|
||||||
|
/// <see cref="AlarmTransitionKind.Raise"/> on a condition already projected Active WITH THE SAME MESSAGE
|
||||||
|
/// (re-review R1 — a different message is a different alarm, and un-acks). That is what a driver
|
||||||
|
/// whose told-record died with its instance sends after a deploy respawns it — the alarm it re-reads is the
|
||||||
|
/// one the server already shows, so the acknowledgement is KEPT and the host writes no second "Activated"
|
||||||
|
/// row. Only <see cref="AlarmTransitionKind.Retrigger"/> (or inactive → active) un-acknowledges; a source
|
||||||
|
/// that knows the alarm is a genuinely new statement must send Retrigger.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||||
|
/// <param name="e">The native alarm transition.</param>
|
||||||
|
/// <param name="restatement">True when <paramref name="e"/> re-stated an already-active condition.</param>
|
||||||
|
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||||
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement)
|
||||||
{
|
{
|
||||||
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
|
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Message: (string?)null);
|
||||||
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||||
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||||
|
// Gitea #647 re-review R1: only a Raise saying the SAME thing the condition already shows is a re-statement.
|
||||||
|
// A respawned driver cannot tell "the alarm I was showing" from "a different one that appeared while I was
|
||||||
|
// down", so a different message is treated as a new statement — un-acked, with its alerts row. Comparing the
|
||||||
|
// message is conservative: any difference (a sources' messages are deterministic for an unchanged statement)
|
||||||
|
// errs toward showing the operator an unacknowledged alarm, never toward hiding one behind a stale ack.
|
||||||
|
restatement = e.Kind == AlarmTransitionKind.Raise && prev.Active
|
||||||
|
&& string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal);
|
||||||
var (active, acked) = e.Kind switch
|
var (active, acked) = e.Kind switch
|
||||||
{
|
{
|
||||||
|
AlarmTransitionKind.Raise when restatement => (true, prev.Acked),
|
||||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||||
AlarmTransitionKind.Acknowledge => (prev.Active, true),
|
AlarmTransitionKind.Acknowledge => (prev.Active, true),
|
||||||
AlarmTransitionKind.Clear => (false, prev.Acked),
|
AlarmTransitionKind.Clear => (false, prev.Acked),
|
||||||
_ => (prev.Active, prev.Acked),
|
_ => (prev.Active, prev.Acked),
|
||||||
};
|
};
|
||||||
_prior[nodeId] = (active, acked);
|
_prior[nodeId] = (active, acked, e.Message);
|
||||||
return new AlarmConditionSnapshot(
|
return new AlarmConditionSnapshot(
|
||||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||||
@@ -48,7 +71,7 @@ public sealed class NativeAlarmProjector
|
|||||||
/// <param name="nodeId">The acknowledged condition node id.</param>
|
/// <param name="nodeId">The acknowledged condition node id.</param>
|
||||||
public void MarkAcknowledged(string nodeId)
|
public void MarkAcknowledged(string nodeId)
|
||||||
{
|
{
|
||||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
|
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true, p.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clears all tracked per-node state.</summary>
|
/// <summary>Clears all tracked per-node state.</summary>
|
||||||
|
|||||||
+39
-17
@@ -154,9 +154,13 @@ public sealed class WireBackendCoverageTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that alarm raise then clear emits both events via the wire backend.</summary>
|
/// <summary>
|
||||||
|
/// Gitea #647 — CNC alarms injected into the mock drive ONE condition per <c>ALARM:</c> tag, keyed by the tag's
|
||||||
|
/// RawPath, with a <see cref="AlarmTransitionKind"/>: an Overtravel alarm raises both <c>ALARM:Overtravel</c>
|
||||||
|
/// and <c>ALARM:*</c>; a second (servo) alarm retriggers only the summary; clearing the list clears both.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
|
public async Task Alarm_selectors_raise_retrigger_and_clear_via_wire_backend()
|
||||||
{
|
{
|
||||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||||
var ct = TestContext.Current.CancellationToken;
|
var ct = TestContext.Current.CancellationToken;
|
||||||
@@ -165,16 +169,15 @@ public sealed class WireBackendCoverageTests
|
|||||||
// Start with no active alarms.
|
// Start with no active alarms.
|
||||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||||
|
|
||||||
|
const string overtravelPath = "Plant/Cnc/m1/overtravel";
|
||||||
|
const string anyPath = "Plant/Cnc/m1/any";
|
||||||
|
|
||||||
var drv = new FocasDriver(new FocasDriverOptions
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||||
RawTags = [],
|
RawTags = [AlarmTag(overtravelPath, "ALARM:Overtravel"), AlarmTag(anyPath, "ALARM:*")],
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
AlarmProjection = new FocasAlarmProjectionOptions
|
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(200) },
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
PollInterval = TimeSpan.FromMilliseconds(200),
|
|
||||||
},
|
|
||||||
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
||||||
|
|
||||||
await using (drv)
|
await using (drv)
|
||||||
@@ -183,27 +186,46 @@ public sealed class WireBackendCoverageTests
|
|||||||
var events = new List<AlarmEventArgs>();
|
var events = new List<AlarmEventArgs>();
|
||||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||||
|
|
||||||
var sub = await drv.SubscribeAlarmsAsync([], ct);
|
var sub = await drv.SubscribeAlarmsAsync([overtravelPath, anyPath], ct);
|
||||||
|
|
||||||
|
await _fx.PatchStateAsync(new
|
||||||
|
{
|
||||||
|
alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" } },
|
||||||
|
}, ct);
|
||||||
|
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Raise) == 2, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// Raise one alarm.
|
|
||||||
await _fx.PatchStateAsync(new
|
await _fx.PatchStateAsync(new
|
||||||
{
|
{
|
||||||
alarms = new[]
|
alarms = new[]
|
||||||
{
|
{
|
||||||
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||||
|
new { alm_no = 401, type = 4, axis = 2, msg = "TEST SERVO" },
|
||||||
},
|
},
|
||||||
}, ct);
|
}, ct);
|
||||||
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
|
await WaitFor(() => Snapshot(events).Any(e => e.Kind == AlarmTransitionKind.Retrigger), TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// Clear.
|
|
||||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
|
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Clear) == 2, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
||||||
|
|
||||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
var all = Snapshot(events);
|
||||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
all.Where(e => e.ConditionId == overtravelPath).Select(e => e.Kind).ToArray()
|
||||||
events[0].SourceNodeId.ShouldBe(DeviceHost);
|
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear });
|
||||||
|
all.Where(e => e.ConditionId == anyPath).Select(e => e.Kind).ToArray()
|
||||||
|
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear });
|
||||||
|
all[0].AlarmType.ShouldBe("Overtravel");
|
||||||
|
all[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||||
|
all.ShouldAllBe(e => e.SourceNodeId == DeviceHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
RawTagEntry AlarmTag(string rawPath, string address) => new(rawPath,
|
||||||
|
"{\"address\":\"" + address + "\",\"dataType\":\"Int32\",\"alarm\":{\"alarmType\":\"AlarmCondition\"}}",
|
||||||
|
WriteIdempotent: false, DeviceName: DeviceHost);
|
||||||
|
|
||||||
|
static List<AlarmEventArgs> Snapshot(List<AlarmEventArgs> events)
|
||||||
|
{
|
||||||
|
lock (events) return [.. events];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,33 @@ internal class FakeFocasClient : IFocasClient
|
|||||||
/// <summary>Gets the list of active alarms.</summary>
|
/// <summary>Gets the list of active alarms.</summary>
|
||||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Gets or sets a value indicating whether the alarm read throws (Gitea #647: a failed read).</summary>
|
||||||
|
public bool ThrowOnReadAlarms { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets an exception the alarm read throws (e.g. a CNC refusal carrying an rc).</summary>
|
||||||
|
public Exception? AlarmReadException { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets the number of alarm reads served (successful or not).</summary>
|
||||||
|
public int AlarmReadCount => Volatile.Read(ref _alarmReadCount);
|
||||||
|
|
||||||
|
private int _alarmReadCount;
|
||||||
|
|
||||||
|
/// <summary>Replaces the active alarm list atomically with respect to the poll thread.</summary>
|
||||||
|
/// <param name="alarms">The new active alarms.</param>
|
||||||
|
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||||
|
{
|
||||||
|
lock (Alarms) { Alarms.Clear(); Alarms.AddRange(alarms); }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Reads active alarms asynchronously.</summary>
|
/// <summary>Reads active alarms asynchronously.</summary>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct)
|
||||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
{
|
||||||
|
Interlocked.Increment(ref _alarmReadCount);
|
||||||
|
if (AlarmReadException is { } refusal) throw refusal;
|
||||||
|
if (ThrowOnReadAlarms) throw new InvalidOperationException("cnc_rdalmmsg2 failed (fake)");
|
||||||
|
lock (Alarms) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Fixed-tree T1 ----
|
// ---- Fixed-tree T1 ----
|
||||||
/// <summary>Gets or sets the system information.</summary>
|
/// <summary>Gets or sets the system information.</summary>
|
||||||
|
|||||||
@@ -1,196 +1,668 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 — FOCAS CNC alarms as Part 9 conditions at <c>ALARM:</c> raw tags. Every event must carry
|
||||||
|
/// <c>ConditionId == RawPath</c> and a <see cref="AlarmTransitionKind"/>, or the host drops / ignores it.
|
||||||
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class FocasAlarmProjectionTests
|
public sealed class FocasAlarmProjectionTests
|
||||||
{
|
{
|
||||||
private const string Host = "focas://10.0.0.5:8193";
|
private const string Host = "focas://10.0.0.5:8193";
|
||||||
|
private const string Host2 = "focas://10.0.0.6:8193";
|
||||||
|
private const string ServoPath = "Plant/Cnc/m1/servo";
|
||||||
|
private const string AnyPath = "Plant/Cnc/m1/any";
|
||||||
|
private const string OtherPath = "Plant/Cnc/m1/other";
|
||||||
|
|
||||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
private static FocasActiveAlarm Servo401(short axis = 1) =>
|
||||||
|
new(401, FocasAlarmType.Servo, axis, "SERVO V-READY OFF");
|
||||||
|
|
||||||
|
private static FocasActiveAlarm Overtravel506 => new(506, FocasAlarmType.Overtravel, 2, "OVER TRAVEL +2");
|
||||||
|
|
||||||
|
private static FocasActiveAlarm Overheat700 => new(700, FocasAlarmType.Overheat, 0, "OVERHEAT: CONTROL UNIT");
|
||||||
|
|
||||||
|
private static string AlarmTagConfig(string address, int? severity = null) =>
|
||||||
|
JsonSerializer.Serialize(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["address"] = address,
|
||||||
|
["dataType"] = "Int32",
|
||||||
|
["alarm"] = severity is { } s
|
||||||
|
? new Dictionary<string, object> { ["alarmType"] = "AlarmCondition", ["severity"] = s }
|
||||||
|
: new Dictionary<string, object> { ["alarmType"] = "AlarmCondition" },
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
|
||||||
|
params RawTagEntry[] tags) => NewDriver([new FocasDeviceOptions(Host, DeviceName: "m1")], tags);
|
||||||
|
|
||||||
|
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
|
||||||
|
IReadOnlyList<FocasDeviceOptions> devices, params RawTagEntry[] tags)
|
||||||
{
|
{
|
||||||
var factory = new FakeFocasClientFactory();
|
var factory = new FakeFocasClientFactory();
|
||||||
var drv = new FocasDriver(new FocasDriverOptions
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new FocasDeviceOptions(Host)],
|
Devices = [.. devices],
|
||||||
RawTags = [],
|
RawTags = [.. tags],
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
AlarmProjection = new FocasAlarmProjectionOptions
|
// Enabled is left at its default (false) on purpose: authoring an ALARM: tag is the opt-in (#647).
|
||||||
{
|
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||||
Enabled = alarmsEnabled,
|
|
||||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
|
||||||
},
|
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
return (drv, factory);
|
return (drv, factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that subscribe without enable throws NotSupported.</summary>
|
private static RawTagEntry Tag(string rawPath, string address, int? severity = null, string device = "m1") =>
|
||||||
[Fact]
|
new(rawPath, AlarmTagConfig(address, severity), WriteIdempotent: false, DeviceName: device);
|
||||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
|
||||||
|
private static FakeFocasClient ClientOf(FakeFocasClientFactory factory) => factory.Clients.Single();
|
||||||
|
|
||||||
|
/// <summary>Drives one tick for the driver's live subscription, the way the poll loop would.</summary>
|
||||||
|
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
|
||||||
|
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
|
||||||
|
|
||||||
|
private static List<AlarmEventArgs> Capture(FocasDriver drv, bool transitionsOnly = false)
|
||||||
{
|
{
|
||||||
var (drv, _) = NewDriver(alarmsEnabled: false);
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
||||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that raise then clear emits both events.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task Raise_then_clear_emits_both_events()
|
|
||||||
{
|
|
||||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
|
||||||
factory.Customise = () => new FakeFocasClient();
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
||||||
|
|
||||||
var events = new List<AlarmEventArgs>();
|
var events = new List<AlarmEventArgs>();
|
||||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
drv.OnAlarmEvent += (_, e) =>
|
||||||
|
{
|
||||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
if (transitionsOnly && e.IsQualityOnly) return;
|
||||||
|
lock (events) events.Add(e);
|
||||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
};
|
||||||
// poke the alarm list so we don't race the poll loop.
|
return events;
|
||||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
|
||||||
var client = factory.Clients[0];
|
|
||||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
|
||||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
|
||||||
|
|
||||||
// Clear — the clear event wraps the original message with "(cleared)".
|
|
||||||
client.Alarms.Clear();
|
|
||||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
|
||||||
|
|
||||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
|
||||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
|
||||||
events[0].SourceNodeId.ShouldBe(Host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
|
/// A driver whose loop is not polling, so ticks are driven by the test only. The subscription is real; the
|
||||||
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first
|
/// loop is paused right after so its first read cannot race the hand-driven ticks.
|
||||||
/// calling Unsubscribe, and the driver object (and its projection) survives the in-place
|
/// </summary>
|
||||||
/// reconnect. Each SubscribeAlarmsAsync used to start a fresh, never-cancelled poll loop —
|
private static async Task<FocasDriver> InitAndSubscribeForTicksAsync(
|
||||||
/// so after N reconnects there were N live loops all polling the device and all firing the
|
FocasDriver drv, FakeFocasClientFactory factory, params string[] refs)
|
||||||
/// same raise/clear transition, producing DUPLICATE alarm events.
|
{
|
||||||
///
|
// The real loop still starts on every subscribe; a CNC whose alarm read always fails makes its ticks
|
||||||
/// This test simulates two re-subscribes (one reconnect) against the same source, then
|
// no-ops (a failed read keeps state), so only the hand-driven ticks below move anything.
|
||||||
/// drives ONE device raise. After the collapse-to-single-loop fix exactly one loop is alive
|
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
|
||||||
/// so the raise must fire exactly once. Before the fix both loops fire it ⇒ two events.
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.SubscribeAlarmsAsync(refs, CancellationToken.None);
|
||||||
|
await drv.AlarmProjection.PauseAsync();
|
||||||
|
return drv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the transition contract ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Raise_then_unchanged_then_clear_carries_RawPath_and_Kind()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
Tick(drv);
|
||||||
|
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
events.ShouldAllBe(e => e.ConditionId == ServoPath && e.SourceNodeId == Host);
|
||||||
|
events[0].AlarmType.ShouldBe("Servo");
|
||||||
|
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axis 1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Overlapping_selectors_all_fire_and_OTHER_only_takes_what_no_type_selector_claims()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"), Tag(OtherPath, "ALARM:OTHER"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath, OtherPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath },
|
||||||
|
customMessage: "the servo alarm drives both ALARM:Servo and ALARM:*; ALARM:* does not claim it away from OTHER, but ALARM:Servo does");
|
||||||
|
|
||||||
|
events.Clear();
|
||||||
|
Tick(drv, Servo401(), Overheat700);
|
||||||
|
events.ShouldContain(e => e.ConditionId == OtherPath && e.Kind == AlarmTransitionKind.Raise,
|
||||||
|
"an overheat alarm no type selector claims lands in OTHER");
|
||||||
|
events.ShouldContain(e => e.ConditionId == AnyPath && e.Kind == AlarmTransitionKind.Retrigger);
|
||||||
|
events.ShouldNotContain(e => e.ConditionId == ServoPath, "ALARM:Servo's matched set did not change");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_new_key_retriggers_while_a_non_displayed_member_leaving_emits_nothing()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Servo401()); // Raise — Servo is the winner (Critical)
|
||||||
|
Tick(drv, Servo401(), Overheat700); // Retrigger — a key the operator has not seen
|
||||||
|
Tick(drv, Servo401()); // the non-displayed Overheat left: nothing
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger]);
|
||||||
|
events[1].Message.ShouldEndWith("[+1 more]");
|
||||||
|
|
||||||
|
events.Clear();
|
||||||
|
Tick(drv, Servo401(), Overheat700); // Overheat back: new to the set again ⇒ Retrigger
|
||||||
|
Tick(drv, Overheat700); // the DISPLAYED alarm left: retrigger with the next one down
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Retrigger]);
|
||||||
|
events[1].AlarmType.ShouldBe("Overheat");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reordered_list_never_re_raises_and_the_displayed_alarm_is_the_most_severe()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Overheat700, Servo401(), Overtravel506);
|
||||||
|
Tick(drv, Overtravel506, Servo401(), Overheat700);
|
||||||
|
Tick(drv, Servo401(), Overheat700, Overtravel506);
|
||||||
|
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
// Overtravel and Servo are both Critical; the tie goes to the lower type code (Overtravel = 2).
|
||||||
|
events[0].AlarmType.ShouldBe("Overtravel");
|
||||||
|
events[0].Message.ShouldStartWith("506 OVER TRAVEL +2 (type 2 Overtravel, axis 2)");
|
||||||
|
events[0].Message.ShouldEndWith("[+2 more]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task One_alarm_number_on_two_axes_is_one_alarm_listing_both_axes()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo/401-453"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Servo401(axis: 2), Servo401(axis: 1));
|
||||||
|
Tick(drv, Servo401(axis: 1)); // the same key on fewer axes is the same alarm: nothing
|
||||||
|
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axes 1, 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Severity_is_the_authored_value_when_present_else_the_type_bucket()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo", severity: 350), Tag(AnyPath, "ALARM:*"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
|
||||||
|
var authored = events.Single(e => e.ConditionId == ServoPath);
|
||||||
|
authored.OpcUaSeverity.ShouldBe(350);
|
||||||
|
authored.Severity.ShouldBe(AlarmSeverity.Low);
|
||||||
|
|
||||||
|
var derived = events.Single(e => e.ConditionId == AnyPath);
|
||||||
|
derived.OpcUaSeverity.ShouldBeNull();
|
||||||
|
derived.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||||
|
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||||
|
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||||
|
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||||
|
FocasAlarmConditionMapper.TypeSeverity(42).ShouldBe(AlarmSeverity.High, "an unknown code is High, not ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unnamed_type_code_is_shown_raw_so_it_can_be_selected()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"), Tag(ServoPath, "ALARM:T9"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath, ServoPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
Tick(drv, new FocasActiveAlarm(750, 9, 0, "SPINDLE ALARM"));
|
||||||
|
|
||||||
|
events.Count.ShouldBe(2);
|
||||||
|
events.ShouldAllBe(e => e.AlarmType == "Type9" && e.Message == "750 SPINDLE ALARM (type 9)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_superseded_subscription_emits_nothing()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
var stale = drv.AlarmProjection.Current!;
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await drv.AlarmProjection.PauseAsync();
|
||||||
|
var events = Capture(drv);
|
||||||
|
|
||||||
|
drv.AlarmProjection.Tick(stale, Host, [Servo401()], drv.AlarmTags);
|
||||||
|
|
||||||
|
events.ShouldBeEmpty("a retired loop finishing its last read must not deliver");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the told record --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Nothing_is_recorded_while_no_handler_is_attached()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
|
||||||
|
Tick(drv, Servo401()); // nobody listening (the host detached while reconnecting)
|
||||||
|
var events = Capture(drv);
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise],
|
||||||
|
"the raise that happened while detached is delivered once a handler is back");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_same_set_resubscribe_re_raises_nothing_and_a_changed_set_prunes_only_the_leavers()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.Count.ShouldBe(2);
|
||||||
|
|
||||||
|
// A reconnect: the actor re-subscribes the SAME set.
|
||||||
|
events.Clear();
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
|
||||||
|
await drv.AlarmProjection.PauseAsync();
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.ShouldBeEmpty("re-raising an unchanged alarm would un-acknowledge it");
|
||||||
|
|
||||||
|
// A deploy drops AnyPath, then a later one brings it back: only AnyPath is told again.
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
|
||||||
|
await drv.AlarmProjection.PauseAsync();
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.Select(e => e.ConditionId).ShouldBe([AnyPath]);
|
||||||
|
events[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the poll loop, end to end over the fake client -------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 defect 4 — a failed read keeps state. Pincer: a throwing read must NOT clear, and (the
|
||||||
|
/// falsification control) a genuinely empty read MUST — so the first half cannot pass vacuously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Resubscribe_collapses_to_single_loop_no_duplicate_raise()
|
public async Task A_failed_read_keeps_the_condition_active_while_an_empty_read_clears_it()
|
||||||
{
|
{
|
||||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
factory.Customise = () => new FakeFocasClient();
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
// Transitions only: the outage's quality-only BadNoCommunication annotation is pinned separately.
|
||||||
|
var events = Capture(drv, transitionsOnly: true);
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 0);
|
||||||
|
var client = ClientOf(factory);
|
||||||
|
|
||||||
|
client.SetAlarms(Servo401());
|
||||||
|
await WaitFor(() => Count(events) == 1);
|
||||||
|
|
||||||
|
client.ThrowOnReadAlarms = true;
|
||||||
|
var readsAtFailure = client.AlarmReadCount;
|
||||||
|
await WaitFor(() => client.AlarmReadCount >= readsAtFailure + 5);
|
||||||
|
Count(events).ShouldBe(1, "a failed read is not an empty list");
|
||||||
|
|
||||||
|
client.ThrowOnReadAlarms = false;
|
||||||
|
client.SetAlarms();
|
||||||
|
await WaitFor(() => Count(events) == 2);
|
||||||
|
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reinitialise_neither_re_raises_nor_loses_the_subscription()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var events = Capture(drv);
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 0);
|
||||||
|
factory.Clients[0].SetAlarms(Servo401());
|
||||||
|
await WaitFor(() => Count(events) == 1);
|
||||||
|
|
||||||
|
// A re-initialise without teardown (#630) builds a fresh client; the CNC still reports the alarm.
|
||||||
|
factory.Customise = () =>
|
||||||
|
{
|
||||||
|
var c = new FakeFocasClient();
|
||||||
|
c.SetAlarms(Servo401());
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 1 && factory.Clients[^1].AlarmReadCount >= 3);
|
||||||
|
Count(events).ShouldBe(1, "the server was already told; a re-raise would un-acknowledge it");
|
||||||
|
|
||||||
|
factory.Clients[^1].SetAlarms();
|
||||||
|
await WaitFor(() => Count(events) == 2);
|
||||||
|
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear, "the poll resumed after the re-initialise");
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Only_devices_that_own_a_subscribed_alarm_tag_are_polled()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
[new FocasDeviceOptions(Host, DeviceName: "m1"), new FocasDeviceOptions(Host2, DeviceName: "m2")],
|
||||||
|
Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
|
||||||
|
|
||||||
|
factory.Clients.Count.ShouldBe(1, "the second device owns no alarm tag, so it is never connected for alarms");
|
||||||
|
drv.GetDeviceState(Host2)!.Client.ShouldBeNull();
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribing_with_the_projection_flag_off_does_not_throw()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var raises = new List<AlarmEventArgs>();
|
var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
drv.OnAlarmEvent += (_, e) =>
|
|
||||||
{
|
|
||||||
// Count only the raise (the initial 0->1) — clears wrap the message in "(cleared)".
|
|
||||||
if (!e.Message.Contains("cleared")) lock (raises) raises.Add(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
// First subscribe creates + connects the per-device client and starts polling. Wait for
|
handle.ShouldNotBeNull();
|
||||||
// it + a couple empty-list ticks so the loop's LastByDevice baseline is seeded empty.
|
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||||
var sub1 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
|
||||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
|
||||||
await Task.Delay(120);
|
|
||||||
|
|
||||||
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reuses the same
|
|
||||||
// connected DeviceState.Client (EnsureConnectedAsync), so it polls the same alarm list.
|
|
||||||
var sub2 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
|
||||||
await Task.Delay(120); // let sub2's loop seed its own empty LastByDevice baseline
|
|
||||||
|
|
||||||
// One device raise transition. Add to every created client so that whichever instance the
|
|
||||||
// live loop(s) poll, the raise is observed (EnsureConnectedAsync may have churned clients).
|
|
||||||
foreach (var c in factory.Clients)
|
|
||||||
c.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
|
||||||
|
|
||||||
// Wait until at least one raise is observed, then give any leaked second loop ample time
|
|
||||||
// (well past several 30ms poll intervals) to ALSO fire its duplicate before we assert.
|
|
||||||
await WaitFor(() => { lock (raises) return raises.Count > 0; }, TimeSpan.FromSeconds(3));
|
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
|
||||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
lock (raises)
|
// ---- authoring at init ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Alarm_tags_are_conditions_not_values_and_half_authored_ones_are_skipped_and_reported()
|
||||||
|
{
|
||||||
|
var noObject = new RawTagEntry("Plant/Cnc/m1/noobj",
|
||||||
|
JsonSerializer.Serialize(new { address = "ALARM:Servo", dataType = "Int32" }), false, "m1");
|
||||||
|
var pmcWithObject = new RawTagEntry("Plant/Cnc/m1/pmc",
|
||||||
|
JsonSerializer.Serialize(new { address = "R100", dataType = "Byte", alarm = new { severity = 700 } }), false, "m1");
|
||||||
|
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo/401", severity: 800), noObject, pmcWithObject);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.AlarmTags.Keys.ShouldBe([ServoPath]);
|
||||||
|
drv.AlarmTags[ServoPath].AuthoredSeverity.ShouldBe(800);
|
||||||
|
drv.AlarmTags[ServoPath].Selector.Canonical.ShouldBe("Servo/401");
|
||||||
|
|
||||||
|
var skipped = drv.GetSkippedTags()!;
|
||||||
|
skipped.Sample.Select(s => s.RawPath).Order().ToArray().ShouldBe(new[] { "Plant/Cnc/m1/noobj", "Plant/Cnc/m1/pmc" });
|
||||||
|
|
||||||
|
var read = await drv.ReadAsync([ServoPath], CancellationToken.None);
|
||||||
|
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||||
|
var write = await drv.WriteAsync([new WriteRequest(ServoPath, 1)], CancellationToken.None);
|
||||||
|
write[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A re-initialise without teardown (#630) adopting a config that turns a value tag into an alarm tag, or
|
||||||
|
/// drops it, must not leave the old value-table entry serving reads.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reinitialise_rebuilds_the_value_and_alarm_tables_from_scratch()
|
||||||
|
{
|
||||||
|
const string path = "Plant/Cnc/m1/t";
|
||||||
|
FocasDriverOptions Options(params RawTagEntry[] tags) => new()
|
||||||
{
|
{
|
||||||
raises.Count.ShouldBe(1,
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||||
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
|
RawTags = [.. tags],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
var asValue = Options(new RawTagEntry(path, """{"address":"R100","dataType":"Byte"}""", false, "m1"));
|
||||||
|
var asAlarm = Options(Tag(path, "ALARM:*"));
|
||||||
|
var removed = Options();
|
||||||
|
var factory = new FakeFocasClientFactory();
|
||||||
|
var drv = new FocasDriver(asValue, "drv-1", factory,
|
||||||
|
rebind: json => new FocasDriverBinding(json switch
|
||||||
|
{
|
||||||
|
"""{"v":"alarm"}""" => asAlarm,
|
||||||
|
"""{"v":"removed"}""" => removed,
|
||||||
|
_ => asValue,
|
||||||
|
}, factory));
|
||||||
|
|
||||||
|
await drv.InitializeAsync("""{"v":"value"}""", CancellationToken.None);
|
||||||
|
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("""{"v":"alarm"}""", CancellationToken.None);
|
||||||
|
drv.AlarmTags.Keys.ShouldBe([path]);
|
||||||
|
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("""{"v":"removed"}""", CancellationToken.None);
|
||||||
|
drv.AlarmTags.ShouldBeEmpty();
|
||||||
|
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown,
|
||||||
|
"a tag the new config removed must not keep reading");
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gitea #647 review MAJOR-2: an unreachable alarm source must say so ----------------------------------
|
||||||
|
|
||||||
|
private const uint BadNoCommunication = 0x80310000u;
|
||||||
|
|
||||||
|
private static void TickFailed(FocasDriver drv) =>
|
||||||
|
drv.AlarmProjection.TickFailed(drv.AlarmProjection.Current!, Host, drv.AlarmTags, new InvalidOperationException("down"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A failed read marks each subscribed condition BadNoCommunication ONCE per outage, quality-only (no Clear,
|
||||||
|
/// no state change); recovery lifts it to Good and, with the alarm unchanged, re-raises nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failed_read_annotates_quality_once_and_recovery_lifts_it_without_a_re_raise()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.Clear();
|
||||||
|
|
||||||
|
TickFailed(drv);
|
||||||
|
TickFailed(drv);
|
||||||
|
events.Count.ShouldBe(2, "one Bad per condition for the whole outage");
|
||||||
|
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == BadNoCommunication);
|
||||||
|
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath });
|
||||||
|
|
||||||
|
events.Clear();
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
events.Count.ShouldBe(2);
|
||||||
|
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == 0u, "recovery lifts the Bad and says nothing else");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_recovery_that_finds_the_alarm_cleared_lifts_quality_then_clears()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
var events = Capture(drv);
|
||||||
|
Tick(drv, Servo401());
|
||||||
|
TickFailed(drv);
|
||||||
|
events.Clear();
|
||||||
|
|
||||||
|
Tick(drv);
|
||||||
|
|
||||||
|
events.Select(e => (e.Kind, e.QualityStatusCode)).ToArray().ShouldBe(new (AlarmTransitionKind, uint?)[]
|
||||||
|
{
|
||||||
|
(AlarmTransitionKind.Unspecified, 0u),
|
||||||
|
(AlarmTransitionKind.Clear, null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_persistently_failing_alarm_read_warns_once_per_outage()
|
||||||
|
{
|
||||||
|
var logger = new ListLogger();
|
||||||
|
var factory = new FakeFocasClientFactory();
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||||
|
RawTags = [Tag(ServoPath, "ALARM:Servo")],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory, logger);
|
||||||
|
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||||
|
Capture(drv);
|
||||||
|
var before = logger.Warnings();
|
||||||
|
|
||||||
|
TickFailed(drv);
|
||||||
|
TickFailed(drv);
|
||||||
|
TickFailed(drv);
|
||||||
|
logger.Warnings().ShouldBe(before + 1);
|
||||||
|
|
||||||
|
Tick(drv); // recovery ends the episode…
|
||||||
|
TickFailed(drv); // …so the next outage warns again
|
||||||
|
logger.Warnings().ShouldBe(before + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An alarm-only driver must be able to trip the comms-loss watchdog (#527): sustained alarm-read failure
|
||||||
|
/// goes non-Healthy with LastSuccessfulRead FROZEN; a successful alarm read advances it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Sustained_alarm_read_failure_degrades_health_with_LastSuccessfulRead_frozen()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||||
|
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var atInit = drv.GetHealth().LastSuccessfulRead;
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
|
||||||
|
|
||||||
|
var failing = drv.GetHealth();
|
||||||
|
failing.State.ShouldBe(DriverState.Degraded);
|
||||||
|
failing.LastSuccessfulRead.ShouldBe(atInit, "a failed read never advances LastSuccessfulRead");
|
||||||
|
|
||||||
|
factory.Clients[0].ThrowOnReadAlarms = false;
|
||||||
|
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy);
|
||||||
|
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<FocasDriver>
|
||||||
|
{
|
||||||
|
private int _warnings;
|
||||||
|
|
||||||
|
public int Warnings() => Volatile.Read(ref _warnings);
|
||||||
|
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||||
|
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning
|
||||||
|
&& formatter(state, exception).Contains("cnc_rdalmmsg2", StringComparison.Ordinal))
|
||||||
|
Interlocked.Increment(ref _warnings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
|
// ---- Gitea #647 re-review R1–R3 -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R1 — the host treats a respawned driver's Raise as a re-statement only when its message matches what the
|
||||||
|
/// condition shows, so the message must be a deterministic function of the matched SET: two fresh driver
|
||||||
|
/// instances reading the same alarms in a different wire (and axis) order say exactly the same thing, and a
|
||||||
|
/// different set says something different.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
|
public async Task The_condition_message_is_deterministic_for_the_same_alarm_set_across_driver_instances()
|
||||||
{
|
{
|
||||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
async Task<string> FirstRaiseMessage(params FocasActiveAlarm[] alarms)
|
||||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
{
|
||||||
// subscription — simpler than standing up the full loop.
|
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
|
||||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
|
||||||
factory.Customise = () => new FakeFocasClient();
|
var events = Capture(drv);
|
||||||
|
Tick(drv, alarms);
|
||||||
|
return events.Single().Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
var a = await FirstRaiseMessage(Servo401(axis: 2), Overheat700, Servo401(axis: 1), Overtravel506);
|
||||||
|
var b = await FirstRaiseMessage(Overtravel506, Servo401(axis: 1), Overheat700, Servo401(axis: 2));
|
||||||
|
b.ShouldBe(a);
|
||||||
|
(await FirstRaiseMessage(Overtravel506, Servo401(axis: 1))).ShouldNotBe(a, "a different set must not read as a re-statement");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Wire.FocasWireException CncRefusal() => new("cnc_rdalmmsg2 refused EW_FUNC", rc: 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R2 — a CNC REFUSAL of cnc_rdalmmsg2 (an rc; the transport is fine) is a capability answer, not comms loss:
|
||||||
|
/// health stays Healthy (so the #527 watchdog can never reconnect-loop a production control), the conditions
|
||||||
|
/// are annotated BadNotSupported once, one Warning is logged, and polling continues at the normal cadence.
|
||||||
|
/// Falsification control: a TRANSPORT failure on the same driver does degrade.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_CNC_refusal_marks_conditions_not_supported_without_degrading_health()
|
||||||
|
{
|
||||||
|
var logger = new ListLogger();
|
||||||
|
var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { AlarmReadException = CncRefusal() } };
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||||
|
RawTags = [Tag(ServoPath, "ALARM:Servo")],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||||
|
}, "drv-1", factory, logger);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var events = Capture(drv);
|
||||||
|
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||||
|
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 5);
|
||||||
|
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
|
||||||
|
lock (events)
|
||||||
|
{
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
events[0].IsQualityOnly.ShouldBeTrue();
|
||||||
|
events[0].QualityStatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
|
logger.Warnings().ShouldBe(1);
|
||||||
|
factory.Clients.Count.ShouldBe(1, "a refusal does not tear the session down");
|
||||||
|
|
||||||
|
// Control: the same driver losing its transport DOES degrade, and re-annotates the condition.
|
||||||
|
factory.Clients[0].AlarmReadException = null;
|
||||||
|
factory.Clients[0].ThrowOnReadAlarms = true;
|
||||||
|
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded);
|
||||||
|
lock (events) events[^1].QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3 — on a mixed driver health is the WORST of the value path and the alarm path, computed together: a
|
||||||
|
/// failing alarm read keeps the driver Degraded however many value reads succeed (no per-poll flip on
|
||||||
|
/// /hosts), and a successful alarm read cannot mask a value-path Degraded.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_is_the_worst_of_the_value_and_alarm_paths()
|
||||||
|
{
|
||||||
|
const string valuePath = "Plant/Cnc/m1/r100";
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new RawTagEntry(valuePath, """{"address":"R100","dataType":"Byte"}""", false, "m1"),
|
||||||
|
Tag(ServoPath, "ALARM:Servo"));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
// Value reads good, alarm path failing ⇒ Degraded, and it stays Degraded across further good value reads.
|
||||||
var sub = new FocasAlarmProjection.Subscription(
|
(await drv.ReadAsync([valuePath], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
drv.NoteAlarmReadFailed(Host, new InvalidOperationException("socket closed"));
|
||||||
new CancellationTokenSource());
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await drv.ReadAsync([valuePath], CancellationToken.None);
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a good value read must not mask the failing alarm path");
|
||||||
|
}
|
||||||
|
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull("value reads still succeed, so the watchdog is not tripped");
|
||||||
|
|
||||||
var events = new List<AlarmEventArgs>();
|
// Alarm path recovers ⇒ Healthy again.
|
||||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
drv.NoteAlarmReadSucceeded(Host);
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
|
||||||
// Tick 1 — raise two alarms.
|
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
|
||||||
projection.Tick(sub, Host, [
|
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
|
||||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
await drv.ReadAsync([valuePath], CancellationToken.None);
|
||||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
drv.NoteAlarmReadSucceeded(Host);
|
||||||
]);
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||||
events.Count.ShouldBe(2);
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
|
||||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
|
||||||
|
|
||||||
// Tick 2 — same alarms stay active → no new events.
|
|
||||||
events.Clear();
|
|
||||||
projection.Tick(sub, Host, [
|
|
||||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
|
||||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
|
||||||
]);
|
|
||||||
events.ShouldBeEmpty();
|
|
||||||
|
|
||||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
|
||||||
projection.Tick(sub, Host, [
|
|
||||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
|
||||||
]);
|
|
||||||
events.Count.ShouldBe(1);
|
|
||||||
events[0].Message.ShouldEndWith("(cleared)");
|
|
||||||
events[0].AlarmType.ShouldBe("Parameter");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that severity mapping matches docs.</summary>
|
private static int Count(List<AlarmEventArgs> events)
|
||||||
[Fact]
|
|
||||||
public void Severity_mapping_matches_docs()
|
|
||||||
{
|
{
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
lock (events) return events.Count;
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
|
||||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
|
||||||
{
|
{
|
||||||
var deadline = DateTime.UtcNow + timeout;
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
while (DateTime.UtcNow < deadline)
|
while (DateTime.UtcNow < deadline)
|
||||||
{
|
{
|
||||||
if (pred()) return;
|
if (pred()) return;
|
||||||
await Task.Delay(30);
|
await Task.Delay(10);
|
||||||
}
|
}
|
||||||
|
pred().ShouldBeTrue("condition not reached within the timeout");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>Gitea #647 — the <c>ALARM:</c> address grammar, shared by the editor, the deploy gate and the driver.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasAlarmSelectorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ALARM:*", "ALARM:*")]
|
||||||
|
[InlineData("alarm:other", "ALARM:OTHER")]
|
||||||
|
[InlineData("ALARM:servo", "ALARM:Servo")]
|
||||||
|
[InlineData("ALARM:Servo/401", "ALARM:Servo/401")]
|
||||||
|
[InlineData("ALARM:SERVO/401-453", "ALARM:Servo/401-453")]
|
||||||
|
[InlineData("ALARM:Servo/401-401", "ALARM:Servo/401")]
|
||||||
|
[InlineData("ALARM:T9", "ALARM:T9")]
|
||||||
|
[InlineData("ALARM:t9/750-758", "ALARM:T9/750-758")]
|
||||||
|
[InlineData(" ALARM:MacroAlarm/3000 ", "ALARM:MacroAlarm/3000")]
|
||||||
|
public void Valid_selectors_parse_to_their_canonical_form(string address, string canonical)
|
||||||
|
{
|
||||||
|
var parsed = FocasAddress.TryParse(address);
|
||||||
|
parsed.ShouldNotBeNull();
|
||||||
|
parsed.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||||
|
parsed.Canonical.ShouldBe(canonical);
|
||||||
|
FocasAddress.TryParse(parsed.Canonical)!.Canonical.ShouldBe(canonical, "canonical must round-trip");
|
||||||
|
FocasAddress.DescribeAlarmAddressError(address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ALARM:")]
|
||||||
|
[InlineData("ALARM:Bogus")]
|
||||||
|
[InlineData("ALARM:Servo/")]
|
||||||
|
[InlineData("ALARM:Servo/x")]
|
||||||
|
[InlineData("ALARM:Servo/-5")]
|
||||||
|
[InlineData("ALARM:Servo/10-5")]
|
||||||
|
[InlineData("ALARM:Servo/1-")]
|
||||||
|
[InlineData("ALARM:T")]
|
||||||
|
[InlineData("ALARM:T-1")]
|
||||||
|
[InlineData("ALARM:T99999")]
|
||||||
|
[InlineData("ALARM:Servo /401")]
|
||||||
|
[InlineData("ALARM:*/401")]
|
||||||
|
public void Malformed_selectors_are_refused_with_a_reason(string address)
|
||||||
|
{
|
||||||
|
FocasAddress.TryParse(address).ShouldBeNull();
|
||||||
|
FocasAddress.DescribeAlarmAddressError(address).ShouldNotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Alarm_prefix_is_not_read_as_a_PMC_A_address()
|
||||||
|
{
|
||||||
|
FocasAddress.TryParse("A10")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||||
|
FocasAddress.TryParse("ALARM:*")!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||||
|
FocasAddress.DescribeAlarmAddressError("A10").ShouldBeNull("not an ALARM: address at all");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Type_selector_matches_on_type_and_inclusive_number_range()
|
||||||
|
{
|
||||||
|
var range = FocasAlarmSelector.TryParse("Servo/401-453")!;
|
||||||
|
range.Matches(FocasAlarmType.Servo, 401).ShouldBeTrue();
|
||||||
|
range.Matches(FocasAlarmType.Servo, 453).ShouldBeTrue();
|
||||||
|
range.Matches(FocasAlarmType.Servo, 454).ShouldBeFalse();
|
||||||
|
range.Matches(FocasAlarmType.Overtravel, 401).ShouldBeFalse("(type, number) is the identity");
|
||||||
|
|
||||||
|
var any = FocasAlarmSelector.TryParse("*")!;
|
||||||
|
any.Matches(FocasAlarmType.Overheat, 1).ShouldBeTrue();
|
||||||
|
|
||||||
|
// OTHER depends on the device's other selectors, so it never matches on its own terms.
|
||||||
|
FocasAlarmSelector.TryParse("OTHER")!.Matches(FocasAlarmType.Servo, 401).ShouldBeFalse();
|
||||||
|
|
||||||
|
// A raw code selects exactly that code — the series-independent form.
|
||||||
|
FocasAlarmSelector.TryParse("T4")!.Matches(FocasAlarmType.Servo, 1).ShouldBeTrue();
|
||||||
|
FocasAlarmSelector.TryParse("T9")!.Matches(FocasAlarmType.Servo, 1).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_named_type_resolves_through_the_one_table()
|
||||||
|
{
|
||||||
|
foreach (var name in FocasAlarmType.Names)
|
||||||
|
{
|
||||||
|
FocasAlarmType.TryResolve(name.ToUpperInvariant(), out var code).ShouldBeTrue(name);
|
||||||
|
FocasAlarmType.NameOf(code).ShouldBe(name);
|
||||||
|
}
|
||||||
|
FocasAlarmType.NameOf(9).ShouldBeNull("codes outside the 16i-era table are not invented");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Capability_matrix_accepts_an_alarm_address_on_every_series()
|
||||||
|
{
|
||||||
|
var address = FocasAddress.TryParse("ALARM:Servo/401")!;
|
||||||
|
foreach (var series in Enum.GetValues<FocasCncSeries>())
|
||||||
|
FocasCapabilityMatrix.Validate(series, address).ShouldBeNull(series.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the deploy-time inspector (the gate CSV import / API authoring meets) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspect_is_clean_for_an_alarm_selector_with_its_alarm_object()
|
||||||
|
{
|
||||||
|
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/401-453","dataType":"Int32","alarm":{"severity":800}}""")
|
||||||
|
.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspect_advises_when_an_alarm_selector_has_no_alarm_object_or_an_alarm_object_has_no_selector()
|
||||||
|
{
|
||||||
|
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:*","dataType":"Int32"}""")
|
||||||
|
.ShouldHaveSingleItem().ShouldContain("no 'alarm' object");
|
||||||
|
FocasTagDefinitionFactory.Inspect("""{"address":"R100","dataType":"Byte","alarm":{}}""")
|
||||||
|
.ShouldHaveSingleItem().ShouldContain("not an ALARM: selector");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inspect_names_the_selector_fault_for_a_malformed_alarm_address()
|
||||||
|
{
|
||||||
|
var warning = FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/10-5","alarm":{}}""").ShouldHaveSingleItem();
|
||||||
|
warning.ShouldContain("reversed");
|
||||||
|
warning.ShouldContain(FocasAddress.DescribeAlarmAddressError("ALARM:Servo/10-5")!,
|
||||||
|
customMessage: "the gate and the editor must say the same thing about the same string");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_mapper_reads_the_alarm_object_and_its_optional_integer_severity()
|
||||||
|
{
|
||||||
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":650}}""", "p", out var a).ShouldBeTrue();
|
||||||
|
a.Alarm.ShouldBe(new FocasTagAlarm(650));
|
||||||
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":"high"}}""", "p", out var b).ShouldBeTrue();
|
||||||
|
b.Alarm.ShouldBe(new FocasTagAlarm(null));
|
||||||
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"R100"}""", "p", out var c).ShouldBeTrue();
|
||||||
|
c.Alarm.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 review MINOR-5 — a non-empty alarm payload that fits neither record stride is malformed, not
|
||||||
|
/// "no alarms" (which would mass-clear every condition on the device). Empty stays empty.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_malformed_alarm_payload_throws_while_an_empty_one_is_no_alarms()
|
||||||
|
{
|
||||||
|
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
|
||||||
|
FocasWireClient.ParseAlarms(new byte[76], 32).Count.ShouldBe(1);
|
||||||
|
FocasWireClient.ParseAlarms(new byte[160], 32).Count.ShouldBe(2);
|
||||||
|
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
|
||||||
|
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[12], 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
|
||||||
|
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty()
|
||||||
|
{
|
||||||
|
using var client = new WireFocasClient();
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(() => client.ReadAlarmsAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -58,9 +58,9 @@ public sealed class GatewayGalaxyAlarmFeedTests
|
|||||||
|
|
||||||
observed.Count.ShouldBe(3);
|
observed.Count.ShouldBe(3);
|
||||||
|
|
||||||
// Active snapshot entry → Raise.
|
// Active (unacked) snapshot entry → Retrigger (#647 review: forces the upstream's unack on the host).
|
||||||
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
||||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
|
||||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,21 @@ public sealed class OpcUaClientAlarmTests
|
|||||||
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
|
||||||
|
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
|
||||||
|
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
|
||||||
|
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||||
|
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||||
|
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||||
|
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
// ---- #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>
|
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||||
@@ -357,7 +372,9 @@ public sealed class OpcUaClientAlarmTests
|
|||||||
|
|
||||||
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
||||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
|
||||||
|
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
|
||||||
|
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
|
||||||
|
|
||||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||||
seen.Count.ShouldBe(2);
|
seen.Count.ShouldBe(2);
|
||||||
|
|||||||
@@ -63,4 +63,37 @@ public sealed class FocasAddressBuilderTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Gitea #647: ALARM: selectors ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("*", null, null, "ALARM:*")]
|
||||||
|
[InlineData("*", 401, null, "ALARM:*")] // numbers apply to a type scope only
|
||||||
|
[InlineData("OTHER", null, null, "ALARM:OTHER")]
|
||||||
|
[InlineData("Servo", null, null, "ALARM:Servo")]
|
||||||
|
[InlineData("Servo", 401, null, "ALARM:Servo/401")]
|
||||||
|
[InlineData("Servo", 401, 453, "ALARM:Servo/401-453")]
|
||||||
|
[InlineData("Servo", 401, 401, "ALARM:Servo/401")]
|
||||||
|
[InlineData("T9", 750, 758, "ALARM:T9/750-758")]
|
||||||
|
public void BuildAlarm_produces_a_selector_the_driver_can_parse(string scope, int? lo, int? hi, string expected)
|
||||||
|
{
|
||||||
|
var built = FocasAddressBuilder.BuildAlarm(scope, lo, hi);
|
||||||
|
|
||||||
|
built.ShouldBe(expected);
|
||||||
|
FocasAddress.TryParse(built)!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Servo", 453, 401)] // reversed range
|
||||||
|
[InlineData("Bogus", null, null)] // not a type
|
||||||
|
[InlineData("", null, null)]
|
||||||
|
public void BuildAlarm_returns_empty_rather_than_an_unusable_selector(string scope, int? lo, int? hi)
|
||||||
|
=> FocasAddressBuilder.BuildAlarm(scope, lo, hi).ShouldBeEmpty();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_offered_alarm_scope_composes_a_parseable_selector()
|
||||||
|
{
|
||||||
|
foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||||
|
FocasAddress.TryParse(FocasAddressBuilder.BuildAlarm(scope, 1, 2)).ShouldNotBeNull(scope);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModelTests
|
|||||||
[InlineData("PARAM:1815/0")]
|
[InlineData("PARAM:1815/0")]
|
||||||
[InlineData("MACRO:500")]
|
[InlineData("MACRO:500")]
|
||||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||||
|
[InlineData("ALARM:*")]
|
||||||
|
[InlineData("ALARM:OTHER")]
|
||||||
|
[InlineData("ALARM:Servo/401-453")]
|
||||||
|
[InlineData("ALARM:T9")]
|
||||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||||
|
|
||||||
@@ -104,6 +108,25 @@ public sealed class FocasTagConfigModelTests
|
|||||||
// The operator has to be able to act on it, so the message names the legal forms.
|
// The operator has to be able to act on it, so the message names the legal forms.
|
||||||
error.ShouldContain("PARAM:");
|
error.ShouldContain("PARAM:");
|
||||||
error.ShouldContain("FIXED:");
|
error.ShouldContain("FIXED:");
|
||||||
|
error.ShouldContain("ALARM:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 — a malformed ALARM: selector gets the selector's own reason, the same text the deploy-time
|
||||||
|
/// inspector gives for the same string (one authority: <c>FocasAddress.DescribeAlarmAddressError</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ALARM:Servo/10-5")]
|
||||||
|
[InlineData("ALARM:Bogus")]
|
||||||
|
[InlineData("ALARM:")]
|
||||||
|
public void Validate_rejects_a_malformed_alarm_selector_with_the_gate_s_reason(string address)
|
||||||
|
{
|
||||||
|
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||||
|
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
error.ShouldContain(FocasAddress.DescribeAlarmAddressError(address)!);
|
||||||
|
FocasTagDefinitionFactory.Inspect("{\"address\":\"" + address + "\",\"alarm\":{}}")
|
||||||
|
.ShouldContain(w => w.Contains(FocasAddress.DescribeAlarmAddressError(address)!));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+5
-3
@@ -256,7 +256,7 @@ public sealed class OpcUaClientDriverWireTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
|
/// #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
|
/// reported (Retrigger since #647, 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
|
/// 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.
|
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -291,9 +291,11 @@ public sealed class OpcUaClientDriverWireTests
|
|||||||
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
|
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
|
||||||
|
|
||||||
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
|
// First sight of an active+UNACKED upstream condition is a Retrigger (#647 review): the host keeps an ack
|
||||||
|
// across a Raise on an already-active condition, so only a Retrigger forces the upstream's unack.
|
||||||
|
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Retrigger));
|
||||||
|
|
||||||
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
|
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Retrigger);
|
||||||
raise.ConditionId.ShouldBe(AlarmRef);
|
raise.ConditionId.ShouldBe(AlarmRef);
|
||||||
raise.Message.ShouldBe("already hot");
|
raise.Message.ShouldBe("already hot");
|
||||||
raise.OpcUaSeverity.ShouldBe(800);
|
raise.OpcUaSeverity.ShouldBe(800);
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS <c>"{host}#{type}:{number}"</c> before #647) has
|
||||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_decorated_condition_id_is_dropped()
|
public void A_decorated_condition_id_is_dropped()
|
||||||
|
|||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster.Tools.PublishSubscribe;
|
||||||
|
using Akka.TestKit;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
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.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 — a FOCAS CNC alarm reaches its Part 9 condition END TO END: the REAL <see cref="FocasDriver"/>
|
||||||
|
/// (over a fake CNC client), fed the raw tags exactly as the deployment artifact delivers them, raises into the
|
||||||
|
/// REAL <see cref="DriverHostActor"/>, whose <c>ForwardNativeAlarm</c> + <c>NativeAlarmProjector</c> must turn it
|
||||||
|
/// into an Active and then an inactive condition at the tag's RawPath.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The lesson this exists for: OpcUaClient and AbCip native alarms were "routed" — events raised, unit tests
|
||||||
|
/// green — but never reached a condition, because nothing checked the event against the key the host actually
|
||||||
|
/// routes on (ConditionId == RawPath) or the field its projector actually reads (Kind). A test that stops at the
|
||||||
|
/// driver's event cannot see either.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly NodeId TestNode = NodeId.Parse("focas-alarm-e2e");
|
||||||
|
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('f', 64));
|
||||||
|
private const string Host = "focas://10.0.0.5:8193";
|
||||||
|
private const string DriverId = "drv-focas";
|
||||||
|
private const string ExpectedRawPath = "Plant/Cnc/m1/servo_alarms";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_CNC_servo_alarm_activates_then_clears_the_condition_at_the_RawPath()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var blob = BuildArtifact();
|
||||||
|
var deploymentId = Seed(db, blob, Rev);
|
||||||
|
var (host, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
// The raw tags exactly as the driver receives them: the artifact's merged DriverConfig RawTags array.
|
||||||
|
var rawTags = RawTagsFor(blob, DriverId);
|
||||||
|
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
|
||||||
|
|
||||||
|
var cnc = new AlarmOnlyFocasClient();
|
||||||
|
var driver = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||||
|
RawTags = rawTags,
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||||
|
}, DriverId, new SingleClientFactory(cnc));
|
||||||
|
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
// The host's alarm subscription set is the alarm-bearing raw tags' RawPaths. Called directly, as the
|
||||||
|
// DriverInstanceActor would through its invoker: the routing is under test, not resilience wrapping.
|
||||||
|
#pragma warning disable OTOPCUA0001
|
||||||
|
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
|
||||||
|
var active = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||||
|
active.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||||
|
active.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
active.State.Active.ShouldBeTrue("the Raise must project to an ACTIVE condition, not 'keep prior state'");
|
||||||
|
active.State.Acknowledged.ShouldBeFalse();
|
||||||
|
active.State.Severity.ShouldBe((ushort)800, "the authored alarm.severity is the condition's severity");
|
||||||
|
active.State.Message.ShouldStartWith("401 SERVO V-READY OFF");
|
||||||
|
|
||||||
|
cnc.SetAlarms();
|
||||||
|
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||||
|
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||||
|
cleared.State.Active.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 review (MAJOR-1) — a deploy that changes ANY of the driver's config respawns it, and the fresh
|
||||||
|
/// instance has no record of what the server was told. Its first poll re-reads the still-active alarm and
|
||||||
|
/// sends Raise; the host must treat that as a re-statement: the operator's acknowledgement survives and no
|
||||||
|
/// second "Activated" alerts row is written. Driven through the real host with a FRESH driver instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <para>
|
||||||
|
/// Re-review R1 — the other half: a DIFFERENT alarm that appeared while the driver was down (here a second
|
||||||
|
/// servo alarm, so the condition's displayed set changed) is a new statement even though the fresh driver
|
||||||
|
/// can only say Raise. It must un-acknowledge and write its alerts row.
|
||||||
|
/// </para>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack(bool newAlarmDuringRespawn)
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var blob = BuildArtifact();
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
|
||||||
|
alerts.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||||
|
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||||
|
var rawTags = RawTagsFor(blob, DriverId);
|
||||||
|
var cnc = new AlarmOnlyFocasClient();
|
||||||
|
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
|
||||||
|
|
||||||
|
var first = await StartDriverAsync(rawTags, cnc, host);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5))
|
||||||
|
.State.Acknowledged.ShouldBeFalse();
|
||||||
|
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await first.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator acknowledges, then a deploy changes the driver's config => stop + respawn.
|
||||||
|
host.Tell(new DriverHostActor.RouteNativeAlarmAck(ExpectedRawPath, "seen", "op"));
|
||||||
|
var revB = RevisionHash.Parse(new string('e', 64));
|
||||||
|
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
|
||||||
|
coordinator.FishForMessage<ApplyAck>(a => a.Outcome == ApplyAckOutcome.Applied, TimeSpan.FromSeconds(5));
|
||||||
|
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (newAlarmDuringRespawn)
|
||||||
|
cnc.SetAlarms(
|
||||||
|
new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"),
|
||||||
|
new FocasActiveAlarm(410, FocasAlarmType.Servo, 2, "SERVO EXCESS ERROR"));
|
||||||
|
|
||||||
|
var respawned = await StartDriverAsync(rawTags, cnc, host);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var restated = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||||
|
restated.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||||
|
restated.State.Active.ShouldBeTrue();
|
||||||
|
if (newAlarmDuringRespawn)
|
||||||
|
{
|
||||||
|
restated.State.Acknowledged.ShouldBeFalse("an alarm the operator has not seen must not inherit the old ack");
|
||||||
|
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
|
||||||
|
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await respawned.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<FocasDriver> StartDriverAsync(List<RawTagEntry> rawTags, IFocasClient cnc, IActorRef host)
|
||||||
|
{
|
||||||
|
var driver = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||||
|
RawTags = rawTags,
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||||
|
}, DriverId, new SingleClientFactory(cnc));
|
||||||
|
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
|
||||||
|
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The seeded artifact's FOCAS driver raw tags, decoded through the production artifact reader.</summary>
|
||||||
|
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
|
||||||
|
{
|
||||||
|
using var artifact = DeploymentArtifact.Open(blob);
|
||||||
|
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
|
||||||
|
using var doc = JsonDocument.Parse(spec.DriverConfig);
|
||||||
|
return JsonSerializer.Deserialize<List<RawTagEntry>>(
|
||||||
|
doc.RootElement.GetProperty("RawTags").GetRawText(),
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
|
||||||
|
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Cnc", DriverType = "FOCAS", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||||
|
},
|
||||||
|
Devices = new[]
|
||||||
|
{
|
||||||
|
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { HostAddress = Host }) },
|
||||||
|
},
|
||||||
|
TagGroups = Array.Empty<object>(),
|
||||||
|
Tags = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
TagId = "tag-servo",
|
||||||
|
DeviceId = "dev-m1",
|
||||||
|
TagGroupId = (string?)null,
|
||||||
|
Name = "servo_alarms",
|
||||||
|
DataType = "Int32",
|
||||||
|
AccessLevel = 0,
|
||||||
|
TagConfig = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
address = "ALARM:Servo",
|
||||||
|
dataType = "Int32",
|
||||||
|
alarm = new { alarmType = "AlarmCondition", severity = 800 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
|
||||||
|
{
|
||||||
|
var id = DeploymentId.NewId();
|
||||||
|
using var ctx = db.CreateDbContext();
|
||||||
|
ctx.Deployments.Add(new Deployment
|
||||||
|
{
|
||||||
|
DeploymentId = id.Value,
|
||||||
|
RevisionHash = rev.Value,
|
||||||
|
Status = DeploymentStatus.Sealed,
|
||||||
|
CreatedBy = "test",
|
||||||
|
SealedAtUtc = DateTime.UtcNow,
|
||||||
|
ArtifactBlob = blob,
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||||
|
{
|
||||||
|
var coordinator = CreateTestProbe();
|
||||||
|
var publish = this.CreateCleanOutcomePublishProbe();
|
||||||
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||||
|
db, TestNode, coordinator.Ref,
|
||||||
|
localRoles: new HashSet<string> { "driver" },
|
||||||
|
dependencyMux: CreateTestProbe().Ref,
|
||||||
|
opcUaPublishActor: publish.Ref,
|
||||||
|
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||||
|
virtualTagHostOverride: CreateTestProbe().Ref));
|
||||||
|
|
||||||
|
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||||
|
return (actor, publish, coordinator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SingleClientFactory(IFocasClient client) : IFocasClientFactory
|
||||||
|
{
|
||||||
|
public IFocasClient Create() => client;
|
||||||
|
|
||||||
|
public void EnsureUsable() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A CNC that only answers the alarm list — everything else this test never reaches.</summary>
|
||||||
|
private sealed class AlarmOnlyFocasClient : IFocasClient
|
||||||
|
{
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private FocasActiveAlarm[] _alarms = [];
|
||||||
|
|
||||||
|
public bool IsConnected { get; private set; }
|
||||||
|
|
||||||
|
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||||
|
{
|
||||||
|
lock (_gate) _alarms = alarms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IsConnected = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (_gate) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. _alarms]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ProbeAsync(CancellationToken cancellationToken) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public void Dispose() => IsConnected = false;
|
||||||
|
|
||||||
|
public Task<(object? value, uint status)> ReadAsync(FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||||
|
|
||||||
|
private static NotSupportedException Unused() => new("not reached by the alarm end-to-end test");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,6 +224,50 @@ public class NativeAlarmProjectorTests
|
|||||||
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 review — a Raise on a condition that is ALREADY active is a re-statement (a respawned driver
|
||||||
|
/// re-reading a still-active alarm it never told the server about): it keeps the acknowledgement and is
|
||||||
|
/// flagged so the host writes no duplicate "Activated" row. Only a Retrigger (or inactive → active) un-acks.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
|
||||||
|
first.ShouldBeFalse("inactive → active is a real transition");
|
||||||
|
sut.MarkAcknowledged("n1");
|
||||||
|
|
||||||
|
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
|
||||||
|
restatement.ShouldBeTrue();
|
||||||
|
restated.Active.ShouldBeTrue();
|
||||||
|
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
|
||||||
|
|
||||||
|
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
|
||||||
|
retrigger.ShouldBeFalse();
|
||||||
|
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
|
||||||
|
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
|
||||||
|
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
|
||||||
|
sut.MarkAcknowledged("n1");
|
||||||
|
|
||||||
|
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
|
||||||
|
r1.ShouldBeTrue();
|
||||||
|
same.Acknowledged.ShouldBeTrue();
|
||||||
|
|
||||||
|
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
|
||||||
|
r2.ShouldBeFalse("a different alarm is not a re-statement");
|
||||||
|
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
|
||||||
|
}
|
||||||
|
|
||||||
private static AlarmEventArgs Evt(
|
private static AlarmEventArgs Evt(
|
||||||
AlarmTransitionKind kind,
|
AlarmTransitionKind kind,
|
||||||
AlarmSeverity sev = AlarmSeverity.High,
|
AlarmSeverity sev = AlarmSeverity.High,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
||||||
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||||
|
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
|
||||||
|
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user