feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831)

- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
  ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = 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.
This commit is contained in:
Joseph Doherty
2026-11-11 01:37:41 -05:00
parent 5bbe1e3c7a
commit 2b0eed1a8f
22 changed files with 1379 additions and 31 deletions
@@ -30,6 +30,12 @@ public enum HeidenhainAddressKind
/// <summary>A data-path value read via <c>R_DP</c> (DATA role).</summary>
DataPath,
/// <summary>
/// An <c>ALARM:</c> selector — a native Part 9 condition driven from the alarm list (<c>R_RI 27/28</c>), never a
/// value (lmxopcua#831). Requires the cross-driver <c>alarm</c> object on the tag.
/// </summary>
Alarm,
}
/// <summary>A parsed, validated Heidenhain tag address.</summary>
@@ -39,13 +45,15 @@ public enum HeidenhainAddressKind
/// <param name="Area">The PLC area, for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="Index">The PLC <b>element</b> index (not an absolute address), for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="DataPath">The data path as authored, for <see cref="HeidenhainAddressKind.DataPath"/>.</param>
/// <param name="AlarmSelector">The alarm selector, for <see cref="HeidenhainAddressKind.Alarm"/>.</param>
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)
{
/// <summary>Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).</summary>
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;
}
@@ -0,0 +1,191 @@
using System.Globalization;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>Which controller alarms an <c>ALARM:</c> address selects.</summary>
public enum HeidenhainAlarmSelectorKind
{
/// <summary><c>ALARM:*</c> — any active alarm on the tag's control (a device summary condition).</summary>
Any,
/// <summary>
/// <c>ALARM:OTHER</c> — active alarms that no <see cref="Filter"/> selector on the same control matches (the
/// "unmatched" bucket). <see cref="Any"/> and other <see cref="Other"/> tags do not claim an alarm.
/// </summary>
Other,
/// <summary><c>ALARM:CLASS=7</c>, <c>ALARM:CLASS=7/GROUP=4</c>, <c>ALARM:NUMBER=100..199</c>, …</summary>
Filter,
}
/// <summary>An inclusive integer range; <see cref="Low"/> == <see cref="High"/> for a single value.</summary>
/// <param name="Low">Lowest matched value.</param>
/// <param name="High">Highest matched value (inclusive).</param>
public readonly record struct HeidenhainAlarmRange(long Low, long High)
{
/// <summary>Whether <paramref name="value"/> lies in the range.</summary>
/// <param name="value">The value.</param>
/// <returns>True when matched.</returns>
public bool Contains(long value) => value >= Low && value <= High;
/// <inheritdoc />
public override string ToString() => Low == High
? Low.ToString(CultureInfo.InvariantCulture)
: $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}";
}
/// <summary>
/// The selector half of an <c>ALARM:</c> Heidenhain address (lmxopcua#831): which entries of the control's
/// active-alarm list (<c>R_RI 27</c> then <c>28</c> — class, group, number, text) drive the ONE Part 9 condition
/// the raw tag is materialised as.
/// </summary>
/// <remarks>
/// <para>Grammar of the text after <c>ALARM:</c> (keys case-insensitive, no whitespace):</para>
/// <list type="table">
/// <listheader><term>Form</term><description>Matches</description></listheader>
/// <item><term><c>*</c></term><description>any active alarm on the control</description></item>
/// <item><term><c>OTHER</c></term><description>alarms no filter selector on the control matches</description></item>
/// <item><term><c>CLASS=&lt;r&gt;</c>, <c>GROUP=&lt;r&gt;</c>, <c>NUMBER=&lt;r&gt;</c>, joined by <c>/</c></term>
/// <description>every named field in its range (each key at most once)</description></item>
/// </list>
/// <para>A range <c>&lt;r&gt;</c> is <c>n</c> or <c>lo..hi</c> (inclusive; <c>..</c> rather than <c>-</c> because
/// alarm numbers can be negative — a real iTNC 530 reports <c>-1</c>). CLASS and GROUP are the wire's uint16
/// fields (0–65535); NUMBER is int32.</para>
/// <para><b>No semantic names.</b> 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 <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message, then filter.</para>
/// <para><b>One authority.</b> Backs the AdminUI editor, the deploy inspector and the driver's table build.</para>
/// </remarks>
/// <param name="Kind">The selector kind.</param>
/// <param name="Class">The class range, or null for any class (filter only).</param>
/// <param name="Group">The group range, or null for any group (filter only).</param>
/// <param name="Number">The number range, or null for any number (filter only).</param>
public sealed record HeidenhainAlarmSelector(
HeidenhainAlarmSelectorKind Kind,
HeidenhainAlarmRange? Class = null,
HeidenhainAlarmRange? Group = null,
HeidenhainAlarmRange? Number = null)
{
/// <summary>Address prefix of an alarm condition.</summary>
public const string Prefix = "ALARM:";
/// <summary>The <c>*</c> body.</summary>
public const string AnyToken = "*";
/// <summary>The <c>OTHER</c> body.</summary>
public const string OtherToken = "OTHER";
/// <summary>The canonical text after <c>ALARM:</c>.</summary>
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)),
};
/// <summary>
/// Whether this selector matches one alarm on its own terms. <see cref="HeidenhainAlarmSelectorKind.Other"/>
/// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates.
/// </summary>
/// <param name="errorClass">The alarm's class.</param>
/// <param name="group">The alarm's group.</param>
/// <param name="number">The alarm's number.</param>
/// <returns>True when selected.</returns>
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)),
};
/// <summary>Parses the text after <c>ALARM:</c>. Never throws.</summary>
/// <param name="body">The selector text.</param>
/// <param name="selector">The selector on success.</param>
/// <param name="error">Why it was rejected.</param>
/// <returns>True when valid.</returns>
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=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
}
@@ -13,6 +13,9 @@ public enum HeidenhainPollGroup
/// <summary>Identity, tool, alarms, timers and capability strings (<c>Polling.SlowMs</c>).</summary>
Slow,
/// <summary>The alarm-list walk behind <c>ALARM:</c> Part 9 conditions (<c>Polling.AlarmMs</c>, lmxopcua#831).</summary>
Alarm,
}
/// <summary>One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.</summary>
@@ -50,6 +53,9 @@ public static class HeidenhainCatalog
/// <summary>Feature id reported for the optional DNC login.</summary>
public const string DncLoginFeature = "DncLogin";
/// <summary>Feature id of the alarm-list walk (<c>R_RI 27/28</c>) — shared by the <c>Alarms/*</c> leaves and <c>ALARM:</c> conditions.</summary>
public const string AlarmsFeature = "Alarms";
/// <summary>Feature id reported for the opt-in DATA login.</summary>
public const string DataLoginFeature = "DataLogin";
@@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions
/// <summary>Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.</summary>
public int SlowMs { get; init; } = 10000;
/// <summary>
/// Alarm-condition group: the alarm-list walk that drives authored <c>ALARM:</c> Part 9 conditions
/// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an
/// <c>ALARM:</c> tag; the <c>Alarms/*</c> typed arrays stay on <see cref="SlowMs"/>.
/// </summary>
public int AlarmMs { get; init; } = 2000;
/// <summary>Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.</summary>
public int MaxAlarms { get; init; } = 64;
@@ -110,7 +110,7 @@ public static class HeidenhainOptionsParser
var errors = new List<string>();
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;
@@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <param name="Address">The parsed address.</param>
/// <param name="DataType">The OPC data type the driver publishes.</param>
/// <param name="IsArray">Whether the value is a 1-D array.</param>
/// <param name="Alarm">
/// The cross-driver <c>alarm</c> object, present exactly when <see cref="Address"/> is an <c>ALARM:</c> selector
/// (lmxopcua#831); the tag is then a Part 9 condition, never a value.
/// </param>
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)
{
/// <summary>The poll cadence this tag rides.</summary>
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,
};
/// <summary>True for an <c>ALARM:</c> condition tag.</summary>
public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm;
}
/// <summary>The driver's view of a tag's cross-driver <c>alarm</c> object.</summary>
/// <param name="Severity">The authored integer <c>alarm.severity</c>, or null when absent / not an integer.</param>
public sealed record HeidenhainTagAlarm(int? Severity);
/// <summary>
/// The single authority on a Heidenhain raw tag's <c>TagConfig</c>: consumed by the driver's table build,
/// the deploy-time gate (<see cref="Inspect"/>) and the AdminUI typed editor's <c>Validate()</c>, so the
@@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition(
/// <c>{"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}</c>. Array leaves
/// (<c>Axes/*</c>, <c>Alarms/Classes|Groups|Numbers|Messages</c>) must carry <c>"isArray": true</c> so the
/// node's rank matches what the driver publishes; every other address must not.</para>
/// <para><b>Alarm conditions</b> (lmxopcua#831): <c>{"address": "ALARM:CLASS=7", "dataType": "Boolean",
/// "alarm": {...}}</c>. An <c>ALARM:</c> address and the <c>alarm</c> 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
/// <c>Boolean</c>.</para>
/// <para><b>Never a write flag.</b> There is no <c>writable</c> key: the driver is read-only and the deploy
/// gate refuses a ReadWrite access level on any Heidenhain tag.</para>
/// <para><b>Data types are parsed by NAME only.</b> 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=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
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;
}
/// <summary>The fixed <c>Tag.DataType</c> of an <c>ALARM:</c> condition tag (active / inactive).</summary>
public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean;
/// <summary>
/// Deploy-time inspection: empty when <see cref="TryFromTagConfig"/> accepts the blob (never stricter),
/// otherwise one warning explaining the rejection. Never throws.
@@ -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;
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for.</summary>
/// <param name="RawPath">The tag's RawPath — the ConditionId, the only key the host routes on.</param>
/// <param name="HostName">The control (<c>host:port</c>) whose alarm list the tag selects from.</param>
/// <param name="Selector">Which alarms on that control drive the condition.</param>
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or null.</param>
internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity);
/// <summary>
/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real
/// iTNC 530 reports <c>-1</c> for (at least) "Run test of cutout channels" — so two different texts under one
/// number are two alarms.
/// </summary>
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());
}
/// <summary>What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise).</summary>
internal sealed record HeidenhainToldCondition(
bool Active,
IReadOnlySet<HeidenhainAlarmKey> Keys,
HeidenhainAlarmKey? Winner,
string Message,
AlarmSeverity Severity,
int? OpcUaSeverity);
/// <summary>
/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics
/// applied to the LSV2 alarm list.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><b>Overlap is intended</b>: one alarm drives every selector that matches it (<c>*</c> and
/// <c>CLASS=7</c>). <c>OTHER</c> is what no FILTER selector on the same control claims.</item>
/// <item><b>Displayed alarm is deterministic</b> — ascending (class, group, number, text), never wire order,
/// so a reordered unchanged list cannot re-raise.</item>
/// <item><b>Transitions</b>: inactive → active = <c>Raise</c>; a key not in the told set appears, or the
/// displayed alarm leaves while others remain = <c>Retrigger</c> (un-acknowledges: a statement the operator
/// has not seen); a non-displayed member leaves = nothing; nothing matches = <c>Clear</c>.</item>
/// <item><b>Severity</b>: an authored integer <c>alarm.severity</c> is sent exactly (<c>OpcUaSeverity</c>);
/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no
/// class→severity table is invented.</item>
/// </list>
/// </remarks>
internal static class HeidenhainAlarmRules
{
/// <summary>The <see cref="AlarmEventArgs.AlarmType"/> every Heidenhain condition event carries.</summary>
public const string AlarmType = "HeidenhainAlarm";
/// <summary>The alarms one condition matches, in display order (distinct by key).</summary>
public static IReadOnlyList<HeidenhainAlarmKey> Evaluate(
HeidenhainAlarmSelector selector, IReadOnlyList<Lsv2Alarm> alarms, IReadOnlyCollection<HeidenhainAlarmSelector> 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();
}
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="matched"/>, and the new record.</summary>
public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition(
HeidenhainToldCondition? told, IReadOnlyList<HeidenhainAlarmKey> 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 });
}
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
public static AlarmSeverity BucketOf(int severity) => severity switch
{
< 400 => AlarmSeverity.Low,
< 600 => AlarmSeverity.Medium,
< 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// <summary><c>-1 Run test of cutout channels (class 7, group 4) [+2 more]</c>.</summary>
public static string MessageOf(IReadOnlyList<HeidenhainAlarmKey> 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);
}
/// <summary>
/// Drives one Part 9 condition per subscribed <c>ALARM:</c> 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 <c>R_RI 27/28</c> the <c>Alarms/*</c> arrays use, issued by the control's single acquisition loop on
/// the <c>Polling.AlarmMs</c> cadence.
/// </summary>
/// <remarks>
/// <para><b>Lives as long as the driver</b> (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.</para>
/// <para><b>A failed walk is not "no alarms".</b> 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 <c>/alerts</c>
/// 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.</para>
/// <para><b>Read-only acknowledgement.</b> LSV2 has no acknowledge request in the allowlist, and none is added:
/// <see cref="HeidenhainDriver.AcknowledgeAsync"/> 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.</para>
/// <para>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 <see cref="_lock"/>; the only production handler
/// is <c>DriverInstanceActor</c>'s non-blocking <c>Tell</c>.</para>
/// </remarks>
internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger)
{
private readonly Lock _lock = new();
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _failing = new(StringComparer.Ordinal);
private IReadOnlyDictionary<string, HeidenhainAlarmTag> _tags = new Dictionary<string, HeidenhainAlarmTag>();
private Subscription? _current;
private long _nextId;
/// <summary>The authored alarm tags (RawPath → tag). Swapped whole on every table build.</summary>
public IReadOnlyDictionary<string, HeidenhainAlarmTag> Tags
{
get => Volatile.Read(ref _tags);
set => Volatile.Write(ref _tags, value);
}
/// <summary>The live subscription (test seam).</summary>
internal Subscription? Current => Volatile.Read(ref _current);
public IAlarmSubscriptionHandle Subscribe(IReadOnlyList<string> 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);
}
}
/// <summary>One alarm-walk outcome of <paramref name="hostName"/> (Good + list, or a non-Good status).</summary>
public void OnWalk(string hostName, uint status, IReadOnlyList<Lsv2Alarm>? 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<AlarmEventArgs> h, AlarmEventArgs evt)
{
foreach (var registration in h.GetInvocationList())
{
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(sender, evt); }
catch (Exception ex)
{
logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId);
}
}
}
/// <summary>The live subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
}
/// <summary>Handle returned by <see cref="HeidenhainDriver.SubscribeAlarmsAsync"/>.</summary>
/// <param name="Id">The driver-local subscription number.</param>
public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => $"heidenhain-alarm-sub-{Id}";
}
@@ -26,6 +26,15 @@ public enum HeidenhainConnectionState
/// <summary>Builds the client for an endpoint (test seam).</summary>
internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
/// <summary>
/// The alarm-walk outcome for a control's <c>ALARM:</c> conditions (lmxopcua#831): <paramref name="status"/> Good
/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with <c>null</c> — never "no alarms".
/// </summary>
internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList<Lsv2Alarm>? alarms);
/// <summary>A session's controller identity, reported once per established session (lmxopcua#831 rediscovery).</summary>
internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity);
/// <summary>
/// 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<HeidenhainTagDefinition> _alarmTags;
private readonly object _publication = new();
private readonly object _lifecycle = new();
private readonly Dictionary<HeidenhainPollGroup, bool> _groupGood = new();
@@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> 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<HeidenhainTagDefinition>)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<Lsv2Alarm> : 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));
}
}
/// <summary>Hands an alarm-walk outcome to the projection. Called under <see cref="_publication"/> (ordered with the generation).</summary>
private void DeliverAlarms(uint status, IReadOnlyList<Lsv2Alarm>? 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;
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// dedup (subscriptions dedup on value AND status).</para>
/// </remarks>
public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable
{
/// <summary>Most custom (PLC/DATA) bindings one control may carry (reference limit).</summary>
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<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
/// <summary>
/// The <c>ALARM:</c> 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).
/// </summary>
private readonly HeidenhainAlarmProjection _alarms;
/// <summary>
/// Last controller identity seen per control (<c>host:port</c>). Driver-lifetime, so a re-initialise does not
/// forget the baseline; an identity that differs on a later session raises <see cref="OnRediscoveryNeeded"/>.
/// </summary>
private readonly ConcurrentDictionary<string, Lsv2Versions> _identities = new(StringComparer.Ordinal);
private HeidenhainDriverOptions _options;
private IReadOnlyDictionary<string, HeidenhainDeviceRuntime> _byRawPath = new Dictionary<string, HeidenhainDeviceRuntime>();
private IReadOnlyList<HeidenhainDeviceRuntime> _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);
}
/// <inheritdoc />
@@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
/// <inheritdoc />
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <inheritdoc />
/// <remarks>
/// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects
/// reporting a different identity (<c>R_VR</c>: 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 <c>R_VR</c> every session already reads at connect.
/// </remarks>
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <summary>Parses + validates a config document (the one authority the factory and the driver share).</summary>
/// <param name="driverConfigJson">The config JSON.</param>
/// <returns>The options.</returns>
@@ -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<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
var alarmTags = new Dictionary<string, HeidenhainAlarmTag>(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<HeidenhainDeviceOptions> 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<IReadOnlyList<DataValueSnapshot>>(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<ISubscriptionHandle>(sub);
}
/// <summary>Status for a reference with no cached value: an <c>ALARM:</c> condition has no value to read.</summary>
private uint Unknown(string reference) =>
_alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown;
/// <inheritdoc />
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) ----
/// <inheritdoc />
/// <remarks>
/// <paramref name="sourceNodeIds"/> 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
/// <c>Polling.AlarmMs</c>.
/// </remarks>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var handle = _alarms.Subscribe(sourceNodeIds);
EnsureStarted();
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
_alarms.Unsubscribe(handle);
return Task.CompletedTask;
}
/// <inheritdoc />
/// <remarks>
/// <b>Local-only.</b> LSV2 has no acknowledge request, and the read-only allowlist (<see cref="Lsv2ReadOnlyPolicy"/>)
/// 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.
/// </remarks>
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(acknowledgements);
return Task.CompletedTask;
}
/// <summary>The alarm projection (test seam).</summary>
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}'";
}
/// <inheritdoc />
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -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,
};
/// <summary>True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk).</summary>
private static bool IsCustom(HeidenhainTagDefinition tag) =>
tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath;
/// <summary>The catalog feature a non-custom tag is acquired through.</summary>
private static string FeatureOf(HeidenhainTagDefinition tag) =>
tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature;
/// <summary>The current capability string for a feature.</summary>
public string Capability(string feature)
@@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable
private async Task<Outcome> AcquireAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> 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<Lsv2Alarm> alarms ? alarms.ToArray() : null;
if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value;
var address = tag.Address.Catalog!.Address;
var name = address[(address.IndexOf('/') + 1)..];