feat(heidenhain): Part 9 condition class from alarm class/group, ALARM: selectors in the browse picker, unclaimed alarms counted + logged (#839)

- Core: optional AlarmEventArgs.ConditionClass (AlarmConditionClass Base/Process/Maintenance/System/Safety)
  carried by NativeAlarmProjector on AlarmConditionSnapshot and written to ConditionClassId/Name by
  OtOpcUaNodeManager (kept across class-less events, carried across a #641 rebuild).
- Heidenhain: HeidenhainAlarmClasses — HEIDENHAIN DNC-interface class/group names (corroborated by the
  captured iTNC 530 record); class 5 => Safety, group 1/2 => Process, 4/5 => System, else Base.
- DiscoverAsync offers AlarmConditions/ selector leaves (IsAlarm Boolean); browse-commit seeds the alarm object.
- Alarms no selector claims: otopcua.driver.alarms.unmatched counter, /hosts diagnostics, rate-limited Warning.

Refs #839
This commit is contained in:
Joseph Doherty
2026-11-11 03:41:14 -05:00
parent 481985e20b
commit ddc1f29e3a
17 changed files with 593 additions and 39 deletions
@@ -0,0 +1,98 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// Names for the class / group codes of a Heidenhain control's active-alarm list, and the Part 9
/// <c>ConditionClass</c> each maps to (lmxopcua#839).
/// </summary>
/// <remarks>
/// <para><b>Provenance.</b> LSV2 itself documents neither field (pyLSV2 and the ignitionoee Java port keep them
/// raw). The names are HEIDENHAIN's own error enums from the DNC interface (RemoTools SDK / HeidenhainDNC
/// <c>DNC_ERROR_CLASS</c>, <c>DNC_ERROR_GROUP</c>), which describe the same control error list. That the LSV2
/// <c>R_RI 27/28</c> codes use the same numbering is corroborated by the one real record we hold — the captured
/// iTNC 530 alarm "Run test of cutout channels" is class 7 (<c>INFO</c>), group 4 (<c>GENERAL</c>), a power-up
/// self-test notice — but is not otherwise proven on hardware. Treat the names as labels, never as behaviour:
/// they drive no selection and no severity (an authored <c>alarm.severity</c>, else High, still decides that).</para>
/// <para><b>Part 9 mapping — only where the code says where the alarm comes from.</b>
/// <c>EMERGENCY_STOP</c> (class 5) is a safety reaction ⇒ <see cref="AlarmConditionClass.Safety"/>, whatever its
/// group. Otherwise the GROUP decides: <c>OPERATING</c> / <c>PROGRAMMING</c> (machining and NC-program errors) ⇒
/// <see cref="AlarmConditionClass.Process"/>; <c>GENERAL</c> / <c>REMOTE</c> (the control's own system and
/// remote-interface messages) ⇒ <see cref="AlarmConditionClass.System"/>. <c>PLC</c> messages are written by the
/// machine builder and can be process, maintenance or safety alike, and <c>PYTHON</c> / unknown codes say nothing
/// ⇒ <see cref="AlarmConditionClass.Base"/> ("not modelled"). Nothing maps to Maintenance: no code is evidence of
/// it.</para>
/// </remarks>
public static class HeidenhainAlarmClasses
{
/// <summary>The emergency-stop error class.</summary>
public const int EmergencyStopClass = 5;
/// <summary>Class code → HEIDENHAIN DNC name (<c>DNC_ERROR_CLASS</c>).</summary>
public static IReadOnlyDictionary<int, string> ClassNames { get; } = new Dictionary<int, string>
{
[0] = "None",
[1] = "Warning",
[2] = "FeedHold",
[3] = "ProgramHold",
[4] = "ProgramAbort",
[5] = "EmergencyStop",
[6] = "Reset",
[7] = "Info",
[8] = "Error",
[9] = "Note",
};
/// <summary>Group code → HEIDENHAIN DNC name (<c>DNC_ERROR_GROUP</c>).</summary>
public static IReadOnlyDictionary<int, string> GroupNames { get; } = new Dictionary<int, string>
{
[0] = "None",
[1] = "Operating",
[2] = "Programming",
[3] = "Plc",
[4] = "General",
[5] = "Remote",
[6] = "Python",
};
/// <summary>The browse-picker folder holding the <c>ALARM:</c> selectors (lmxopcua#839).</summary>
public const string BrowseFolder = "AlarmConditions";
/// <summary>
/// The <c>ALARM:</c> selectors the <c>/raw</c> browse picker offers (lmxopcua#839), as (sub-folder or null, leaf
/// name, address): <c>*</c> and <c>OTHER</c> at the folder root, then one <c>CLASS=n</c> per named class
/// (<c>ByClass/</c>) and one <c>GROUP=n</c> per named group (<c>ByGroup/</c>), code 0 ("None") excluded. The leaf
/// name carries the code AND the DNC name (<c>Class5_EmergencyStop</c>) so a committed tag still says which
/// number it filters on. Any other selector (ranges, NUMBER, combinations) is typed by hand as before.
/// </summary>
public static IReadOnlyList<(string? SubFolder, string Name, string Address)> BrowseSelectors { get; } = BuildBrowseSelectors();
private static List<(string?, string, string)> BuildBrowseSelectors()
{
var list = new List<(string?, string, string)>
{
(null, "AnyAlarm", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.AnyToken),
(null, "OtherAlarms", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.OtherToken),
};
foreach (var (code, name) in ClassNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
list.Add(("ByClass", $"Class{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}CLASS={code}"));
foreach (var (code, name) in GroupNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
list.Add(("ByGroup", $"Group{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}GROUP={code}"));
return list;
}
/// <summary>The Part 9 condition class of one controller alarm (see the remarks for the evidence).</summary>
/// <param name="errorClass">The alarm's class code.</param>
/// <param name="group">The alarm's group code.</param>
/// <returns>The class; <see cref="AlarmConditionClass.Base"/> when the codes say nothing.</returns>
public static AlarmConditionClass ConditionClassOf(int errorClass, int group)
{
if (errorClass == EmergencyStopClass) return AlarmConditionClass.Safety;
return group switch
{
1 or 2 => AlarmConditionClass.Process,
4 or 5 => AlarmConditionClass.System,
_ => AlarmConditionClass.Base,
};
}
}
@@ -51,9 +51,10 @@ public readonly record struct HeidenhainAlarmRange(long Low, long High)
/// <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>Numeric codes only.</b> The grammar takes raw codes, never names: LSV2 itself does not document them.
/// <see cref="HeidenhainAlarmClasses"/> carries HEIDENHAIN's DNC-interface names for labels (the browse picker) and
/// the Part 9 condition class (lmxopcua#839), but a selector always filters on the number the wire carries —
/// observe it on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message.</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>
@@ -1,5 +1,6 @@
using System.Text;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
@@ -45,8 +46,10 @@ internal sealed record HeidenhainToldCondition(
/// 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>
/// otherwise the bucket is High and no exact value is sent. No class→severity table is invented.</item>
/// <item><b>Condition class</b> (lmxopcua#839): a Raise / Retrigger carries the DISPLAYED alarm's Part 9 class
/// (<see cref="HeidenhainAlarmClasses.ConditionClassOf"/>); a Clear or a quality-only event carries none, so the
/// node keeps the class of the alarm it last showed.</item>
/// </list>
/// </remarks>
internal static class HeidenhainAlarmRules
@@ -90,10 +93,11 @@ internal static class HeidenhainAlarmRules
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);
var conditionClass = HeidenhainAlarmClasses.ConditionClassOf(winner.Class, winner.Group);
if (told is not { Active: true })
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised);
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact, conditionClass: conditionClass), raised);
if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner)
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised);
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact, conditionClass: conditionClass), raised);
return (null, told with { Keys = keys });
}
@@ -119,7 +123,8 @@ internal static class HeidenhainAlarmRules
}
public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc,
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) =>
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null,
AlarmConditionClass? conditionClass = null) =>
new(handle,
SourceNodeId: tag.HostName,
ConditionId: tag.RawPath,
@@ -129,7 +134,8 @@ internal static class HeidenhainAlarmRules
SourceTimestampUtc: nowUtc,
Kind: kind,
OpcUaSeverity: exact,
QualityStatusCode: quality);
QualityStatusCode: quality,
ConditionClass: conditionClass);
}
/// <summary>
@@ -154,8 +160,14 @@ internal static class HeidenhainAlarmRules
/// 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)
internal sealed class HeidenhainAlarmProjection(
object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger, string driverInstanceId = "")
{
/// <summary>The shortest gap between two "unclaimed alarms" log lines for one control (lmxopcua#839).</summary>
internal static readonly TimeSpan DefaultUnmatchedLogInterval = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, UnmatchedState> _unmatched = new(StringComparer.Ordinal);
private readonly Lock _lock = new();
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
@@ -226,6 +238,8 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName);
}
if (status == HeidenhainStatus.Good && alarms is not null) TrackUnmatched(hostName, deviceTags, alarms, now);
var h = handlers();
if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again
@@ -255,6 +269,77 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
}
}
/// <summary>The gap between two "unclaimed alarms" log lines per control (test seam).</summary>
internal TimeSpan UnmatchedLogInterval { get; set; } = DefaultUnmatchedLogInterval;
/// <summary>
/// Per control: how many alarms on its last good walk no configured selector claims, and how many unclaimed
/// occurrences were seen since the driver started (lmxopcua#839). Published on <c>/hosts</c> via
/// <see cref="HeidenhainDriver.GetDiagnostics"/>.
/// </summary>
/// <returns>Host → (current, total).</returns>
public IReadOnlyDictionary<string, (int Current, long Total)> UnmatchedSnapshot()
{
lock (_lock)
return _unmatched.ToDictionary(kv => kv.Key, kv => (kv.Value.Keys.Count, kv.Value.Total), StringComparer.Ordinal);
}
/// <summary>
/// Controller alarms that no configured <c>ALARM:</c> selector on the control claims are shown by NO condition
/// (lmxopcua#839 — they used to vanish silently). A control with a <c>*</c> or <c>OTHER</c> selector claims every
/// alarm. Each alarm ENTERING the unclaimed set counts once (metric + total), so a standing alarm is not
/// re-counted every walk; the log is rate-limited per control to one line per <see cref="UnmatchedLogInterval"/>,
/// naming up to three alarms and how many more were suppressed since the previous line. Must run under
/// <see cref="_lock"/>.
/// </summary>
private void TrackUnmatched(string hostName, List<HeidenhainAlarmTag> deviceTags, IReadOnlyList<Lsv2Alarm> alarms, DateTime now)
{
if (!_unmatched.TryGetValue(hostName, out var state)) _unmatched[hostName] = state = new UnmatchedState();
var claimsAll = deviceTags.Any(t => t.Selector.Kind != HeidenhainAlarmSelectorKind.Filter);
var current = claimsAll
? new HashSet<HeidenhainAlarmKey>()
: alarms.Where(a => !deviceTags.Any(t => t.Selector.Matches(a.ErrorClass, a.Group, a.Number)))
.Select(HeidenhainAlarmKey.Of).ToHashSet();
var entered = current.Where(k => !state.Keys.Contains(k))
.OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number).ThenBy(k => k.Message, StringComparer.Ordinal)
.ToList();
state.Keys = current;
if (entered.Count == 0) return;
state.Total += entered.Count;
OtOpcUaTelemetry.DriverAlarmsUnmatched.Add(entered.Count,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.Heidenhain),
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId));
state.PendingLog += entered.Count;
if (state.LastLogUtc is { } last && now - last < UnmatchedLogInterval)
{
state.PendingSample ??= entered;
return;
}
var sample = state.PendingSample is { } earlier ? earlier.Concat(entered).Distinct().ToList() : entered;
var suppressed = state.PendingLog - entered.Count;
logger.LogWarning(
"Heidenhain {Host}: {Count} controller alarm(s) match no ALARM: selector on this control and are shown by no condition " +
"(e.g. {Examples}){Suppressed}; add a selector or an ALARM:OTHER catch-all to surface them",
hostName, entered.Count, string.Join("; ", sample.Take(3).Select(Describe)),
suppressed > 0 ? $"; {suppressed} more since the previous report" : "");
state.LastLogUtc = now;
state.PendingLog = 0;
state.PendingSample = null;
static string Describe(HeidenhainAlarmKey k) => HeidenhainAlarmRules.MessageOf([k]);
}
private sealed class UnmatchedState
{
public HashSet<HeidenhainAlarmKey> Keys { get; set; } = [];
public long Total { get; set; }
public DateTime? LastLogUtc { get; set; }
public long PendingLog { get; set; }
public List<HeidenhainAlarmKey>? PendingSample { get; set; }
}
private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
{
_told.TryGetValue(tag.RawPath, out var told);
@@ -87,7 +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);
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger, driverInstanceId);
}
/// <inheritdoc />
@@ -419,6 +419,13 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
}
// lmxopcua#839: controller alarms no ALARM: selector claims (shown by no condition) — current + since start.
foreach (var (host, (current, total)) in _alarms.UnmatchedSnapshot())
{
bag[$"{host}.alarms.unmatched"] = current.ToString(System.Globalization.CultureInfo.InvariantCulture);
bag[$"{host}.alarms.unmatchedTotal"] = total.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
return DriverDiagnostics.Bound(bag);
}
@@ -428,7 +435,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
/// <inheritdoc />
/// <remarks>
/// The namespace is a fixed catalog (no control-dependent path churn), so the browse picker can offer it
/// without contacting the control. PLC/DATA bindings are authored by hand: LSV2 has no proven universal
/// without contacting the control — plus the <c>AlarmConditions/</c> folder of <c>ALARM:</c> selectors
/// (lmxopcua#839). PLC/DATA bindings are authored by hand: LSV2 has no proven universal
/// symbol model and this driver performs no memory scanning.
/// </remarks>
public bool SupportsOnlineDiscovery => true;
@@ -449,6 +457,20 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
e.Address, e.DataType, e.IsArray, null, SecurityClassification.ViewOnly, IsHistorized: false));
}
// lmxopcua#839: the ALARM: selectors, so a condition can be browse-committed rather than only typed. Each is an
// IsAlarm Boolean leaf; the commit's typed model seeds the paired `alarm` object the factory requires.
var alarmRoot = builder.Folder(HeidenhainAlarmClasses.BrowseFolder, HeidenhainAlarmClasses.BrowseFolder);
var alarmFolders = new Dictionary<string, IAddressSpaceBuilder>(StringComparer.Ordinal);
foreach (var (sub, name, address) in HeidenhainAlarmClasses.BrowseSelectors)
{
var parent = alarmRoot;
if (sub is not null && !alarmFolders.TryGetValue(sub, out parent))
alarmFolders[sub] = parent = alarmRoot.Folder(sub, sub);
parent.Variable(name, name, new DriverAttributeInfo(
address, HeidenhainTagDefinitionFactory.AlarmConditionDataType, false, null, SecurityClassification.ViewOnly,
IsHistorized: false, IsAlarm: true));
}
return Task.CompletedTask;
}