From 2b0eed1a8f71275d7c9040ffd7175ba141c7394e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:41 -0500 Subject: [PATCH] feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER, ALARM:CLASS=[/GROUP=][/NUMBER=] ( = n | lo..hi; negative numbers allowed). An ALARM: address and the cross-driver alarm object only work together — the factory refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription. - HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath, driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag). FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text, deterministic display order, told-record on the driver instance, recorded only while a handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear. - Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture); Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists. - Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged. - IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic. - AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM: tags at Boolean and seeds/drops the alarm object with the selector; driver form gains "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.) Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530 alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery), Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver. --- .claude/rules/drivers.md | 11 +- .../IAlarmSource.cs | 2 +- .../HeidenhainAddress.cs | 20 +- .../HeidenhainAlarmSelector.cs | 191 +++++++++ .../HeidenhainCatalog.cs | 6 + .../HeidenhainDriverOptions.cs | 7 + .../HeidenhainOptionsParser.cs | 2 +- .../HeidenhainTagDefinitionFactory.cs | 69 +++- .../HeidenhainAlarmProjection.cs | 287 +++++++++++++ .../HeidenhainDeviceRuntime.cs | 49 ++- .../HeidenhainDriver.cs | 107 ++++- .../HeidenhainSession.cs | 25 +- .../Drivers/Forms/HeidenhainDriverForm.razor | 4 + .../Forms/HeidenhainDriverFormModel.cs | 17 +- .../HeidenhainTagConfigEditor.razor | 11 +- .../TagEditors/HeidenhainTagConfigModel.cs | 32 ++ .../Uns/TagEditors/NativeAlarmModel.cs | 2 +- .../DraftValidatorHeidenhainTests.cs | 15 + .../HeidenhainAlarmTests.cs | 384 ++++++++++++++++++ .../Uns/NativeAlarmEditorModelTests.cs | 2 +- .../ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj | 3 + .../HeidenhainNativeAlarmEndToEndTests.cs | 164 ++++++++ 22 files changed, 1379 insertions(+), 31 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..e2c058b0e 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` / @@ -319,6 +321,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/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/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/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 {