Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+110
-32
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user