diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 5de204ad0..c210aaa84 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -61,7 +61,9 @@ design treated it as an implementation detail. ⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT, -MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an +MTConnect and MQTT raise natively and are unaffected. Heidenhain (#831) raises on the event-driven shape this +recommends: a reconnect whose `R_VR` identity (model / NC / PLC version / options) differs from the previous session — +no timer, no extra request. If that coverage matters later, reach for an event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`, `DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` / @@ -329,6 +331,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. +- **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=[/GROUP=][/NUMBER=]`, `` = `n` | + `lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save + path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*` + arrays unchanged. Walked on `Polling.AlarmMs` (2 s) by the control's single loop with the SAME `R_RI 27/28` — the + allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, `-1`). A failed walk + (refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (`AcknowledgeAsync` no-op success) — + never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented names or severities. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's 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 diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 978088a90..a73701084 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -1,7 +1,7 @@ # Heidenhain LSV2 driver (read-only) `DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · -Gitea **#803**. +Gitea **#803** (native alarms + rediscovery: **#831**; authoring UX: **#830**). A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the @@ -21,9 +21,12 @@ Three independent layers, each sufficient on its own for its concern: manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed - address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the - save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older - (one rule, `HeidenhainTagRules`). + address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag + modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid + used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or + mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate + `HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI + path through the driver-dispatched `RawTagRowRules` (no call site names a driver). 3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s public surface is pinned by a reflection test) and no configuration knob that widens it. @@ -66,6 +69,7 @@ enums, so the numeric-enum form/factory trap cannot occur): | `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) | | `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | | `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | +| `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) | | `Polling.MaxAlarms` | 64 | 1–256 | | `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 | | `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded | @@ -90,8 +94,9 @@ Host at their own machine. Anything outside the namespace is refused by **every* (`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time** (`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** (`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the -driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the -deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. +driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed +forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate +`HeidenhainCredentialPersisted`. Messages never echo the value. Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the @@ -147,6 +152,70 @@ truncates or wraps — otherwise `BadTypeMismatch`. At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). +## Alarm conditions (`ALARM:`, Gitea #831) + +The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal +57 ends it). Besides the `Alarms/*` typed arrays (unchanged), the list can drive **native OPC UA Part 9 conditions**: +a raw tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object is +exactly one condition, `ConditionId` = the tag's RawPath (the key the host routes and fans on — raw device folder + +every referencing equipment folder, per the v3 native-alarm model). Same opt-in-per-tag shape as FOCAS #647 and +MTConnect #626. + +```jsonc +{ "address": "ALARM:CLASS=7", "dataType": "Boolean", + "alarm": { "alarmType": "AlarmCondition", "severity": 800 } } +``` + +| Address | Condition is active while… | +|---|---| +| `ALARM:*` | any alarm is active on the control (summary) | +| `ALARM:OTHER` | an alarm is active that no **filter** selector on the same control claims | +| `ALARM:CLASS=[/GROUP=][/NUMBER=]` (any subset, any order, each key once) | an alarm matches every named field | + +`` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP +are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the +reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or +a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the +driver. + +- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the + `Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored + tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no + driver-level enable flag. +- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector + agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The + tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data + subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds + `{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one. +- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is + deterministic (ascending class, group, number, text), never wire order. Message: + `-1 Run test of cutout channels (class 7, group 4) [+N more]`. +- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed + alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a + non-displayed member leaves → nothing; nothing matches → `Clear`. +- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No + class→severity table is invented. +- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the + driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its + first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept, + `NativeAlarmProjector` #647). +- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 → + `BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss + (`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only + event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down + session degrades health (the existing rule) — a refusal never does. +- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened: + `AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and + pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are + refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset; + an operator ack never reaches it. +- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop + every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists + (`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged). +- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the + alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves. +- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`). + ## Quality | Condition | Value status / capability | @@ -183,6 +252,11 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re - Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never receives an older value after a newer one. A re-init disposes every control loop first; a late start of a disposed loop is a no-op. +- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control + reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous + session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason — + the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first + session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic. - **Health:** `Degraded` only while a control's session is down (connect failing / lost), with `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one @@ -202,7 +276,13 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three). - `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`. -- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet +- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured + iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal, + handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set. +- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition. +- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`, + `AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single + credential notice) + the fleet parity suites. - **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional `HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a @@ -223,5 +303,5 @@ No real control was available for this port. Still open: Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, -PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are -published as typed arrays). +PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm +conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 084e54a7d..707a40fa7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Driver capability for alarm events. Optional — only drivers whose backends expose /// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS /// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect -/// (CONDITION data items). +/// (CONDITION data items), AbCip (ALMD/ALMA), Heidenhain (LSV2 controller alarm list, #831). /// public interface IAlarmSource { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs index 6dcbe5def..4c719b996 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs @@ -30,6 +30,12 @@ public enum HeidenhainAddressKind /// A data-path value read via R_DP (DATA role). DataPath, + + /// + /// An ALARM: selector — a native Part 9 condition driven from the alarm list (R_RI 27/28), never a + /// value (lmxopcua#831). Requires the cross-driver alarm object on the tag. + /// + Alarm, } /// A parsed, validated Heidenhain tag address. @@ -39,13 +45,15 @@ public enum HeidenhainAddressKind /// The PLC area, for . /// The PLC element index (not an absolute address), for . /// The data path as authored, for . +/// The alarm selector, for . public sealed record HeidenhainAddress( HeidenhainAddressKind Kind, string Text, HeidenhainCatalogEntry? Catalog = null, HeidenhainPlcArea? Area = null, long Index = 0, - string? DataPath = null) + string? DataPath = null, + HeidenhainAlarmSelector? AlarmSelector = null) { /// Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header). public const int MaxPayload = 247; @@ -117,6 +125,14 @@ public sealed record HeidenhainAddress( return false; } + if (t.StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) + { + if (!HeidenhainAlarmSelector.TryParse(t[HeidenhainAlarmSelector.Prefix.Length..], out var selector, out error)) return false; + address = new HeidenhainAddress(HeidenhainAddressKind.Alarm, HeidenhainAlarmSelector.Prefix + selector.Canonical, + AlarmSelector: selector); + return true; + } + if (t.StartsWith(DataPrefix, StringComparison.Ordinal)) { var path = t[DataPrefix.Length..]; @@ -157,7 +173,7 @@ public sealed record HeidenhainAddress( return true; } - error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding."; + error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding, a DATA:/path binding or an ALARM: selector."; return false; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs new file mode 100644 index 000000000..7fb8c0823 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs @@ -0,0 +1,191 @@ +using System.Globalization; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// Which controller alarms an ALARM: address selects. +public enum HeidenhainAlarmSelectorKind +{ + /// ALARM:* — any active alarm on the tag's control (a device summary condition). + Any, + + /// + /// ALARM:OTHER — active alarms that no selector on the same control matches (the + /// "unmatched" bucket). and other tags do not claim an alarm. + /// + Other, + + /// ALARM:CLASS=7, ALARM:CLASS=7/GROUP=4, ALARM:NUMBER=100..199, … + Filter, +} + +/// An inclusive integer range; == for a single value. +/// Lowest matched value. +/// Highest matched value (inclusive). +public readonly record struct HeidenhainAlarmRange(long Low, long High) +{ + /// Whether lies in the range. + /// The value. + /// True when matched. + public bool Contains(long value) => value >= Low && value <= High; + + /// + public override string ToString() => Low == High + ? Low.ToString(CultureInfo.InvariantCulture) + : $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}"; +} + +/// +/// The selector half of an ALARM: Heidenhain address (lmxopcua#831): which entries of the control's +/// active-alarm list (R_RI 27 then 28 — class, group, number, text) drive the ONE Part 9 condition +/// the raw tag is materialised as. +/// +/// +/// Grammar of the text after ALARM: (keys case-insensitive, no whitespace): +/// +/// FormMatches +/// *any active alarm on the control +/// OTHERalarms no filter selector on the control matches +/// CLASS=<r>, GROUP=<r>, NUMBER=<r>, joined by / +/// every named field in its range (each key at most once) +/// +/// A range <r> is n or lo..hi (inclusive; .. rather than - because +/// alarm numbers can be negative — a real iTNC 530 reports -1). CLASS and GROUP are the wire's uint16 +/// fields (0–65535); NUMBER is int32. +/// No semantic names. The meaning of the class/group codes is not documented in the reference +/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed: +/// observe the raw values on Alarms/Classes / Alarms/Groups or in a condition message, then filter. +/// One authority. Backs the AdminUI editor, the deploy inspector and the driver's table build. +/// +/// The selector kind. +/// The class range, or null for any class (filter only). +/// The group range, or null for any group (filter only). +/// The number range, or null for any number (filter only). +public sealed record HeidenhainAlarmSelector( + HeidenhainAlarmSelectorKind Kind, + HeidenhainAlarmRange? Class = null, + HeidenhainAlarmRange? Group = null, + HeidenhainAlarmRange? Number = null) +{ + /// Address prefix of an alarm condition. + public const string Prefix = "ALARM:"; + + /// The * body. + public const string AnyToken = "*"; + + /// The OTHER body. + public const string OtherToken = "OTHER"; + + /// The canonical text after ALARM:. + public string Canonical => Kind switch + { + HeidenhainAlarmSelectorKind.Any => AnyToken, + HeidenhainAlarmSelectorKind.Other => OtherToken, + _ => string.Join("/", new[] + { + Class is { } c ? $"CLASS={c}" : null, + Group is { } g ? $"GROUP={g}" : null, + Number is { } n ? $"NUMBER={n}" : null, + }.Where(s => s is not null)), + }; + + /// + /// Whether this selector matches one alarm on its own terms. + /// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates. + /// + /// The alarm's class. + /// The alarm's group. + /// The alarm's number. + /// True when selected. + public bool Matches(int errorClass, int group, int number) => Kind switch + { + HeidenhainAlarmSelectorKind.Any => true, + HeidenhainAlarmSelectorKind.Other => false, + _ => (Class is not { } c || c.Contains(errorClass)) + && (Group is not { } g || g.Contains(group)) + && (Number is not { } n || n.Contains(number)), + }; + + /// Parses the text after ALARM:. Never throws. + /// The selector text. + /// The selector on success. + /// Why it was rejected. + /// True when valid. + public static bool TryParse(string? body, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAlarmSelector? selector, out string? error) + { + selector = null; + error = null; + var t = body?.Trim() ?? ""; + if (t.Length == 0) + { + error = Grammar("an alarm selector is required"); + return false; + } + + if (t == AnyToken) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Any); + return true; + } + + if (string.Equals(t, OtherToken, StringComparison.OrdinalIgnoreCase)) + { + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Other); + return true; + } + + HeidenhainAlarmRange? cls = null, grp = null, num = null; + foreach (var segment in t.Split('/')) + { + var eq = segment.IndexOf('='); + if (eq <= 0) + { + error = Grammar($"'{segment}' is not KEY=VALUE"); + return false; + } + + var key = segment[..eq].ToUpperInvariant(); + var value = segment[(eq + 1)..]; + var (min, max) = key == "NUMBER" ? ((long)int.MinValue, (long)int.MaxValue) : (0L, 65535L); + if (key is not ("CLASS" or "GROUP" or "NUMBER")) + { + error = Grammar($"unknown key '{segment[..eq]}'"); + return false; + } + + if (!TryParseRange(value, min, max, out var range)) + { + error = Grammar($"{key} value '{value}' must be an integer {min}–{max} or lo..hi with lo ≤ hi"); + return false; + } + + ref var slot = ref key == "CLASS" ? ref cls : ref key == "GROUP" ? ref grp : ref num; + if (slot is not null) + { + error = Grammar($"{key} appears twice"); + return false; + } + + slot = range; + } + + selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Filter, cls, grp, num); + return true; + } + + private static bool TryParseRange(string text, long min, long max, out HeidenhainAlarmRange range) + { + range = default; + var dots = text.IndexOf("..", StringComparison.Ordinal); + var loText = dots < 0 ? text : text[..dots]; + var hiText = dots < 0 ? text : text[(dots + 2)..]; + if (!long.TryParse(loText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var lo) + || !long.TryParse(hiText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var hi)) + return false; + if (lo < min || hi > max || lo > hi) return false; + range = new HeidenhainAlarmRange(lo, hi); + return true; + } + + private static string Grammar(string why) => + $"Invalid alarm selector: {why}. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs index 5cef4a304..e76775e47 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs @@ -13,6 +13,9 @@ public enum HeidenhainPollGroup /// Identity, tool, alarms, timers and capability strings (Polling.SlowMs). Slow, + + /// The alarm-list walk behind ALARM: Part 9 conditions (Polling.AlarmMs, lmxopcua#831). + Alarm, } /// One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it. @@ -50,6 +53,9 @@ public static class HeidenhainCatalog /// Feature id reported for the optional DNC login. public const string DncLoginFeature = "DncLogin"; + /// Feature id of the alarm-list walk (R_RI 27/28) — shared by the Alarms/* leaves and ALARM: conditions. + public const string AlarmsFeature = "Alarms"; + /// Feature id reported for the opt-in DATA login. public const string DataLoginFeature = "DataLogin"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs index 880505ad2..852f4182a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs @@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions /// Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms. public int SlowMs { get; init; } = 10000; + /// + /// Alarm-condition group: the alarm-list walk that drives authored ALARM: Part 9 conditions + /// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an + /// ALARM: tag; the Alarms/* typed arrays stay on . + /// + public int AlarmMs { get; init; } = 2000; + /// Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64. public int MaxAlarms { get; init; } = 64; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs index 925b4735c..82f94166c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs @@ -110,7 +110,7 @@ public static class HeidenhainOptionsParser var errors = new List(); if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms."); var p = o.Polling; - foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) }) + foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs), ("AlarmMs", p.AlarmMs) }) if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms."); if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256."); var r = o.Reconnect; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs index a3c774701..aa2929acd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs @@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// The parsed address. /// The OPC data type the driver publishes. /// Whether the value is a 1-D array. +/// +/// The cross-driver alarm object, present exactly when is an ALARM: selector +/// (lmxopcua#831); the tag is then a Part 9 condition, never a value. +/// public sealed record HeidenhainTagDefinition( - string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray) + string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray, + HeidenhainTagAlarm? Alarm = null) { /// The poll cadence this tag rides. - public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard - ? Address.Catalog!.Group - : HeidenhainPollGroup.Custom; + public HeidenhainPollGroup Group => Address.Kind switch + { + HeidenhainAddressKind.Standard => Address.Catalog!.Group, + HeidenhainAddressKind.Alarm => HeidenhainPollGroup.Alarm, + _ => HeidenhainPollGroup.Custom, + }; + + /// True for an ALARM: condition tag. + public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm; } +/// The driver's view of a tag's cross-driver alarm object. +/// The authored integer alarm.severity, or null when absent / not an integer. +public sealed record HeidenhainTagAlarm(int? Severity); + /// /// The single authority on a Heidenhain raw tag's TagConfig: consumed by the driver's table build, /// the deploy-time gate () and the AdminUI typed editor's Validate(), so the @@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition( /// {"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}. Array leaves /// (Axes/*, Alarms/Classes|Groups|Numbers|Messages) must carry "isArray": true so the /// node's rank matches what the driver publishes; every other address must not. +/// Alarm conditions (lmxopcua#831): {"address": "ALARM:CLASS=7", "dataType": "Boolean", +/// "alarm": {...}}. An ALARM: address and the alarm object only work together — either alone is +/// refused (a condition nothing can raise, or a selector with no value to read). The data type is fixed at +/// Boolean. /// Never a write flag. There is no writable key: the driver is read-only and the deploy /// gate refuses a ReadWrite access level on any Heidenhain tag. /// Data types are parsed by NAME only. A digits-only value (a numerically-serialized enum) is @@ -59,6 +78,7 @@ public static class HeidenhainTagDefinitionFactory string? dataTypeText; bool dataTypePresent; bool? isArray; + HeidenhainTagAlarm? alarm; try { using var doc = JsonDocument.Parse(tagConfig); @@ -95,6 +115,15 @@ public static class HeidenhainTagDefinitionFactory } } + alarm = null; + // Exact-case, exactly as the host's TagConfigIntent reads it: the condition exists iff the host sees it. + if (root.TryGetProperty("alarm", out var a) && a.ValueKind == JsonValueKind.Object) + { + int? severity = a.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.Number + && sev.TryGetInt32(out var sv) ? sv : null; + alarm = new HeidenhainTagAlarm(severity); + } + if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True) { error = "Heidenhain tags are read-only; remove \"writable\": true."; @@ -121,9 +150,34 @@ public static class HeidenhainTagDefinitionFactory declared = parsed; } + if (address.Kind == HeidenhainAddressKind.Alarm && alarm is null) + { + error = $"'{address.Text}' is an alarm selector — enable the tag's native alarm (the 'alarm' object); " + + "an ALARM: tag is a Part 9 condition and has no value to read."; + return false; + } + + if (address.Kind != HeidenhainAddressKind.Alarm && alarm is not null) + { + error = $"The tag carries an 'alarm' object but '{address.Text}' is not an ALARM: selector — nothing could " + + "ever raise the condition. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=[/GROUP=][/NUMBER=|..]."; + return false; + } + DriverDataType type; bool array; - if (address.Kind == HeidenhainAddressKind.Standard) + if (address.Kind == HeidenhainAddressKind.Alarm) + { + if (declared is { } d && d != AlarmConditionDataType) + { + error = $"An ALARM: condition tag is {AlarmConditionDataType}; dataType {d} does not match."; + return false; + } + + type = AlarmConditionDataType; + array = false; + } + else if (address.Kind == HeidenhainAddressKind.Standard) { var entry = address.Catalog!; if (declared is { } d && d != entry.DataType) @@ -172,10 +226,13 @@ public static class HeidenhainTagDefinitionFactory return false; } - definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array); + definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array, alarm); return true; } + /// The fixed Tag.DataType of an ALARM: condition tag (active / inactive). + public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean; + /// /// Deploy-time inspection: empty when accepts the blob (never stricter), /// otherwise one warning explaining the rejection. Never throws. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs new file mode 100644 index 000000000..53e6c7c86 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs @@ -0,0 +1,287 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// An authored ALARM: raw tag the driver drives a Part 9 condition for. +/// The tag's RawPath — the ConditionId, the only key the host routes on. +/// The control (host:port) whose alarm list the tag selects from. +/// Which alarms on that control drive the condition. +/// The authored alarm.severity, or null. +internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity); + +/// +/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real +/// iTNC 530 reports -1 for (at least) "Run test of cutout channels" — so two different texts under one +/// number are two alarms. +/// +internal readonly record struct HeidenhainAlarmKey(int Class, int Group, int Number, string Message) +{ + public static HeidenhainAlarmKey Of(Lsv2Alarm a) => new(a.ErrorClass, a.Group, a.Number, (a.Message ?? "").Trim()); +} + +/// What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise). +internal sealed record HeidenhainToldCondition( + bool Active, + IReadOnlySet Keys, + HeidenhainAlarmKey? Winner, + string Message, + AlarmSeverity Severity, + int? OpcUaSeverity); + +/// +/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics +/// applied to the LSV2 alarm list. +/// +/// +/// +/// Overlap is intended: one alarm drives every selector that matches it (* and +/// CLASS=7). OTHER is what no FILTER selector on the same control claims. +/// Displayed alarm is deterministic — ascending (class, group, number, text), never wire order, +/// so a reordered unchanged list cannot re-raise. +/// Transitions: inactive → active = Raise; a key not in the told set appears, or the +/// displayed alarm leaves while others remain = Retrigger (un-acknowledges: a statement the operator +/// has not seen); a non-displayed member leaves = nothing; nothing matches = Clear. +/// Severity: an authored integer alarm.severity is sent exactly (OpcUaSeverity); +/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no +/// class→severity table is invented. +/// +/// +internal static class HeidenhainAlarmRules +{ + /// The every Heidenhain condition event carries. + public const string AlarmType = "HeidenhainAlarm"; + + /// The alarms one condition matches, in display order (distinct by key). + public static IReadOnlyList Evaluate( + HeidenhainAlarmSelector selector, IReadOnlyList alarms, IReadOnlyCollection deviceSelectors) + { + var filters = selector.Kind == HeidenhainAlarmSelectorKind.Other + ? deviceSelectors.Where(s => s.Kind == HeidenhainAlarmSelectorKind.Filter).ToList() + : null; + return alarms + .Where(a => filters is null + ? selector.Matches(a.ErrorClass, a.Group, a.Number) + : !filters.Any(f => f.Matches(a.ErrorClass, a.Group, a.Number))) + .Select(HeidenhainAlarmKey.Of) + .Distinct() + .OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number) + .ThenBy(k => k.Message, StringComparer.Ordinal) + .ToList(); + } + + /// The event moving the server from to , and the new record. + public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition( + HeidenhainToldCondition? told, IReadOnlyList matched, HeidenhainAlarmTag tag, + IAlarmSubscriptionHandle handle, DateTime nowUtc) + { + var keys = matched.ToHashSet(); + if (matched.Count == 0) + { + if (told is not { Active: true }) + return (null, told ?? new HeidenhainToldCondition(false, keys, null, "", AlarmSeverity.Low, null)); + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}", told.Severity, told.OpcUaSeverity), + told with { Active = false, Keys = keys, Winner = null }); + } + + var winner = matched[0]; + var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null); + var message = MessageOf(matched); + var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact); + if (told is not { Active: true }) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised); + if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner) + return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised); + return (null, told with { Keys = keys }); + } + + /// The nearest bucket of an exact 1..1000 severity. + public static AlarmSeverity BucketOf(int severity) => severity switch + { + < 400 => AlarmSeverity.Low, + < 600 => AlarmSeverity.Medium, + < 800 => AlarmSeverity.High, + _ => AlarmSeverity.Critical, + }; + + /// -1 Run test of cutout channels (class 7, group 4) [+2 more]. + public static string MessageOf(IReadOnlyList matched) + { + var w = matched[0]; + var b = new StringBuilder(); + b.Append(w.Number); + if (w.Message.Length > 0) b.Append(' ').Append(w.Message); + b.Append(" (class ").Append(w.Class).Append(", group ").Append(w.Group).Append(')'); + if (matched.Count > 1) b.Append(" [+").Append(matched.Count - 1).Append(" more]"); + return b.ToString(); + } + + public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, + AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) => + new(handle, + SourceNodeId: tag.HostName, + ConditionId: tag.RawPath, + AlarmType: AlarmType, + Message: message, + Severity: bucket, + SourceTimestampUtc: nowUtc, + Kind: kind, + OpcUaSeverity: exact, + QualityStatusCode: quality); +} + +/// +/// Drives one Part 9 condition per subscribed ALARM: raw tag from the alarm walks the device loops already +/// perform (lmxopcua#831). There is NO extra connection and NO extra request type: the walk is the same +/// allowlisted R_RI 27/28 the Alarms/* arrays use, issued by the control's single acquisition loop on +/// the Polling.AlarmMs cadence. +/// +/// +/// Lives as long as the driver (not re-created on re-initialise), so the told record survives a +/// reconnect, a same-set re-subscribe and an in-place re-init — re-raising there would un-acknowledge. A changed +/// subscribed set prunes only the RawPaths that left it. +/// A failed walk is not "no alarms". A refusal, a list overflow, a decode error or comms loss keeps +/// every condition's Active/Acked state and annotates its quality once (quality-only event, no /alerts +/// row); the next good walk lifts it to Good before diffing. Only a down session degrades driver health (the +/// runtime's rule) — a refusal never does, or the #527 watchdog would reconnect-loop a production control. +/// Read-only acknowledgement. LSV2 has no acknowledge request in the allowlist, and none is added: +/// is the no-op success the host routes a Part 9 Acknowledge to +/// (the FOCAS / MTConnect posture) — the ack is applied server-side only; the control clears an alarm when its +/// cause is reset. Confirm / shelve / enable are refused by the host for every native condition. +/// Recorded only while a handler is attached (the host detaches while it reconnects), so the re-subscribe +/// that follows still sees the difference. Delivery runs under ; the only production handler +/// is DriverInstanceActor's non-blocking Tell. +/// +internal sealed class HeidenhainAlarmProjection(object sender, Func?> handlers, ILogger logger) +{ + private readonly Lock _lock = new(); + private readonly Dictionary _told = new(StringComparer.Ordinal); + private readonly Dictionary _qualityTold = new(StringComparer.Ordinal); + private readonly Dictionary _failing = new(StringComparer.Ordinal); + private IReadOnlyDictionary _tags = new Dictionary(); + private Subscription? _current; + private long _nextId; + + /// The authored alarm tags (RawPath → tag). Swapped whole on every table build. + public IReadOnlyDictionary Tags + { + get => Volatile.Read(ref _tags); + set => Volatile.Write(ref _tags, value); + } + + /// The live subscription (test seam). + internal Subscription? Current => Volatile.Read(ref _current); + + public IAlarmSubscriptionHandle Subscribe(IReadOnlyList sourceNodeIds) + { + ArgumentNullException.ThrowIfNull(sourceNodeIds); + var refs = sourceNodeIds.Where(r => !string.IsNullOrWhiteSpace(r)).ToHashSet(StringComparer.Ordinal); + var sub = new Subscription(new HeidenhainAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs); + lock (_lock) + { + foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone); + // A (re)subscribe follows a host-side reconnect, where the host re-annotates its conditions; forget which + // ones this projection marked, so a still-failing control re-asserts its quality on the next walk. + _qualityTold.Clear(); + Volatile.Write(ref _current, sub); + } + + return sub.Handle; + } + + public void Unsubscribe(IAlarmSubscriptionHandle handle) + { + lock (_lock) + { + if (Volatile.Read(ref _current) is { } live && ReferenceEquals(live.Handle, handle)) Volatile.Write(ref _current, null); + } + } + + /// One alarm-walk outcome of (Good + list, or a non-Good status). + public void OnWalk(string hostName, uint status, IReadOnlyList? alarms) + { + var now = DateTime.UtcNow; + var tags = Tags; + lock (_lock) + { + if (Volatile.Read(ref _current) is not { } sub) return; + var deviceTags = tags.Values.Where(t => t.HostName == hostName).ToList(); + var subscribed = deviceTags.Where(t => sub.References.Contains(t.RawPath)).ToList(); + if (subscribed.Count == 0) return; + + if (status != HeidenhainStatus.Good || alarms is null) + { + if (!_failing.TryGetValue(hostName, out var was) || was != status) + { + _failing[hostName] = status; + logger.LogWarning( + "Heidenhain {Host}: alarm list unavailable ({Status}); its {Count} alarm condition(s) keep their state and are marked with that quality", + hostName, HeidenhainStatus.State(status), subscribed.Count); + } + } + else if (_failing.Remove(hostName)) + { + logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName); + } + + var h = handlers(); + if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again + + if (status != HeidenhainStatus.Good || alarms is null) + { + foreach (var tag in subscribed) + { + if (_qualityTold.TryGetValue(tag.RawPath, out var q) && q == status) continue; + _qualityTold[tag.RawPath] = status; + Deliver(h, QualityOnly(tag, sub.Handle, status, now)); + } + + return; + } + + var selectors = deviceTags.Select(t => t.Selector).ToList(); + foreach (var tag in subscribed) + { + // Lift a non-Good this projection put there BEFORE the diff, so recovery + a change reads Good then the transition. + if (_qualityTold.Remove(tag.RawPath)) Deliver(h, QualityOnly(tag, sub.Handle, HeidenhainStatus.Good, now)); + _told.TryGetValue(tag.RawPath, out var told); + var (evt, nowTold) = HeidenhainAlarmRules.Transition( + told, HeidenhainAlarmRules.Evaluate(tag.Selector, alarms, selectors), tag, sub.Handle, now); + _told[tag.RawPath] = nowTold; + if (evt is not null) Deliver(h, evt); + } + } + } + + private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now) + { + _told.TryGetValue(tag.RawPath, out var told); + return HeidenhainAlarmRules.Build(tag, handle, now, AlarmTransitionKind.Unspecified, told?.Message ?? "", + told?.Severity ?? AlarmSeverity.Low, told?.OpcUaSeverity, quality); + } + + private void Deliver(EventHandler h, AlarmEventArgs evt) + { + foreach (var registration in h.GetInvocationList()) + { + try { ((EventHandler)registration).Invoke(sender, evt); } + catch (Exception ex) + { + logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId); + } + } + } + + /// The live subscription: its handle and the ConditionIds (RawPaths) it names. + internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet References); +} + +/// Handle returned by . +/// The driver-local subscription number. +public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + /// + public string DiagnosticId => $"heidenhain-alarm-sub-{Id}"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs index cdfe9dbed..4950300db 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs @@ -26,6 +26,15 @@ public enum HeidenhainConnectionState /// Builds the client for an endpoint (test seam). internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout); +/// +/// The alarm-walk outcome for a control's ALARM: conditions (lmxopcua#831): Good +/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with null — never "no alarms". +/// +internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList? alarms); + +/// A session's controller identity, reported once per established session (lmxopcua#831 rediscovery). +internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity); + /// /// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction /// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache @@ -54,6 +63,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private readonly Lsv2ClientFactory _clientFactory; private readonly TimeProvider _time; private readonly ILogger _logger; + private readonly HeidenhainAlarmSink? _alarms; + private readonly HeidenhainIdentitySink? _identity; + private readonly IReadOnlyList _alarmTags; private readonly object _publication = new(); private readonly object _lifecycle = new(); private readonly Dictionary _groupGood = new(); @@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable Action stateChanged, Lsv2ClientFactory clientFactory, TimeProvider time, - ILogger logger) + ILogger logger, + HeidenhainAlarmSink? alarms = null, + HeidenhainIdentitySink? identity = null) { _device = device; _options = HeidenhainOptionsParser.Normalize(options); @@ -92,6 +106,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable _clientFactory = clientFactory; _time = time; _logger = logger; + _alarms = alarms; + _identity = identity; + _alarmTags = tags.Where(t => t.IsAlarmCondition).ToList(); _stateChangedUtc = time.GetUtcNow().UtcDateTime; _groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } @@ -220,6 +237,12 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable SetState(HeidenhainConnectionState.Connected, null); _logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model); + if (session.Identity is { } identity && _identity is not null) + { + try { _identity(this, identity); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} identity handler threw", HostName); } + } + await PollAsync(session, generation, ct).ConfigureAwait(false); // Poll returns only on session loss. Retire(generation, "LSV2 communication lost"); @@ -254,6 +277,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)), [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)), [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)), + [HeidenhainPollGroup.Alarm] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.AlarmMs)), }; var start = _time.GetTimestamp(); var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero); @@ -267,7 +291,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable while (!ct.IsCancellationRequested) { var elapsed = _time.GetElapsedTime(start); - foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) + foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Alarm, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow }) { if (!due.TryGetValue(group, out var at) || at > elapsed) continue; var tags = _groups[group]; @@ -291,9 +315,19 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable if (generation != _generation || !ReferenceEquals(_session, session)) return false; if (session.CommunicationLost) return false; var good = true; + var alarmsDelivered = false; for (var i = 0; i < tags.Count; i++) { var s = samples[i]; + if (tags[i].IsAlarmCondition) + { + // One alarm walk per batch serves every ALARM: condition of this control (they share the outcome). + if (!alarmsDelivered) DeliverAlarms(s.Status, s.Status == HeidenhainStatus.Good ? s.Value as IReadOnlyList : null); + alarmsDelivered = true; + if (s.Status != HeidenhainStatus.Good) good = false; + continue; + } + _publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now)); if (tags[i].Address.Catalog?.IsCapability == true) continue; // Explicitly disabled optional timers are not partial data. @@ -341,14 +375,25 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable private void Invalidate(DateTime now) { + if (_alarmTags.Count > 0) DeliverAlarms(HeidenhainStatus.BadNoCommunication, null); foreach (var tag in _tags) { + if (tag.IsAlarmCondition) continue; _publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true ? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now) : new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now)); } } + /// Hands an alarm-walk outcome to the projection. Called under (ordered with the generation). + private void DeliverAlarms(uint status, IReadOnlyList? alarms) + { + if (_alarms is null) return; + if (status == HeidenhainStatus.Good && alarms is null) status = HeidenhainStatus.BadDecodingError; + try { _alarms(this, status, status == HeidenhainStatus.Good ? alarms : null); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} alarm projection threw", HostName); } + } + private void SetState(HeidenhainConnectionState next, string? error) { HeidenhainConnectionState old; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 65e9ce831..8f5980ac8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// dedup (subscriptions dedup on value AND status). /// public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, - IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable + IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable { /// Most custom (PLC/DATA) bindings one control may carry (reference limit). public const int MaxCustomBindingsPerDevice = 256; @@ -45,6 +45,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi private readonly object _publishLock = new(); private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriptions = new(); + + /// + /// The ALARM: condition projection (lmxopcua#831). Lives as long as the driver — NOT re-created per + /// initialise — so its told record survives a reconnect and an in-place re-init (re-raising would un-ack). + /// + private readonly HeidenhainAlarmProjection _alarms; + + /// + /// Last controller identity seen per control (host:port). Driver-lifetime, so a re-initialise does not + /// forget the baseline; an identity that differs on a later session raises . + /// + private readonly ConcurrentDictionary _identities = new(StringComparer.Ordinal); private HeidenhainDriverOptions _options; private IReadOnlyDictionary _byRawPath = new Dictionary(); private IReadOnlyList _devices = []; @@ -75,6 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi _credentials = credentials; _clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t)); _time = time ?? TimeProvider.System; + _alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger); } /// @@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi /// public event EventHandler? OnHostStatusChanged; + /// + public event EventHandler? OnAlarmEvent; + + /// + /// + /// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects + /// reporting a different identity (R_VR: model, NC/PLC software version or option string) than the + /// previous session — a software update or option install can change what the control serves (selectors, + /// PLC layout). The first session of a control only records the baseline. No extra traffic: the identity is + /// the R_VR every session already reads at connect. + /// + public event EventHandler? OnRediscoveryNeeded; + /// Parses + validates a config document (the one authority the factory and the driver share). /// The config JSON. /// The options. @@ -138,6 +164,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var devices = _options.Devices ?? []; _skipped.BeginRebuild(_options.RawTags.Count); var perDevice = new Dictionary>(); + var alarmTags = new Dictionary(StringComparer.Ordinal); foreach (var d in devices) perDevice[d] = []; foreach (var raw in _options.RawTags) @@ -156,8 +183,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi } var list = perDevice[device]; - if (def.Address.Kind != HeidenhainAddressKind.Standard - && list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice) + if (def.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath + && list.Count(t => t.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath) >= MaxCustomBindingsPerDevice) { _skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control"); continue; @@ -187,17 +214,27 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var runtime = new HeidenhainDeviceRuntime( dev, _options, tags, (role, ct) => _credentials.ResolveAsync(_options, dev, role, ct), - Publish, OnRuntimeState, _clientFactory, _time, _logger); + Publish, OnRuntimeState, _clientFactory, _time, _logger, + (r, status, alarms) => _alarms.OnWalk(r.HostName, status, alarms), + OnIdentity); runtimes.Add(runtime); foreach (var t in tags) { byPath[t.RawPath] = runtime; + if (t.IsAlarmCondition) + { + // A condition, never a value: it never enters the value cache or a data subscription. + alarmTags[t.RawPath] = new HeidenhainAlarmTag(t.RawPath, runtime.HostName, t.Address.AlarmSelector!, t.Alarm?.Severity); + continue; + } + _cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now); } } _devices = runtimes; _byRawPath = byPath; + _alarms.Tags = alarmTags; } private static HeidenhainDeviceOptions? Route(IEnumerable devices, string deviceName) @@ -270,7 +307,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi for (var i = 0; i < fullReferences.Count; i++) result[i] = _cache.TryGetValue(fullReferences[i], out var s) ? s - : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + : new DataValueSnapshot(null, Unknown(fullReferences[i]), null, now); return Task.FromResult>(result); } @@ -288,7 +325,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi var now = _time.GetUtcNow().UtcDateTime; foreach (var r in sub.References) { - var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now); + var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, Unknown(r), null, now); Raise(sub, r, snapshot); } } @@ -297,6 +334,10 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.FromResult(sub); } + /// Status for a reference with no cached value: an ALARM: condition has no value to read. + private uint Unknown(string reference) => + _alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown; + /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { @@ -411,6 +452,60 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi return Task.CompletedTask; } + // ---- IAlarmSource (lmxopcua#831) ---- + + /// + /// + /// are the RawPaths of the alarm-bearing raw tags (the host's alarm set). + /// Collapses to ONE live subscription (the owning actor re-subscribes on every Connected entry). Starts the + /// control loops when nothing else had (an alarm-only driver); the first walk follows within + /// Polling.AlarmMs. + /// + public Task SubscribeAlarmsAsync(IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + var handle = _alarms.Subscribe(sourceNodeIds); + EnsureStarted(); + return Task.FromResult(handle); + } + + /// + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(handle); + _alarms.Unsubscribe(handle); + return Task.CompletedTask; + } + + /// + /// + /// Local-only. LSV2 has no acknowledge request, and the read-only allowlist () + /// is deliberately NOT widened for one: nothing is sent to the control. This is the no-op success the host routes + /// a Part 9 Acknowledge / AddComment to, so the ack is applied (and replicated) server-side — the FOCAS and + /// MTConnect posture. The control clears an alarm itself when its cause is reset. + /// + public Task AcknowledgeAsync(IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(acknowledgements); + return Task.CompletedTask; + } + + /// The alarm projection (test seam). + internal HeidenhainAlarmProjection AlarmProjection => _alarms; + + private void OnIdentity(HeidenhainDeviceRuntime runtime, Lsv2Versions identity) + { + var previous = _identities.GetValueOrDefault(runtime.HostName); + _identities[runtime.HostName] = identity; + if (previous is null || previous == identity) return; + var reason = $"Heidenhain control {runtime.HostName} reconnected with a different identity " + + $"({Describe(previous)} → {Describe(identity)}); re-browse the device and review its tags."; + _logger.LogWarning("{Reason}", reason); + try { OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, null)); } + catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain rediscovery handler threw"); } + + static string Describe(Lsv2Versions v) => $"{v.Model} NC {v.Nc} PLC {v.Plc} options '{v.Options}'"; + } + /// public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index 46b255b0d..0c2b831ed 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -187,9 +187,20 @@ internal sealed class HeidenhainSession : IAsyncDisposable return result; } - private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard - ? tag.Address.Catalog!.Feature - : "Custom|" + tag.Address.Text + "|" + tag.DataType; + private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind switch + { + HeidenhainAddressKind.Standard => tag.Address.Catalog!.Feature, + HeidenhainAddressKind.Alarm => HeidenhainCatalog.AlarmsFeature, + _ => "Custom|" + tag.Address.Text + "|" + tag.DataType, + }; + + /// True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk). + private static bool IsCustom(HeidenhainTagDefinition tag) => + tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath; + + /// The catalog feature a non-custom tag is acquired through. + private static string FeatureOf(HeidenhainTagDefinition tag) => + tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature; /// The current capability string for a feature. public string Capability(string feature) @@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private async Task AcquireAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct) { if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication); - var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard; - var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature; + var isCustom = IsCustom(tag); + var feature = isCustom ? tag.Address.Text : FeatureOf(tag); var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature; if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus); try @@ -251,7 +262,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)), "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)), "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)), - "Alarms" => await AlarmsAsync(ct).ConfigureAwait(false), + HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false), _ => await TimerAsync(feature, ct).ConfigureAwait(false), }; } @@ -419,6 +430,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable private static object? Leaf(HeidenhainTagDefinition tag, object? value) { + // An ALARM: condition receives the whole (immutable copy of the) list; the projection evaluates it. + if (tag.IsAlarmCondition) return value is List alarms ? alarms.ToArray() : null; if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value; var address = tag.Address.Catalog!.Address; var name = address[(address.IndexOf('/') + 1)..]; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs index 4d9b43db2..d4f13462e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel return TagConfigJson.Serialize(_bag); } + /// + /// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830): + /// the hosting DeviceModal shows the save gate's refusal (DriverConfigSaveGate, which covers every + /// password field) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { - var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList(); - foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) }) - if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v))) - errors.Add($"{name} password must be blank, secret:heidenhain/ or env:OTOPCUA_HEIDENHAIN_ — never a literal."); + var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }); return errors.Count == 0 ? null : string.Join(" ", errors); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor index 96faaa293..4390cb413 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -90,6 +90,10 @@ +
+ + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs index a5717b33b..8ec2cd114 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverFormModel.cs @@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel public int StatusMs { get; set; } = 1000; public int CustomMs { get; set; } = 1000; public int SlowMs { get; set; } = 10000; + public int AlarmMs { get; set; } = 2000; public int MaxAlarms { get; set; } = 64; public bool TimersEnabled { get; set; } public int ReconnectInitialMs { get; set; } = 1000; @@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel StatusMs = o.Polling.StatusMs, CustomMs = o.Polling.CustomMs, SlowMs = o.Polling.SlowMs, + AlarmMs = o.Polling.AlarmMs, MaxAlarms = o.Polling.MaxAlarms, TimersEnabled = o.Polling.TimersEnabled, ReconnectInitialMs = o.Reconnect.InitialDelayMs, @@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel ["StatusMs"] = StatusMs, ["CustomMs"] = CustomMs, ["SlowMs"] = SlowMs, + ["AlarmMs"] = AlarmMs, ["MaxAlarms"] = MaxAlarms, ["TimersEnabled"] = TimersEnabled, }; @@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel return _bag.ToJsonString(JsonOptions); } - /// The driver's own verdict on this config (device endpoints are validated on the device form). + /// + /// The driver's own verdict on this config's ranges (device endpoints are validated on the device form). + /// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting DriverConfigModal + /// shows the save gate's refusal (DriverConfigSaveGate, which scans every password field of the stored + /// document) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { var o = HeidenhainOptionsParser.Parse(ToJson()); var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions { - TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access, + TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + Access = new HeidenhainAccessOptions + { + TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled, + DataEnabled = o.Access.DataEnabled, + }, Polling = o.Polling, Reconnect = o.Reconnect, }); return errors.Count == 0 ? null : string.Join(" ", errors); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor index abfe80c7c..fafc9658b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -49,13 +49,23 @@ } - + @if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated) + { + @* lmxopcua#830: the driver dictates the type for this address — show it, locked, + rather than offer a choice the commit would replace. *@ + + } + else + { + + } +
Set by the address.
+ } + else + { + + @foreach (var dt in DataTypes) + { + + } + + }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor index a5a44cb6c..c20f37718 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HeidenhainTagConfigEditor.razor @@ -12,13 +12,15 @@ + placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" /> @foreach (var e in HeidenhainCatalog.Entries) { }
A fixed leaf, PLC:AREA:INDEX (element index into the control's advertised region; AREA one of - @string.Join(", ", HeidenhainAddress.AreaTokens)) or DATA:/path (needs DATA access enabled on the driver). + @string.Join(", ", HeidenhainAddress.AreaTokens)), DATA:/path (needs DATA access enabled on the driver), + or an alarm condition ALARM:* · ALARM:OTHER · + ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199] (enable Native alarm below; acknowledgement is server-side only).
@@ -28,6 +30,11 @@
Fixed by the namespace catalog.
} + else if (_m.IsAlarmAddress) + { + +
A Part 9 condition (active / inactive), not a value.
+ } else {