Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
v2-ci / build (push) Successful in 4m57s
v2-ci / unit-tests (push) Failing after 21m6s

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:46:13 -04:00
36 changed files with 2847 additions and 551 deletions
+19 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>&lt;TYPE&gt;</c></term><description>any alarm of that type</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;n&gt;</c></term><description>that type and alarm number</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;lo&gt;-&lt;hi&gt;</c></term><description>that type, number in the inclusive range</description></item>
/// </list>
/// <para>
/// <c>&lt;TYPE&gt;</c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
/// or a raw type code <c>T&lt;code&gt;</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&lt;code&gt;</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>&lt;400</c> Low, <c>&lt;600</c> Medium, <c>&lt;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&lt;code&gt;</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.
@@ -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>
@@ -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);
@@ -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&lt;code&gt;</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>
@@ -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));
}
}
@@ -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]
@@ -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);
@@ -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()
@@ -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>