From 482bafdfacb9049fb54472bfeb7a7ebb4165bcc9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 19:46:48 -0400 Subject: [PATCH 1/3] feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647) An ALARM: address (*, , /, /-, T, 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 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 --- CLAUDE.md | 8 +- docs/AlarmTracking.md | 3 +- docs/drivers/FOCAS.md | 120 ++-- .../FocasAddress.cs | 47 +- .../FocasAlarmSelector.cs | 190 +++++++ .../FocasAlarmType.cs | 100 ++++ .../FocasDriverOptions.cs | 31 +- .../FocasTagDefinitionFactory.cs | 54 +- .../FocasAlarmConditionMapper.cs | 260 +++++++++ .../FocasAlarmProjection.cs | 451 ++++++++------- .../FocasCapabilityMatrix.cs | 3 + .../FocasDriver.cs | 145 +++-- .../IFocasClient.cs | 36 +- .../Wire/WireFocasClient.cs | 27 +- .../Drivers/Forms/FocasDriverForm.razor | 4 +- .../Pickers/FOCASAddressPickerBody.razor | 63 ++- .../Drivers/Pickers/FocasAddressBuilder.cs | 34 ++ .../Uns/TagEditors/FocasTagConfigModel.cs | 15 +- .../Series/WireBackendCoverageTests.cs | 56 +- .../FakeFocasClient.cs | 23 +- .../FocasAlarmProjectionTests.cs | 525 +++++++++++++----- .../FocasAlarmSelectorTests.cs | 146 +++++ .../Pickers/FocasAddressBuilderTests.cs | 33 ++ .../Uns/FocasTagConfigModelTests.cs | 23 + .../DriverHostActorNativeAlarmTests.cs | 2 +- .../Drivers/FocasNativeAlarmEndToEndTests.cs | 223 ++++++++ .../ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj | 3 + 27 files changed, 2110 insertions(+), 515 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmSelector.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmType.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmConditionMapper.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmSelectorTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/FocasNativeAlarmEndToEndTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3051ac43..695e8123 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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); 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). +- **FOCAS alarms (#647):** an `ALARM:` address (`*`, ``, `/`, `/-`, `T`, `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` 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` 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 @@ -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` (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 - `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 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 diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index a475ce3a..f727f6c5 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -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. - **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 - 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. ### Native condition state survives a full rebuild (Gitea #641) diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index 802f10df..430e4046 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -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`) | +| `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`. 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` 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 | diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs index 862f7e10..337332a3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs @@ -7,7 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// PARAM:1020, PARAM:1815/0 for bit 0), /// (macro variable number — MACRO:100, MACRO:500), and /// (a logical path into the driver's fixed tree — -/// FIXED:Identity/SeriesNumber, FIXED:Axes/X/AbsolutePosition). +/// FIXED:Identity/SeriesNumber, FIXED:Axes/X/AbsolutePosition), plus +/// (a selector over the CNC's active alarm list — ALARM:*, +/// ALARM:Servo/401, ALARM:OTHER; see , Gitea #647). /// /// /// PMC letters: X/Y (IO), F/G (signals between PMC + CNC), R (internal @@ -35,11 +37,15 @@ public sealed record FocasAddress( string? PmcLetter, int Number, int? BitIndex, - string? FixedPath = null) + string? FixedPath = null, + FocasAlarmSelector? AlarmSelector = null) { /// The prefix that introduces a address. public const string FixedPrefix = "FIXED:"; + /// The prefix that introduces a address (Gitea #647). + public const string AlarmPrefix = "ALARM:"; + /// Gets the canonical string representation of this address. public string Canonical => Kind switch { @@ -51,6 +57,7 @@ public sealed record FocasAddress( : $"PARAM:{Number}/{BitIndex}", FocasAreaKind.Macro => $"MACRO:{Number}", FocasAreaKind.Fixed => FixedPrefix + FixedPath, + FocasAreaKind.Alarm => AlarmPrefix + AlarmSelector?.Canonical, _ => $"?{Number}", }; @@ -62,6 +69,26 @@ public sealed record FocasAddress( /// The canonical authored address string. public static string ForFixedPath(string path) => FixedPrefix + path; + /// Builds an address over . + /// The alarm selector. + /// The address. + public static FocasAddress ForAlarm(FocasAlarmSelector selector) => + new(FocasAreaKind.Alarm, null, 0, null, AlarmSelector: selector); + + /// + /// Why is not a usable ALARM: address, or when it + /// is one (or is not an ALARM: address at all). Shared by the AdminUI editor and the deploy-time + /// inspector so both say the same thing about the same string. + /// + /// The authored address. + /// The refusal reason, or . + 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; + } + /// Attempts to parse a FOCAS address from the given string. /// The address string to parse. /// A FocasAddress if parsing succeeds; otherwise null. @@ -86,6 +113,14 @@ public sealed record FocasAddress( 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 if (src.Length < 2 || !char.IsLetter(src[0])) return null; var letter = src[0..1].ToUpperInvariant(); @@ -175,4 +210,12 @@ public enum FocasAreaKind /// nothing in that tree is a writable location on the CNC. /// Fixed, + + /// + /// A selector over the device's active CNC alarm list (ALARM:*, ALARM:Servo/401-453, + /// ALARM:OTHER) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver + /// alarm object, which makes it ONE Part 9 condition at its RawPath; it has no value to read + /// and nothing to write. + /// + Alarm, } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmSelector.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmSelector.cs new file mode 100644 index 00000000..cb42fa9f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmSelector.cs @@ -0,0 +1,190 @@ +using System.Globalization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// Which CNC alarms an ALARM: address selects (Gitea #647). +public enum FocasAlarmSelectorKind +{ + /// ALARM:* — every active alarm on the tag's device (a device summary condition). + Any, + + /// + /// ALARM:OTHER — active alarms on the device that no selector on the same + /// device matches: the "unmatched" bucket. and other tags do not + /// claim an alarm, so an ALARM:* summary beside it does not starve it. + /// + Other, + + /// ALARM:Servo, ALARM:Servo/401, ALARM:Servo/401-453, ALARM:T4. + Type, +} + +/// +/// The selector half of an ALARM: FOCAS address (Gitea #647): which entries of the CNC's flat, +/// per-device active-alarm list (cnc_rdalmmsg2) drive the ONE Part 9 condition the raw tag is +/// materialised as. +/// +/// +/// Grammar of the text after ALARM: (case-insensitive, no embedded whitespace): +/// +/// FormMatches +/// *any active alarm on the device +/// OTHERalarms no type selector on the device matches +/// <TYPE>any alarm of that type +/// <TYPE>/<n>that type and alarm number +/// <TYPE>/<lo>-<hi>that type, number in the inclusive range +/// +/// +/// <TYPE> is a name from (Servo, Overtravel…) +/// or a raw type code T<code> (T4). The names resolve through the driver's single +/// 16i-era type table — see the ⚠️ on ; the raw-code form is the +/// series-independent one and the right choice on a 30i-series control. +/// +/// +/// One authority. This parser backs the AdminUI editor (through ), +/// the deploy-time inspector and the driver's init — an address one of them accepts, all of them accept. +/// +/// +/// The selector kind. +/// The FOCAS alarm type code; set only for . +/// The canonical type name when the selector named one; for a raw T<code>. +/// The lowest alarm number matched, or for every number. +/// The highest alarm number matched (inclusive); equals for a single number. +public sealed record FocasAlarmSelector( + FocasAlarmSelectorKind Kind, + short? TypeCode = null, + string? TypeName = null, + int? NumberLow = null, + int? NumberHigh = null) +{ + /// The * body. + public const string AnyToken = "*"; + + /// The OTHER body. + public const string OtherToken = "OTHER"; + + /// Gets the canonical selector text (the part after ALARM:). + 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}"), + }; + + /// + /// Whether this selector matches one CNC alarm on its own terms. + /// always answers here: whether an alarm is "unmatched" depends on the other + /// selectors on the device, which the caller evaluates. + /// + /// The alarm's type code. + /// The alarm number. + /// when the alarm is selected. + 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, + }; + + /// Parses the text after ALARM:, or returns . + /// The selector text. + /// The selector, or when the text is not a valid selector. + public static FocasAlarmSelector? TryParse(string? body) => TryParse(body, out var selector, out _) ? selector : null; + + /// Parses the text after ALARM:, explaining a refusal. + /// The selector text. + /// The selector when this returns . + /// Why the text was refused, when this returns . + /// when is a valid selector. + 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. 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); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmType.cs new file mode 100644 index 00000000..2183a482 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAlarmType.cs @@ -0,0 +1,100 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Well-known FOCAS alarm types from fwlib32.h ALM_TYPE_*. Narrow subset — +/// the full list is ~15 types per model; these cover the universally-present categories. +/// +/// +/// +/// Constants are typed so they match the wire field width on +/// cnc_rdalmmsg2 (and so the driver's switch (short) statements compile against a +/// matching type rather than relying on implicit int→short narrowing on the constants). +/// +/// +/// ⚠️ This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows +/// (Gitea #647). The 30i/31i/32i series number alm_type 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 (type 4), and an ALARM: address can name a raw code +/// (ALARM:T4) instead of a name, which is the series-independent form. +/// +/// +/// Moved into the Contracts assembly (from the driver's IFocasClient.cs, same namespace) so the +/// grammar — shared by the AdminUI editor and the deploy gate — can +/// resolve type names without referencing the runtime driver. +/// +/// +public static class FocasAlarmType +{ + /// Pass to the wire's cnc_rdalmmsg2 to mean "any type". + public const short All = -1; + + /// ALM_P — parameter / program (P/S) alarms. + public const short Parameter = 0; + + /// ALM_Y — pulse coder (servo encoder) alarms. + public const short PulseCode = 1; + + /// ALM_O — overtravel alarms. + public const short Overtravel = 2; + + /// ALM_H — overheat alarms. + public const short Overheat = 3; + + /// ALM_S — servo alarms. + public const short Servo = 4; + + /// ALM_T — data I/O alarms. + public const short DataIo = 5; + + /// ALM_M — memory check alarms. + public const short MemoryCheck = 6; + + /// ALM_MC — macro alarms (#3000). + public const short MacroAlarm = 13; + + /// + /// The named types an ALARM: address may use, keyed case-insensitively by the constant's + /// name. The canonical spelling is the constant name (Servo, MacroAlarm). + /// + private static readonly IReadOnlyDictionary ByName = + new Dictionary(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, + }; + + /// Gets the canonical type names an ALARM: address accepts, in code order. + public static IReadOnlyList Names { get; } = + [nameof(Parameter), nameof(PulseCode), nameof(Overtravel), nameof(Overheat), + nameof(Servo), nameof(DataIo), nameof(MemoryCheck), nameof(MacroAlarm)]; + + /// Resolves a type name (case-insensitive) to its code. + /// The type name, e.g. servo. + /// The resolved code. + /// when the name is in the table. + public static bool TryResolve(string name, out short code) => ByName.TryGetValue(name, out code); + + /// The canonical name of a code, or when the code is not in the table. + /// The FOCAS alarm type code. + /// The name, or . + 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, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs index b04f865a..925fcfd7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs @@ -128,12 +128,19 @@ public sealed class FocasHandleRecycleOptions /// /// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via -/// IAlarmSource. Disabled by default — operators opt in by setting -/// in appsettings.json. +/// IAlarmSource as Part 9 conditions at ALARM: raw tags (Gitea #647). /// +/// +/// Authoring an ALARM: tag IS the opt-in. The projection polls exactly the devices that own a +/// subscribed alarm tag, so a driver with none issues no cnc_rdalmmsg2 traffic whatever +/// says. Before #647, gated the poll and +/// SubscribeAlarmsAsync 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. +/// public sealed class FocasAlarmProjectionOptions { - /// Gets or sets a value indicating whether alarm projection is enabled. + /// Retained for config compatibility; has no effect since Gitea #647 (see the class remarks). public bool Enabled { get; init; } = false; /// Poll cadence. One cnc_rdalmmsg2 call per device per tick. @@ -196,13 +203,29 @@ public sealed record FocasDeviceOptions( /// DriverAttributeInfo.WriteIdempotent by DiscoverAsync so OPC UA /// clients can optimise write coalescing for idempotent tags. Defaults to false. /// +/// +/// The tag's cross-driver alarm object, when it carries one (Gitea #647) — which makes the host +/// materialise it as a Part 9 condition at its RawPath. Only an ALARM: address can drive one. +/// public sealed record FocasTagDefinition( string Name, string DeviceHostAddress, string Address, FocasDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + FocasTagAlarm? Alarm = null); + +/// +/// What the FOCAS driver reads from a tag's cross-driver alarm object (Gitea #647). The host owns the +/// rest of it (alarmType, historizeToAveva) through TagConfigIntent. +/// +/// +/// The authored alarm.severity (an integer), or 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 (FocasAlarmConditionMapper). +/// +public sealed record FocasTagAlarm(int? Severity); /// /// Controls periodic connectivity probing. One cnc_rdcncstat call per diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs index f676f6fc..baeeba4d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs @@ -64,7 +64,8 @@ public static class FocasTagDefinitionFactory Address: address, DataType: dataType, Writable: writable, - WriteIdempotent: false); + WriteIdempotent: false, + Alarm: ReadAlarm(root)); return true; } catch (JsonException) { return false; } @@ -72,6 +73,20 @@ public static class FocasTagDefinitionFactory catch (InvalidOperationException) { return false; } } + /// + /// The tag's cross-driver alarm object (Gitea #647), or when it carries none. + /// Lenient like the host's TagConfigIntent: a non-integer severity is simply absent. + /// + /// The TagConfig root object. + /// The alarm intent, or . + 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); + } + /// /// Inverse of : serialises a typed definition back to the camelCase /// TagConfig 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 // 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). - if (root.TryGetProperty("address", out var addr) - && addr.ValueKind == JsonValueKind.String - && addr.GetString() is { } address - && !string.IsNullOrWhiteSpace(address) - && FocasAddress.TryParse(address) is null) + var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String + ? addr.GetString() + : null; + var parsed = string.IsNullOrWhiteSpace(address) ? null : FocasAddress.TryParse(address); + 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( - $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " + - "PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " + - "this tag at startup and it will read BadNodeIdUnknown."); + $"FOCAS address '{address!.Trim()}' is an alarm selector but the tag has no 'alarm' object — it is " + + "not materialised as a condition and has no value to read; the driver skips it."); + } + 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:[/[-]] or ALARM:OTHER."); } if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmConditionMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmConditionMapper.cs new file mode 100644 index 00000000..234f8c5f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmConditionMapper.cs @@ -0,0 +1,260 @@ +using System.Text; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// An authored ALARM: raw tag the driver drives a Part 9 condition for (Gitea #647). +/// The tag's RawPath — the condition's ConditionId, the only key the host routes on. +/// The device whose alarm list the tag selects from. +/// Which alarms on that device drive the condition. +/// The authored alarm.severity, or to derive one from the alarm type. +internal sealed record FocasAlarmTag( + string RawPath, string DeviceHostAddress, FocasAlarmSelector Selector, int? AuthoredSeverity); + +/// A CNC alarm's identity: (type, number). Axis is NOT part of it — see . +/// The FOCAS alarm type code. +/// The alarm number. +internal readonly record struct FocasAlarmKey(short Type, int Number); + +/// One distinct alarm a condition matched, with every axis it was reported on. +/// The alarm's identity. +/// The CNC's message text. +/// The distinct non-zero axes it was reported on, ascending. +internal sealed record FocasMatchedAlarm(FocasAlarmKey Key, string Message, IReadOnlyList Axes); + +/// What one condition sees in one successful alarm-list read. +/// The matched alarms in display order — the first is the one the condition shows. +/// Whether the read may have been cut off at the per-read cap (summary selectors only). +internal sealed record FocasConditionView(IReadOnlyList Matched, bool ListTruncated) +{ + /// Gets a value indicating whether at least one matching alarm is active. + public bool Active => Matched.Count > 0; + + /// Gets the alarm the condition displays, or when inactive. + public FocasMatchedAlarm? Winner => Matched.Count > 0 ? Matched[0] : null; +} + +/// +/// Selects, per authored ALARM: 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. +/// +/// +/// +/// Identity is (type, number). 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. +/// Overlap is intended. An alarm drives every tag that selects it — ALARM:Servo and +/// ALARM:* both go active for one servo alarm, the way one MTConnect DataItem can back several tags. +/// OTHER is what no TYPE selector on the device claims. * summaries and other +/// OTHER tags do not claim anything, so a device summary beside it never starves it. +/// The displayed alarm is deterministic, never wire order: the most severe (by the type +/// table), then ascending (type, number). A CNC that reorders an unchanged list therefore cannot re-raise +/// a condition. +/// +/// +internal static class FocasAlarmMatcher +{ + /// Evaluates one condition against one device's alarm list. + /// The condition's selector. + /// The device's active alarms, as read. + /// Every authored alarm selector on the same device (for OTHER). + /// Whether the read came back at the per-read cap. + /// What the condition sees. + public static FocasConditionView Evaluate( + FocasAlarmSelector selector, + IReadOnlyList alarms, + IReadOnlyCollection 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); + } + + /// Whether any authored selector on the device claims the alarm (*, OTHER or a type match). + /// The alarm. + /// Every authored alarm selector on the device. + /// when some condition on the device will show it. + public static bool IsCovered(FocasActiveAlarm alarm, IReadOnlyCollection deviceSelectors) => + deviceSelectors.Any(s => s.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other + || s.Matches(alarm.Type, alarm.AlarmNumber)); +} + +/// +/// 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. +/// +/// Whether the condition was last told active. +/// The matched set as of the last evaluation. +/// The alarm the condition was last told it displays. +/// The message last sent. +/// The bucket last sent. +/// The exact severity last sent, if any. +internal sealed record FocasToldCondition( + bool Active, + IReadOnlySet Keys, + FocasAlarmKey? Winner, + string Message, + AlarmSeverity Severity, + int? OpcUaSeverity); + +/// +/// 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 to raise — or +/// none — and what the server has been told afterwards. +/// +/// +/// +/// Told → nowEvent +/// inactive → active +/// active → a key not in the told set appears +/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges +/// active → the displayed alarm left, others remain +/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen) +/// active → a NON-displayed member leftnothing (no message-only events) +/// active → nothing matches +/// unchangednothing +/// +/// +/// Severity, explicitly. An authored alarm.severity is sent as +/// (the host clamps it to 1..1000), with the nearest bucket in +/// (<400 Low, <600 Medium, <800 High, else +/// Critical). Without one, the bucket comes from the displayed alarm's type through +/// — 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 ), so on a 30i-series control author a severity. +/// +/// +internal static class FocasAlarmConditionMapper +{ + /// The event moving the server from to , and the new record. + /// What the server was last told, or when nothing yet. + /// What the condition sees now. + /// The condition's tag. + /// The alarm subscription the event belongs to. + /// The transition timestamp. + /// The event (or ) and the new told record. + 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 }); + } + + /// The severity bucket of a FOCAS alarm type (the 16i-era table — see the class remarks). + /// The FOCAS alarm type code. + /// The bucket. + 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, + }; + + /// The nearest bucket of an exact 1..1000 severity. + /// The exact severity. + /// The bucket. + internal static AlarmSeverity BucketOf(int severity) => severity switch + { + < 400 => AlarmSeverity.Low, + < 600 => AlarmSeverity.Medium, + < 800 => AlarmSeverity.High, + _ => AlarmSeverity.Critical, + }; + + /// The human-readable category of a FOCAS type: its table name, else Type{code}. + /// The FOCAS alarm type code. + /// The category. + internal static string TypeLabel(short type) => FocasAlarmType.NameOf(type) ?? $"Type{type}"; + + /// + /// The condition's message: the displayed alarm's number and text, its raw type code (so a 30i-series + /// operator can author a T<code> selector from what they see) and axes, then how many other + /// alarms the condition also matches, and a truncation note on a summary condition. + /// + /// What the condition sees; must be active. + /// e.g. 401 SERVO V-READY OFF (type 4 Servo, axis 1, 2) [+2 more]. + 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); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs index c773ca87..3b835d14 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs @@ -5,196 +5,180 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// -/// Polls each device's CNC active-alarm list via -/// on a timer and translates raise / clear transitions into -/// events on the owning . One poll loop per subscription; the -/// loop fans out across every configured device and diffs the (AlarmNumber, -/// Type) keyed active-alarm set between ticks. +/// Polls the CNC active-alarm list (cnc_rdalmmsg2) of every device that owns a subscribed +/// ALARM: raw tag and drives ONE Part 9 condition per such tag (Gitea #647). /// /// -/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via -/// cnc_rdalmmsg2, not per-node structures the way Galaxy / AbCip ALMD do. So the -/// projection ignores sourceNodeIds at the member level: every alarm event is -/// raised with SourceNodeId=device-host-address. Callers that want per-device -/// filtering can pass the specific host addresses as sourceNodeIds and the -/// projection will skip devices not listed. +/// +/// Which tags are conditions. The server decides: a raw tag whose TagConfig carries the +/// cross-driver alarm object is materialised as a condition at its RawPath, and those RawPaths +/// arrive through . Every event uses the RawPath as +/// — the exact key the host routes on — and carries a +/// , 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 +/// "{host}#{Type}:{Number}" with no kind (so nothing would have routed anyway). +/// +/// +/// A failed read is not an empty list. 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 BadNoCommunication on comms loss (#477). +/// +/// +/// Delivery is diffed against what the server was TOLD (, the MTConnect #626 +/// pattern), recorded only while an OnAlarmEvent 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). +/// +/// +/// One poll loop, owned by the current subscription. Evaluation and delivery run under +/// 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 DriverInstanceActor's +/// non-blocking Tell. +/// +/// Read-only. FOCAS has no alarm acknowledgement; the Part 9 Acknowledge is applied server-side. /// internal sealed class FocasAlarmProjection : IAsyncDisposable { private readonly FocasDriver _driver; - private readonly TimeSpan _pollInterval; private readonly ILogger _logger; - private readonly Dictionary _subs = new(); - private readonly Lock _subsLock = new(); + + /// Serializes evaluation + delivery + the told record. Never held across an await. + private readonly Lock _alarmLock = new(); + + /// ConditionId (RawPath) → what the server was last told. Guarded by . + private readonly Dictionary _told = new(StringComparer.Ordinal); + + /// Per device: the alarm keys already logged as matched by no tag. Guarded by . + private readonly Dictionary> _unmatchedLogged = new(StringComparer.OrdinalIgnoreCase); + + /// Devices whose last read came back at the per-read cap. Guarded by . + private readonly HashSet _truncationWarned = new(StringComparer.OrdinalIgnoreCase); + + /// Serializes loop start/stop. + private readonly SemaphoreSlim _loopGate = new(1, 1); + + private Subscription? _current; + private CancellationTokenSource? _loopCts; + private Task _loop = Task.CompletedTask; private long _nextId; /// Initializes a new FOCAS alarm projection. - /// FOCAS driver instance. - /// Polling interval. + /// The owning driver. /// Optional logger. - public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null) + public FocasAlarmProjection(FocasDriver driver, ILogger? logger = null) { _driver = driver; - _pollInterval = pollInterval; _logger = logger ?? NullLogger.Instance; } - /// Subscribes to alarms from the specified device sources. - /// Source node IDs to listen to. - /// Cancellation token. - /// A task returning the alarm subscription handle. - public Task SubscribeAsync( - IReadOnlyList 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(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 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(handle); - } + /// Gets the live subscription, or . Test seam. + internal Subscription? Current => Volatile.Read(ref _current); /// - /// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes - /// its CTS. Fire-and-forget from ; every step is wrapped so an - /// unobserved exception can never escape (the loops already swallow their own). - /// - /// 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 Task.Delay. 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 - /// ConditionId, 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. - /// + /// Replaces the live subscription with one over (alarm-tag RawPaths) and + /// (re)starts the poll loop. Collapses to ONE subscription: the owning DriverInstanceActor is the + /// only caller and re-subscribes on every Connected entry. /// - /// The retired subscription whose loop must be cancelled + disposed. - private async Task RetireAsync(Subscription sub) + /// The alarm-bearing raw tags' RawPaths. + /// The subscription handle. + public async Task SubscribeAsync(IReadOnlyList sourceNodeIds) { - try { sub.Cts.Cancel(); } - catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); } - try { await sub.Loop.ConfigureAwait(false); } - catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); } - try { sub.Cts.Dispose(); } - catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); } + ArgumentNullException.ThrowIfNull(sourceNodeIds); + var refs = new HashSet(StringComparer.Ordinal); + foreach (var r in sourceNodeIds) + if (!string.IsNullOrWhiteSpace(r)) refs.Add(r); + + 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; } - /// Unsubscribes from an alarm subscription. - /// Alarm subscription handle. - /// Cancellation token. + /// Drops the live subscription when is it; a superseded handle is a no-op. + /// The handle returned by . /// A task representing the asynchronous operation. - public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle) { - if (handle is not FocasAlarmSubscriptionHandle h) return; - Subscription? sub; - lock (_subsLock) + lock (_alarmLock) { - 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(); } - 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(); + await StopLoopAsync().ConfigureAwait(false); } /// - /// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying - /// condition resolves. Swallow the request so capability negotiation succeeds, rather - /// than surfacing a confusing "not supported" error to the operator. + /// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying condition resolves. + /// The Part 9 Acknowledge is applied server-side; this is the no-op success the host routes it to, so + /// nothing is ever written to the CNC. /// /// Alarms to acknowledge. - /// Cancellation token. /// A completed task. - public Task AcknowledgeAsync( - IReadOnlyList acknowledgements, CancellationToken cancellationToken) => - Task.CompletedTask; - - /// Disposes the alarm projection. - /// A task representing the asynchronous operation. - public async ValueTask DisposeAsync() + public static Task AcknowledgeAsync(IReadOnlyList acknowledgements) { - List snap; - lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); } - 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(); - } + ArgumentNullException.ThrowIfNull(acknowledgements); + return Task.CompletedTask; } /// - /// One poll-tick for one device. Diffs the new alarm list against the previous snapshot, - /// emits raise + clear events. Extracted so tests can drive a tick without spinning up - /// the full Task.Run loop. + /// Stops the poll loop but KEEPS the subscription and the told record — the device/tag tables are about + /// to be rebuilt (re-initialise, shutdown). restarts it. /// - /// Active subscription. - /// Device host address. - /// Current alarms from the device. - internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList current) + /// A task representing the asynchronous operation. + public Task PauseAsync() => StopLoopAsync(); + + /// Restarts the poll loop when a subscription is live — after the driver's tables are rebuilt. + /// A task representing the asynchronous operation. + public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync(); + + /// + public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false); + + private async Task RestartLoopAsync() { - var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? []; - var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet(); - var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet(); - - foreach (var a in current) + await _loopGate.WaitAsync().ConfigureAwait(false); + try { - if (prevKeys.Contains(AlarmKey(a))) continue; - _driver.InvokeAlarmEvent(new AlarmEventArgs( - sub.Handle, - SourceNodeId: deviceHostAddress, - ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}", - AlarmType: MapAlarmType(a.Type), - Message: a.Message, - Severity: MapSeverity(a.Type), - SourceTimestampUtc: DateTime.UtcNow)); + await StopLoopCoreAsync().ConfigureAwait(false); + if (Volatile.Read(ref _current) is not { } sub) return; + var cts = new CancellationTokenSource(); + _loopCts = cts; + _loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), CancellationToken.None); } + 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(); } + } + + /// Cancels and joins the loop (bounded). Caller holds . + 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; - _driver.InvokeAlarmEvent(new AlarmEventArgs( - sub.Handle, - SourceNodeId: deviceHostAddress, - ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}", - AlarmType: MapAlarmType(a.Type), - Message: $"{a.Message} (cleared)", - Severity: MapSeverity(a.Type), - SourceTimestampUtc: DateTime.UtcNow)); + var finished = await Task.WhenAny(loop, Task.Delay(FocasDriver.LoopJoinTimeout)).ConfigureAwait(false); + if (finished != loop) + _logger.LogWarning("FOCAS alarm poll loop did not stop within {Timeout}; it cannot emit once superseded", + FocasDriver.LoopJoinTimeout); } - - sub.LastByDevice[deviceHostAddress] = [.. current]; + catch (Exception ex) { _logger.LogDebug(ex, "Awaiting the FOCAS alarm poll loop failed"); } + cts.Dispose(); } private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct) @@ -203,78 +187,147 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable { try { - foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false)) - { - Tick(sub, host, alarms); - } + await PollOnceAsync(sub, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { - /* per-tick failures are non-fatal — next tick retries */ _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; } } } - private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}"; - - /// Map FOCAS type to a human-readable category; falls back to the numeric type. - /// FOCAS alarm type. - /// The mapped alarm type string. - internal static string MapAlarmType(short type) => type switch + /// One pass: read each device that owns a subscribed alarm tag, and evaluate its conditions. + /// The subscription the pass serves. + /// Cancellation. + /// A task representing the asynchronous operation. + internal async Task PollOnceAsync(Subscription sub, CancellationToken ct) { - FocasAlarmType.Parameter => "Parameter", - FocasAlarmType.PulseCode => "PulseCode", - FocasAlarmType.Overtravel => "Overtravel", - FocasAlarmType.Overheat => "Overheat", - FocasAlarmType.Servo => "Servo", - FocasAlarmType.DataIo => "DataIo", - FocasAlarmType.MemoryCheck => "MemoryCheck", - FocasAlarmType.MacroAlarm => "MacroAlarm", - _ => $"Type{type}", - }; + var tags = _driver.AlarmTags; + var devices = sub.References + .Select(r => tags.GetValueOrDefault(r)) + .OfType() + .Select(t => t.DeviceHostAddress) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var host in devices) + { + ct.ThrowIfCancellationRequested(); + IReadOnlyList 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); + } + } /// - /// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel / - /// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land - /// at High (everything else on a CNC is safety-relevant). + /// Evaluates every subscribed condition on against one SUCCESSFUL read + /// and delivers what changed. Extracted so tests can drive a tick without the loop. /// - /// FOCAS alarm type. - /// The mapped alarm severity. - internal static AlarmSeverity MapSeverity(short type) => type switch + /// The subscription the read was taken for; a superseded one emits nothing. + /// The device read. + /// Its active alarms. + /// The driver's alarm-tag table (RawPath → tag). + internal void Tick( + Subscription sub, + string deviceHostAddress, + IReadOnlyList alarms, + IReadOnlyDictionary tags) { - FocasAlarmType.Overtravel => AlarmSeverity.Critical, - FocasAlarmType.Servo => AlarmSeverity.Critical, - FocasAlarmType.PulseCode => AlarmSeverity.Critical, - FocasAlarmType.Parameter => AlarmSeverity.Medium, - FocasAlarmType.MacroAlarm => AlarmSeverity.Medium, - _ => AlarmSeverity.High, - }; + var deviceTags = tags.Values + .Where(t => string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var deviceSelectors = deviceTags.Select(t => t.Selector).ToList(); + var truncated = alarms.Count >= Wire.WireFocasClient.MaxAlarmsPerRead; + var now = DateTime.UtcNow; - internal sealed class Subscription( - FocasAlarmSubscriptionHandle handle, - HashSet? deviceFilter, - CancellationTokenSource cts) - { - /// Gets the subscription handle. - public FocasAlarmSubscriptionHandle Handle { get; } = handle; - /// Gets the device filter. - public HashSet? DeviceFilter { get; } = deviceFilter; - /// Gets the cancellation token source. - public CancellationTokenSource Cts { get; } = cts; - /// Gets or sets the polling loop task. - public Task Loop { get; set; } = Task.CompletedTask; - /// Gets the last seen alarms by device. - public Dictionary> LastByDevice { get; } = - new(StringComparer.OrdinalIgnoreCase); + lock (_alarmLock) + { + if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return; + + NoteTruncation(deviceHostAddress, truncated, alarms.Count); + NoteUnmatched(deviceHostAddress, alarms, deviceSelectors); + + // Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that + // follows still sees the difference and delivers it. + var handlers = _driver.AlarmHandlers; + if (handlers is null) return; + + foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath))) + { + var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated); + _told.TryGetValue(tag.RawPath, out var told); + var (evt, nowTold) = FocasAlarmConditionMapper.Transition(told, view, tag, sub.Handle, now); + _told[tag.RawPath] = nowTold; + if (evt is null) continue; + + foreach (var registration in handlers.GetInvocationList()) + { + try { ((EventHandler)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); + } + } + } + } } + + /// Warns once (per episode) when a read came back at the per-read cap. Caller holds the lock. + 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); + } + + /// + /// Logs (once per alarm) an active alarm that no condition on its device will show — no *, no + /// OTHER and no matching type selector. Caller holds the lock. + /// + private void NoteUnmatched(string host, IReadOnlyList alarms, IReadOnlyCollection 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; + } + + /// The live alarm subscription: its handle and the ConditionIds (RawPaths) it names. + /// The handle. + /// The subscribed RawPaths. + internal sealed record Subscription(FocasAlarmSubscriptionHandle Handle, IReadOnlySet References); } -/// Handle returned by . +/// Handle returned by . public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs index 0e861e92..b271dfb8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs @@ -39,6 +39,9 @@ public static class FocasCapabilityMatrix // 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. 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, }; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 6a656a36..a97993e9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -73,7 +73,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// as the way to publish it without authoring anything. /// private bool _fixedTreeActive; - private FocasAlarmProjection? _alarmProjection; + /// + /// 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. + /// + private readonly FocasAlarmProjection _alarmProjection; + + /// + /// RawPath → authored ALARM: tag (Gitea #647). Built in and swapped in + /// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter + /// : they have no value to read. + /// + private IReadOnlyDictionary _alarmTags = + new Dictionary(StringComparer.Ordinal); // _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync). // 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. @@ -113,6 +126,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); _rebind = rebind; _logger = logger ?? NullLogger.Instance; + _alarmProjection = new FocasAlarmProjection(this, _logger); _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); _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 // that fails fast (Driver.FOCAS-003). _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(StringComparer.Ordinal); foreach (var entry in _options.RawTags) { 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); 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 { 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 // "we now skip fewer tags". _skippedTags.Commit(); + Volatile.Write(ref _alarmTags, alarmTags); 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 // config flag defaults off and the operator who committed those tags has already said // 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)); } catch (Exception ex) @@ -329,11 +369,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// private async Task ReleaseDeviceStatesAsync() { - if (_alarmProjection is { } proj) - { - await proj.DisposeAsync().ConfigureAwait(false); - _alarmProjection = null; - } + // Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild. + await _alarmProjection.PauseAsync().ConfigureAwait(false); if (_devices.Count == 0) return; var loops = new List(); foreach (var state in _devices.Values) @@ -386,11 +423,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async Task ShutdownAsync(CancellationToken cancellationToken) { await _poll.DisposeAsync().ConfigureAwait(false); - if (_alarmProjection is { } proj) - { - await proj.DisposeAsync().ConfigureAwait(false); - _alarmProjection = null; - } + // Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it. + await _alarmProjection.PauseAsync().ConfigureAwait(false); // 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 // 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(); _tagsByRawPath.Clear(); + Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal)); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _parsedAddressesByTagName.Clear(); _fixedTreeActive = false; @@ -523,6 +558,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, 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)) { 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++) { 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)) { results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown); @@ -1613,57 +1661,52 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // ---- IAlarmSource ---- /// + /// + /// 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 ALARM: tag is the opt-in, and a RawPath that is + /// not an ALARM: tag of this driver is simply never raised. + /// public Task SubscribeAlarmsAsync( - IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) - { - 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); - } + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) => + _alarmProjection.SubscribeAsync(sourceNodeIds); /// public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => - _alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask; + _alarmProjection.UnsubscribeAsync(handle); /// + /// Read-only: nothing is written to the CNC. See . public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => - _alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask; + FocasAlarmProjection.AcknowledgeAsync(acknowledgements); - /// Raises an alarm event with the provided arguments. - /// The alarm event arguments. - internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); + /// Gets the attached alarm handlers, or when nobody is listening. + internal EventHandler? AlarmHandlers => OnAlarmEvent; + + /// Gets the authored ALARM: tags (RawPath → tag). Test seam + projection input. + internal IReadOnlyDictionary AlarmTags => Volatile.Read(ref _alarmTags); + + /// Gets the alarm projection. Test seam. + internal FocasAlarmProjection AlarmProjection => _alarmProjection; + + /// Gets the alarm poll cadence — one cnc_rdalmmsg2 per alarm-owning device per tick. + internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval; /// - /// Poll every configured device's active-alarm list in one pass. Used by the alarm - /// projection — kept internal rather than public because callers that - /// want alarm events should subscribe through IAlarmSource instead. + /// Reads one device's active-alarm list. Throws on any failure — an unknown device, a connect that + /// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty + /// list (Gitea #647). /// - /// Optional set of device host addresses to filter results; null includes all devices. + /// The device host address. /// Cancellation token. - /// A list of tuples containing host address and active alarms for each device. - internal async Task Alarms)>> - ReadActiveAlarmsAcrossDevicesAsync(HashSet? deviceFilter, CancellationToken ct) + /// The device's active alarms. + internal async Task> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct) { - var result = new List<(string, IReadOnlyList)>(); - foreach (var state in _devices.Values) - { - if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue; - 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; + if (!_devices.TryGetValue(hostAddress, out var state)) + throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured."); + var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); + return await client.ReadAlarmsAsync(ct).ConfigureAwait(false); } // ---- IPerCallHostResolver ---- diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index ed69e3cf..9837c5f1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -65,12 +65,18 @@ public interface IFocasClient : IDisposable /// /// Read active alarm messages from the CNC via cnc_rdalmmsg2. Returns - /// zero-or-more active alarms. Null / empty list means "no alarms currently - /// active". IAlarmSource projection polls this at a configurable interval + - /// emits transitions (raise / clear) on the driver's OnAlarmEvent. + /// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is + /// active". The alarm projection polls this per device and diffs it into Part 9 + /// condition transitions on the driver's OnAlarmEvent. + /// + /// A failed read MUST throw, never answer empty (Gitea #647): the projection treats an + /// empty answer as "every alarm cleared", so a failure reported as [] becomes a mass + /// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state. + /// /// /// The cancellation token. /// The list of currently active alarms; empty when none are active. + /// Any failure to obtain the list — the session is down, the CNC refused, the wire faulted. Task> ReadAlarmsAsync(CancellationToken cancellationToken); /// @@ -446,27 +452,3 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory /// public IFocasClient Create() => throw new NotSupportedException(Message); } - -/// -/// Well-known FOCAS alarm types from fwlib32.h ALM_TYPE_*. Narrow subset — -/// the full list is ~15 types per model; these cover the universally-present categories. -/// -/// -/// Constants are typed so they match the wire field width on -/// cnc_rdalmmsg2 (and so 's switch (short) -/// statements compile against a matching type rather than relying on implicit int→short -/// narrowing on the constants). -/// -public static class FocasAlarmType -{ - /// Pass to -equivalent to mean "any type". - 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 -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index d4789061..f35d4992 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -115,19 +115,18 @@ public sealed class WireFocasClient : IFocasClient } /// + /// + /// Gitea #647: every failure THROWS — a disconnected session, a non-OK return code and a wire fault + /// alike. This used to answer [] 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. propagates as-is. + /// public async Task> ReadAlarmsAsync(CancellationToken cancellationToken) { - if (!_wire.IsConnected) return []; - try - { - var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false); - if (!result.IsOk || result.Value is null) return []; - return result.Value.Select(Map).ToList(); - } - catch (FocasWireException) - { - return []; - } + RequireConnected(); + var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false); + ThrowIfRcNonZero(result.Rc, "cnc_rdalmmsg2", result.IsOk); + return (result.Value ?? []).Select(Map).ToList(); static FocasActiveAlarm Map(WireAlarm a) => new( AlarmNumber: a.AlarmNumber, @@ -468,6 +467,12 @@ public sealed class WireFocasClient : IFocasClient return true; } + /// + /// Records requested per cnc_rdalmmsg2 call. A reply carrying exactly this many may have been + /// truncated by the CNC; the alarm projection warns about it (Gitea #647). + /// + internal const short MaxAlarmsPerRead = 32; + private void RequireConnected() { if (!_wire.IsConnected) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor index 2cc1e0d4..a8048d1c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor @@ -66,13 +66,13 @@ class="form-check-input" /> -
Surfaces FOCAS alarms via IAlarmSource.
+
Legacy flag, no longer required (#647): authoring an ALARM: tag with native-alarm settings is the opt-in.
-
One cnc_rdalmmsg2 call per device per tick. Default 2 s.
+
One cnc_rdalmmsg2 call per tick for each device that owns an ALARM: tag. Default 2 s.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor index de4671a7..8edee5ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor @@ -1,6 +1,7 @@ @* FOCAS address picker: 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 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 @@ -27,9 +28,41 @@ + - @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. *@ +
+ + +
+ @if (_alarmScope is not ("*" or "OTHER")) + { +
+ + +
+
+ + +
+ } +
+ 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. ALARM:T9) into the address field instead. +
+ } + else if (_kind == FocasManualAddressKind.Pmc) {
@@ -41,12 +74,15 @@
} -
- - -
- @if (_kind != FocasManualAddressKind.Macro) + @if (_kind != FocasManualAddressKind.Alarm) + { +
+ + +
+ } + @if (_kind is not (FocasManualAddressKind.Macro or FocasManualAddressKind.Alarm)) {