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

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:46:13 -04:00
36 changed files with 2847 additions and 551 deletions
+110 -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
@@ -381,41 +382,116 @@ Rules worth knowing:
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
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, a wire fault, or a non-empty reply that fits neither the 76- nor the 80-byte record layout (an
empty reply is "no alarms"). That device is skipped for the tick and its conditions keep their Active/Acked
state. (It used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them
un-acknowledged on recovery.)
- **…but an unreachable source says so.** The first failed read of an outage annotates every subscribed condition
on that device `BadNoCommunication` through the #626 quality-only path (no state change, no `/alerts` row) and
logs ONE Warning naming `cnc_rdalmmsg2`; the next successful read lifts it to Good (before any transition it
also finds, e.g. Good then Clear for an alarm that cleared during the outage). Transport failures also reach
driver health: `Degraded` with `LastSuccessfulRead` frozen (only a successful read advances it), so a driver
whose only tags are `ALARM:` tags trips the comms-loss watchdog (#527) and the host's driver-wide #477
annotation like any other.
- **A CNC refusal is not comms loss.** A non-OK FOCAS return from `cnc_rdalmmsg2` (the CNC answered and declined,
e.g. `EW_FUNC`) marks the conditions `BadNotSupported` (quality-only, once), logs one Warning, and **leaves driver
health alone** — polling continues at the normal cadence. Treating it as comms loss would make the #527 watchdog
reconnect a production control every 90 s forever. Only a transport fault (session down, wire fault, malformed
reply) degrades.
- **Health is the worst of the value path and the alarm path, computed together** (`GetHealth`). A failing alarm
read keeps a mixed driver `Degraded` however many value reads succeed (so `/hosts` does not flip per poll), and a
good alarm read cannot mask a value-path `Degraded`. `LastSuccessfulRead` is the later of the two paths' last
successes, so the watchdog fires only when both have stopped.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached. The record lives on the
driver instance: a reconnect (same-set re-subscribe) and an in-place re-initialise (Force Reconnect, #630) re-raise
nothing. **A deploy that changes the driver's config respawns it** (the planner stops + recreates the driver, a new
instance with an empty record), and its first poll sends `Raise` for every still-active alarm. The host treats a
`Raise` on a condition it already shows Active **with the same message** as a **re-statement**: the
acknowledgement is kept and no second "Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review).
The condition message is a deterministic function of the matched set (winner, type code, axes, `[+N more]`), so a
true re-statement matches exactly, while an alarm set that changed during the respawn — including one that only
LOST a member — reads as a new statement and un-acks (the conservative direction). Only `Retrigger` — or inactive →
active — un-acknowledges, which is why a genuinely new CNC alarm in a matched set is sent as `Retrigger`.
- **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a
**subscribed** alarm tag; devices without one are never polled for alarms.
- **Opt-in is the tag.** `AlarmProjection.Enabled` is retained so old configs bind, and has no effect. Before
#647 `SubscribeAlarmsAsync` threw while it was false, so an authored alarm tag on a default config stayed
gated with a Warning on every reconnect.
- **Read-only.** Nothing is ever written to the CNC. Acknowledge and AddComment are applied server-side (native
conditions support only those two methods); `AcknowledgeAsync` is a no-op success. The CNC clears an alarm
when its cause is reset.
**Authoring mistakes are skipped and reported** (the driver's skipped-tags ledger + a deploy-time advisory):
an `ALARM:` address without the `alarm` object (not a condition, and no value to read), and an `alarm` object
on a non-`ALARM:` address (a condition nothing can raise). Reading an `ALARM:` tag's RawPath through the driver
returns `BadNotSupported`; writing it, `BadNotWritable`.
**Known residuals.** Whether the alarm *list* itself flickers at a CNC reset (the status word does, one poll)
is unobserved; if the live gate shows it, a clear-debounce knob is the follow-up. Real-machine raise/clear is
only observable passively — never induce an alarm on a production control.
## Handle recycling
@@ -444,8 +520,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
@@ -465,7 +543,7 @@ latency spike once per cadence.
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
| 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 |