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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -51,9 +51,10 @@ public readonly record struct HeidenhainAlarmRange(long Low, long High)
|
||||
/// <para>A range <c><r></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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user