From 2b0eed1a8f71275d7c9040ffd7175ba141c7394e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:41 -0500 Subject: [PATCH 1/3] 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 { - @foreach (var dt in DataTypes) - { - - } - + @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/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index c9d39f804..8ddb93541 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -246,6 +246,13 @@ public static class RawTagCsvMapper return Invalid(rowNumber, cells, $"TagConfig: {configError}"); } + // The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit + // (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK. + if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError) + { + return Invalid(rowNumber, cells, rowError); + } + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); return new RawCsvReviewRow diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs index 5718c756c..66e3de04f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs @@ -1,22 +1,27 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// -/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the -/// access level is always Read and Tag.DataType follows the address (catalog type, or the binding's -/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the -/// server (RawTreeService) and the deploy gate would refuse. +/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the +/// access level is always Read and Tag.DataType follows the address (catalog type, the binding's declared +/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both +/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have +/// one silently replaced on commit. The rules themselves live in . /// public static class RawTagRowDefaults { /// True for a driver whose tags can never be writable. /// The driver type. /// Whether the access level is fixed at Read. - public static bool IsReadOnlyDriver(string? driverType) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase); + public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType); + + /// The data type the driver dictates for this TagConfig (lock the field to it), or null. + /// The driver type. + /// The row's TagConfig. + /// The dictated type, or null when the operator chooses. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + RawTagRowRules.DictatedDataType(driverType, tagConfig); /// Applies the driver-dictated access level and data type for a row, when the driver dictates them. /// The driver type. @@ -25,8 +30,7 @@ public static class RawTagRowDefaults /// Sets the row's Tag.DataType. public static void Apply(string? driverType, string? tagConfig, Action setAccess, Action setDataType) { - if (!IsReadOnlyDriver(driverType)) return; - setAccess(TagAccessLevel.Read); - if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType); + if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read); + if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs new file mode 100644 index 000000000..159e838d3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs @@ -0,0 +1,66 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and +/// Tag.DataType, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path +/// ( create / update / import), the CSV import's staged review grid +/// (), and the manual-entry grid + tag modal (through , +/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused. +/// +/// +/// A driver with row rules registers its functions in ; the call sites never name a driver. +/// The deploy gate applies the same driver functions (e.g. HeidenhainDeployChecks → +/// ), so authoring and deploy cannot disagree. +/// +public static class RawTagRowRules +{ + /// One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null. + /// Whether the row is ReadWrite. + /// The row's Tag.DataType. + /// The row's TagConfig. + public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig); + + /// One driver's dictated data type for a TagConfig, or null when the operator chooses. + /// The row's TagConfig. + public delegate string? DataTypeRule(string? tagConfig); + + private static readonly Dictionary Rules = + new(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true), + }; + + /// The driver types that carry row rules (test seam / documentation). + public static IReadOnlyCollection DriverTypesWithRules => Rules.Keys; + + /// Why a row may not be saved for this driver, or null when it may. + /// The owning driver's type. + /// The row's access level. + /// The row's Tag.DataType. + /// The row's (normalized) TagConfig. + /// The refusal, or null. + public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) + ? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig) + : null; + + /// True for a driver whose tags can never be writable (the access level is fixed at Read). + /// The driver type. + /// Whether the access level is fixed. + public static bool IsReadOnlyDriver(string? driverType) => + driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly; + + /// + /// The Tag.DataType the driver dictates for this TagConfig (so a form must lock the field to it), or + /// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet). + /// + /// The driver type. + /// The TagConfig. + /// The dictated type name, or null. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 148a37a39..6b5a9064d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); /// - /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only - /// and carry the data type the driver publishes — the same rule the deploy gate applies. + /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — , the + /// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies. /// private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) - ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( - input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) - : null; + RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig); /// /// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path — diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs index d7b59a66d..0c2c5262d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs @@ -53,7 +53,10 @@ public sealed class HeidenhainAuthoringTests { var m = HeidenhainDriverFormModel.FromJson("{}"); m.DncPassword = "hunter2"; - m.Validate()!.ShouldNotContain("hunter2"); + // #830: the credential refusal is the hosting modal's (the save gate), reported once — not the form's too. + var refusal = DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, m.ToJson()); + refusal.ShouldNotBeNull(); + refusal.ShouldNotContain("hunter2"); m.DncPassword = null; m.StatusMs = 10; m.Validate().ShouldNotBeNull(); @@ -66,7 +69,7 @@ public sealed class HeidenhainAuthoringTests d.Port.ShouldBe(19000); d.Validate().ShouldBeNull(); d.DataPassword = "plain"; - d.Validate().ShouldNotBeNull(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, d.ToJson()).ShouldNotBeNull(); // #830: the modal's gate d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA"; var json = d.ToJson(); JsonNode.Parse(json)!["Note"]!.GetValue().ShouldBe("keep"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs new file mode 100644 index 000000000..b5a48a91a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs @@ -0,0 +1,176 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// lmxopcua#830 authoring UX: (a) the CSV review grid previews the SAME row rule the commit applies, (b) the +/// driver-dictated data type is a lock, never a silent rewrite, (c) a credential refusal is reported once. +/// Plus the #831 ALARM: authoring seams (editor seeding, native-alarm editor visibility). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainAuthoringUxTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private const string Header = "Name,DataType,AccessLevel,TagConfigJson"; + + private static string Csv(params string[] rows) => Header + "\n" + string.Join("\n", rows); + + private static string Json(string json) => "\"" + json.Replace("\"", "\"\"") + "\""; + + // ---- (a) CSV preview == commit ---- + + [Fact] + public void Csv_preview_flags_ReadWrite_and_data_type_mismatch_rows_like_the_commit() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, Csv( + $"ok,Int32,Read,{exec}", + $"rw,Int32,ReadWrite,{exec}", + $"dt,Double,Read,{exec}", + $"feed,Int32,Read,{Json("""{"address":"Overrides/Feed"}""")}")); + + result.FatalError.ShouldBeNull(); + var rows = result.Rows; + rows[0].Ok.ShouldBeTrue(rows[0].Error); + rows[1].Error!.ShouldContain("read-only"); + rows[2].Error!.ShouldContain("Int32"); + rows[3].Error!.ShouldContain("Double"); // a Float64 leaf publishes Double — picked as Int32 it used to preview OK + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public async Task Csv_preview_and_ImportTags_agree_row_for_row() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var csv = Csv($"ok,Int32,Read,{exec}", $"rw,Int32,ReadWrite,{exec}", $"dt,String,Read,{exec}", + $"plc,Int64,Read,{Json("""{"address":"PLC:DWORD:0","dataType":"Int64"}""")}"); + var preview = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, csv); + + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct); + string deviceId; + using (var db = RawTreeTestDb.CreateNamed(name)) deviceId = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId).DeviceId; + + // Feed EVERY parsed row (the invalid ones too, rebuilt from their cells) to the commit and compare verdicts. + foreach (var row in preview.Rows) + { + var dataType = row.Cells[1]; + var access = Enum.Parse(row.Cells[2]); + var input = new RawTagInput(row.Cells[0], dataType, access, false, null, row.Cells[3]); + var outcome = await svc.ImportTagsAsync(deviceId, [new RawTagImportRow(null, input)], Ct); + (outcome.Errors.Count == 0).ShouldBe(row.Ok, $"{row.Name}: preview {row.Error ?? "OK"} vs commit {string.Join("; ", outcome.Errors)}"); + } + } + + [Fact] + public void Row_rules_are_driver_dispatched_not_named_at_call_sites() + { + RawTagRowRules.DriverTypesWithRules.ShouldContain(DriverTypeNames.Heidenhain); + RawTagRowRules.Problem(DriverTypeNames.Modbus, TagAccessLevel.ReadWrite, "Float", "{}").ShouldBeNull(); + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.ReadWrite, "Int32", """{"address":"Status/ExecutionState"}""") + .ShouldBe(HeidenhainTagRules.DescribeRowProblem(true, "Int32", """{"address":"Status/ExecutionState"}""")); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.Heidenhain).ShouldBeTrue(); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.S7).ShouldBeFalse(); + } + + // ---- (b) dictated data type is a lock ---- + + [Theory] + [InlineData("""{"address":"Axes/Positions","isArray":true}""", "Double")] + [InlineData("""{"address":"PLC:WORD:3","dataType":"Int16"}""", "Int16")] + [InlineData("""{"address":"ALARM:*","alarm":{}}""", "Boolean")] + public void The_dictated_data_type_is_what_the_grid_and_modal_lock_to(string config, string expected) + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, config).ShouldBe(expected); + // ...and the locked value is exactly what the save path accepts — nothing to rewrite on commit. + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.Read, expected, config).ShouldBeNull(); + } + + [Fact] + public void An_unresolved_config_or_another_driver_leaves_the_data_type_to_the_operator() + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, "{}").ShouldBeNull(); + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Modbus, """{"address":"Axes/Positions","isArray":true}""").ShouldBeNull(); + } + + // ---- (c) credential error shown once ---- + + [Fact] + public void A_literal_password_is_reported_by_the_modal_save_gate_only_not_also_by_the_form() + { + var driver = HeidenhainDriverFormModel.FromJson("{}"); + driver.InspectPassword = "hunter2"; + var json = driver.ToJson(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, json).ShouldNotBeNull(); // the modal's one notice + Save disabled + driver.Validate().ShouldBeNull(); // the form no longer repeats it + driver.StatusMs = 10; + driver.Validate()!.ShouldContain("StatusMs"); // non-credential problems still show in the form + + var device = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7"}"""); + device.DncPassword = "hunter2"; + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, device.ToJson()).ShouldNotBeNull(); + device.Validate().ShouldBeNull(); + HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host still required in the form + } + + // ---- #831 authoring seams ---- + + [Fact] + public void Picking_an_alarm_selector_seeds_the_alarm_object_and_leaving_it_drops_it() + { + var m = HeidenhainTagConfigModel.FromJson("{}"); + m.Address = "ALARM:CLASS=7"; + var json = m.ToJson(); + m.Validate().ShouldBeNull(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull(); + NativeAlarmModel.FromJson(json).IsAlarm.ShouldBeTrue(); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(HeidenhainTagConfigModel.DefaultAlarmSeverity); + + // An authored severity is never overwritten by a later edit. + var edited = NativeAlarmModel.Edit(json, a => a.Severity = 950); + var again = HeidenhainTagConfigModel.FromJson(edited); + again.Address = "ALARM:*"; + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", again.ToJson(), out def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(950); + + again.Address = "Status/ExecutionState"; + var plain = again.ToJson(); + NativeAlarmModel.FromJson(plain).IsAlarm.ShouldBeFalse(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, plain).ShouldBeNull(); + } + + [Fact] + public void Heidenhain_offers_the_native_alarm_editor_and_a_csv_alarm_row_imports() + { + NativeAlarmModel.SupportsNativeAlarms(DriverTypeNames.Heidenhain).ShouldBeTrue(); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, + "Name,DataType,AccessLevel,TagConfigJson,Alarm.AlarmType,Alarm.Severity\n" + + $"Any,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},AlarmCondition,800\n" + + $"NoAlarm,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},,\n"); + result.Rows[0].Ok.ShouldBeTrue(result.Rows[0].Error); + result.Rows[1].Ok.ShouldBeFalse(); // a selector without the alarm object is refused in the preview + } + + [Fact] + public void The_driver_form_round_trips_the_alarm_cadence() + { + var m = HeidenhainDriverFormModel.FromJson("{}"); + m.AlarmMs.ShouldBe(2000); + m.AlarmMs = 500; + var o = HeidenhainOptionsParser.Parse(m.ToJson()); + o.Polling.AlarmMs.ShouldBe(500); + m.AlarmMs = 50; + m.Validate()!.ShouldContain("AlarmMs"); + } +} From 97991e047609c3d025bd0caf3e0e1180dd9590b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:54:32 -0500 Subject: [PATCH 3/3] test(heidenhain): wait for the whole outage-invalidation pass before asserting on it (pre-existing race) Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values failed 1/40 on base 5bbe1e3c (and 1/29 on this branch): "Capabilities/Identity should be CommunicationLost but was Supported". RetireAll -> Invalidate publishes every tag in authored order under one lock, capability LAST, and the recorder sees each publish as it happens; the test asserted the capability the instant the five value tags read BadNoCommunication, i.e. before the pass's final publish. Now the wait includes the capability's CommunicationLost (a presence the pass must produce) and additionally asserts no value went Good again during the outage. Second latent race fixed in the same test: Degraded holds through Backoff AND Connecting, but the host status is Stopped only in Backoff (Unknown while a reconnect attempt is in flight), so the test now waits for Degraded && Stopped instead of sampling whichever phase it hit. After the fix: 79/80 filtered runs passed and 10/10 full-suite runs; the one non-pass was a vstest "failed to connect to testhost after 90 s" host-startup timeout (the test never ran), not a test result. --- .../HeidenhainDriverTests.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs index 4ef3030e4..c69b67a22 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs @@ -151,13 +151,26 @@ public sealed class HeidenhainDriverTests sim.GoOffline(); // EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once. - await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication)); - rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost"); + // The invalidation is ONE pass over the device's tags (in authored order, capability last), delivered to this + // thread's recorder as it runs — so wait for the WHOLE pass to land (the capability's CommunicationLost is + // its last publication) before asserting on any of it. Asserting the capability the instant the five value + // tags read Bad raced the pass's final publish (1/40 on 5bbe1e3c: "Supported"). + await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication) + && rec.Last(tags[5].RawPath)?.Value is "CommunicationLost"); rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good); + // ...and it is one pass: no remote value published Good again after its outage invalidation. + foreach (var t in tags.Take(5)) + { + var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList(); + seq.Skip(seq.LastIndexOf(HeidenhainStatus.BadNoCommunication)).ShouldAllBe(s => s == HeidenhainStatus.BadNoCommunication); + } + (await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication); - await WaitUntil(() => d.GetHealth().State == DriverState.Degraded); + // Degraded holds through every reconnect attempt (Backoff AND Connecting carry the last error), but the host + // status reads Stopped only in Backoff and Unknown while a reconnect attempt is in flight — so wait for the + // Backoff phase rather than sample whichever phase the loop happens to be in. + await WaitUntil(() => d.GetHealth().State == DriverState.Degraded && d.GetHostStatuses().Single().State == HostState.Stopped); d.GetHealth().LastError.ShouldNotBeNullOrEmpty(); - d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped); hostEvents.ShouldContain(e => e.NewState == HostState.Stopped); var frozen = d.GetHealth().LastSuccessfulRead; await Task.Delay(500, Ct);