feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)

An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:46:48 -04:00
parent 8801631739
commit 482bafdfac
27 changed files with 2110 additions and 515 deletions
+7 -1
View File
@@ -1117,6 +1117,12 @@ 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); the told record lives on the driver, so reconnect/re-init re-raise nothing. 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`. `docs/drivers/FOCAS.md`.
- **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/FOCAS alarm
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
@@ -1158,7 +1164,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
+2 -1
View File
@@ -56,7 +56,8 @@ 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 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)
+88 -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
@@ -334,41 +335,94 @@ 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 or a wire fault; that device is skipped for the tick and its conditions keep their state. (It
used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them un-acknowledged
on recovery.) Driver-wide comms loss is the host's `BadNoCommunication` quality annotation (#477) — Active
is kept.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached, and held for the
driver's lifetime: a reconnect (same-set re-subscribe), a re-initialise and a shutdown/init cycle re-raise
nothing, so an acknowledgement survives them. A changed subscribed set (an alarm-bearing deploy) prunes only
the RawPaths that left (#641).
- **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
@@ -397,8 +451,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
@@ -418,7 +474,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,180 @@ 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>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); 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 +187,147 @@ 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.
_logger.LogDebug(ex, "FOCAS alarm read failed for {Host}; its conditions keep their state", host);
continue;
}
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; foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath)))
/// <summary>Gets the last seen alarms by device.</summary> {
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } = var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated);
new(StringComparer.OrdinalIgnoreCase); _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;
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.",
tag.RawPath);
}
}
}
}
} }
/// <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,
}; };
} }
@@ -73,7 +73,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,6 +126,7 @@ 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 => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine( _poll = new PollGroupEngine(
@@ -218,6 +232,11 @@ 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.
_tagsByRawPath.Clear();
_parsedAddressesByTagName.Clear();
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,6 +279,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
entry.RawPath, def.Address, reason, _driverInstanceId); entry.RawPath, def.Address, reason, _driverInstanceId);
continue; continue;
} }
// 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 _tagsByRawPath[entry.RawPath] = def with
{ {
DeviceHostAddress = deviceHost, DeviceHostAddress = deviceHost,
@@ -274,6 +312,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// 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 _alarmTags, alarmTags);
if (_options.Probe.Enabled) if (_options.Probe.Enabled)
{ {
@@ -295,9 +334,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 +349,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 +369,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 +423,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.
@@ -432,6 +466,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
} }
_devices.Clear(); _devices.Clear();
_tagsByRawPath.Clear(); _tagsByRawPath.Clear();
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;
@@ -523,6 +558,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 +764,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 +1661,52 @@ 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>Raises an alarm event with the provided arguments.</summary> /// <summary>Gets the attached alarm handlers, or <see langword="null"/> when nobody is listening.</summary>
/// <param name="args">The alarm event arguments.</param> internal EventHandler<AlarmEventArgs>? AlarmHandlers => OnAlarmEvent;
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <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> /// <summary>
/// Poll every configured device's active-alarm list in one pass. Used by the alarm /// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that /// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
/// want alarm events should subscribe through <c>IAlarmSource</c> instead. /// list (Gitea #647).
/// </summary> /// </summary>
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param> /// <param name="hostAddress">The device host address.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A list of tuples containing host address and active alarms for each device.</returns> /// <returns>The device's active alarms.</returns>
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>> internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
{ {
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>(); if (!_devices.TryGetValue(hostAddress, out var state))
foreach (var state in _devices.Values) throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
{ var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue; return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
result.Add((state.Options.HostAddress, alarms));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
/* surface a device-local fault on the next tick */
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
}
}
return result;
} }
// ---- 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
}
@@ -115,19 +115,18 @@ 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.
/// </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);
{ ThrowIfRcNonZero(result.Rc, "cnc_rdalmmsg2", result.IsOk);
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false); return (result.Value ?? []).Select(Map).ToList();
if (!result.IsOk || result.Value is null) return [];
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 +467,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)
@@ -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.";
} }
@@ -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,29 @@ 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 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 (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,439 @@
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)
{ {
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) => { lock (events) events.Add(e); };
return events;
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
// First tick creates the client via EnsureConnectedAsync — wait for it before we
// poke the alarm list so we don't race the poll loop.
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); await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0);
var client = ClientOf(factory);
var raises = new List<AlarmEventArgs>(); client.SetAlarms(Servo401());
drv.OnAlarmEvent += (_, e) => await WaitFor(() => Count(events) == 1);
{
// 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 client.ThrowOnReadAlarms = true;
// it + a couple empty-list ticks so the loop's LastByDevice baseline is seeded empty. var readsAtFailure = client.AlarmReadCount;
var sub1 = await drv.SubscribeAlarmsAsync([], CancellationToken.None); await WaitFor(() => client.AlarmReadCount >= readsAtFailure + 5);
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3)); Count(events).ShouldBe(1, "a failed read is not an empty list");
await Task.Delay(120);
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reuses the same client.ThrowOnReadAlarms = false;
// connected DeviceState.Client (EnsureConnectedAsync), so it polls the same alarm list. client.SetAlarms();
var sub2 = await drv.SubscribeAlarmsAsync([], CancellationToken.None); await WaitFor(() => Count(events) == 2);
await Task.Delay(120); // let sub2's loop seed its own empty LastByDevice baseline lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
// 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)
{
raises.Count.ShouldBe(1,
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
}
} }
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
[Fact] [Fact]
public async Task Tick_diffs_raises_and_clears_without_polling_loop() public async Task A_reinitialise_neither_re_raises_nor_loses_the_subscription()
{ {
// Drive Tick directly so the test isn't timing-dependent. The projection's var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
// Tick() is internal so we reach it through the driver using a handcrafted await drv.InitializeAsync("{}", CancellationToken.None);
// subscription — simpler than standing up the full loop. var events = Capture(drv);
var (drv, factory) = NewDriver(alarmsEnabled: true); await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
factory.Customise = () => new FakeFocasClient(); 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 projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1)); var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
var sub = new FocasAlarmProjection.Subscription(
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
new CancellationTokenSource());
var events = new List<AlarmEventArgs>(); handle.ShouldNotBeNull();
drv.OnAlarmEvent += (_, e) => events.Add(e); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
// Tick 1 — raise two alarms.
projection.Tick(sub, Host, [
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
]);
events.Count.ShouldBe(2);
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> // ---- authoring at init ------------------------------------------------------------------------------------
[Fact] [Fact]
public void Severity_mapping_matches_docs() public async Task Alarm_tags_are_conditions_not_values_and_half_authored_ones_are_skipped_and_reported()
{ {
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical); var noObject = new RawTagEntry("Plant/Cnc/m1/noobj",
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical); JsonSerializer.Serialize(new { address = "ALARM:Servo", dataType = "Int32" }), false, "m1");
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical); var pmcWithObject = new RawTagEntry("Plant/Cnc/m1/pmc",
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium); JsonSerializer.Serialize(new { address = "R100", dataType = "Byte", alarm = new { severity = 700 } }), false, "m1");
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium); var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo/401", severity: 800), noObject, pmcWithObject);
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High); 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);
} }
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout) /// <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()
{ {
var deadline = DateTime.UtcNow + timeout; const string path = "Plant/Cnc/m1/t";
FocasDriverOptions Options(params RawTagEntry[] tags) => new()
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
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);
}
private static int Count(List<AlarmEventArgs> events)
{
lock (events) return events.Count;
}
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
{
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,146 @@
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 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));
}
}
@@ -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]
@@ -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,223 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
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.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);
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>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)
{
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) 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);
}
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");
}
}
@@ -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>