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
+88 -32
View File
@@ -90,6 +90,7 @@ on.
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
| `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 |
| `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
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 |
| `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 |
| `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
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
`BadNotSupported`.
`BadNotSupported`; the Part 9 acknowledge is applied server-side.
## 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
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
on sites that don't consume CNC alarms. Opt in per driver instance:
A FANUC CNC exposes **one flat active-alarm list per device** (`cnc_rdalmmsg2`), not one structure per
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
"Config": {
"Devices": [ ... ],
"Tags": [ ... ],
"AlarmProjection": {
"Enabled": true,
"PollInterval": "00:00:02"
}
}
// raw tag TagConfig
{ "address": "ALARM:Servo/401-453", "dataType": "Int32",
"alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
```
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;
the CNC exposes a single flat active-alarm list per session)
- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
`Parameter`, `MacroAlarm`)
- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
→ `Medium`; everything else → `High`
Type names: `Parameter`, `PulseCode`, `Overtravel`, `Overheat`, `Servo`, `DataIo`, `MemoryCheck`,
`MacroAlarm` (case-insensitive). The grammar is `FocasAlarmSelector` in the Contracts assembly, and it is the
**one authority**: the `/raw` tag modal, the address picker ("CNC alarm (condition)"), the deploy-time
inspector and the driver's init all parse with it, and a malformed selector gets the same reason text from
the editor and the gate.
Cleared alarms fire a second event with `" (cleared)"` appended to the message
so downstream consumers can ignore the clear if they only care about raises.
⚠️ **The type table is the 16i/18i/21i-era `ALM_*` numbering** (`Parameter=0, PulseCode=1, Overtravel=2,
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
> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths —
> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
> not one condition per tag), so it is tracked separately rather than patched.
**Semantics.**
- **Identity is (type, number).** Cross-family number collisions exist. The axis is not part of the key: one
alarm number reported on two axes is one alarm, and the message lists both axes.
- **Overlap is intended.** One servo alarm drives `ALARM:Servo` *and* `ALARM:*`. `*` summaries and `OTHER`
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
@@ -397,8 +451,10 @@ latency spike once per cadence.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
clear diffing tests.
driver surface via `FakeFocasClient`. `FocasAlarmSelectorTests` pins the `ALARM:` grammar and the inspector;
`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/`
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
@@ -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 |
| 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` |
| 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 |
| 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 |