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
@@ -1209,6 +1209,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
condition.Message!.Value = new LocalizedText(state.Message);
// #839: a classifying native source's Part 9 ConditionClass. Null leaves the node's class as it is
// (BaseConditionClass from materialise, or what an earlier event set). Not in the delta-gate: a class
// rides along with the transition that set it and never fires an event on its own.
if (state.ConditionClass is { } conditionClass) ApplyConditionClass(condition, conditionClass);
// Project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
@@ -1244,6 +1248,27 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
}
/// <summary>
/// The Part 9 class type id + browse name of an <see cref="AlarmConditionClass"/> (lmxopcua#839).
/// </summary>
/// <param name="conditionClass">The driver-assigned class.</param>
/// <returns>The <c>ConditionClassId</c> / <c>ConditionClassName</c> pair to write.</returns>
internal static (NodeId Id, string Name) ConditionClassOf(AlarmConditionClass conditionClass) => conditionClass switch
{
AlarmConditionClass.Process => (ObjectTypeIds.ProcessConditionClassType, "ProcessConditionClass"),
AlarmConditionClass.Maintenance => (ObjectTypeIds.MaintenanceConditionClassType, "MaintenanceConditionClass"),
AlarmConditionClass.System => (ObjectTypeIds.SystemConditionClassType, "SystemConditionClass"),
AlarmConditionClass.Safety => (ObjectTypeIds.SafetyConditionClassType, "SafetyConditionClass"),
_ => (ObjectTypeIds.BaseConditionClassType, "BaseConditionClass"),
};
private static void ApplyConditionClass(AlarmConditionState condition, AlarmConditionClass conditionClass)
{
var (id, name) = ConditionClassOf(conditionClass);
if (condition.ConditionClassId is not null) condition.ConditionClassId.Value = id;
if (condition.ConditionClassName is not null) condition.ConditionClassName.Value = new LocalizedText(name);
}
/// <summary>
/// The <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
@@ -1623,9 +1648,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
// upstream category is Safety/Diagnostics. Per-alarm classification arrives at RUNTIME instead: a
// classifying native driver states AlarmEventArgs.ConditionClass on its transitions (#839, Heidenhain),
// which WriteAlarmCondition applies over this default — the deploy-time seam still holds none.
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
@@ -1783,7 +1808,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
LocalizedText? Comment = null, string? ClientUserId = null, NodeId? ConditionClassId = null,
LocalizedText? ConditionClassName = null);
/// <summary>
/// Read what a native condition node holds right now, so the rebuild can put it back.
@@ -1810,7 +1836,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
condition.ActiveState?.TransitionTime?.Value.ToDateTime(),
condition.Quality?.SourceTimestamp?.Value.ToDateTime(),
condition.Comment?.Value,
condition.ClientUserId?.Value);
condition.ClientUserId?.Value,
condition.ConditionClassId?.Value,
condition.ConditionClassName?.Value);
/// <summary>
/// Put a carried state back onto the freshly materialised native condition. MUST be called
@@ -1839,6 +1867,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
// #839: a driver-assigned ConditionClass is a property of the condition, not of its state — restored whether
// or not the condition is off-normal (the materialise default is BaseConditionClass).
if (alarm.ConditionClassId is { } classIdNode && carried.ConditionClassId is { } classId && !classId.IsNull)
classIdNode.Value = classId;
if (alarm.ConditionClassName is not null && carried.ConditionClassName is { } className)
alarm.ConditionClassName.Value = className;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode)> _prior =
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode, AlarmConditionClass? Class)> _prior =
new(StringComparer.Ordinal);
/// <summary>
@@ -82,7 +82,7 @@ public sealed class NativeAlarmProjector
// driver's word, so a late ack failure must not rewrite it.
_pendingLocalAck.Remove(nodeId);
var prev = _prior.TryGetValue(nodeId, out var p)
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good);
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good, Class: (AlarmConditionClass?)null);
// A source with a finer scale than the four buckets supplies the exact Part 9 severity.
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
// Only a statement saying the SAME thing the condition already shows is a re-statement.
@@ -154,14 +154,18 @@ public sealed class NativeAlarmProjector
}
var statusCode = e.QualityStatusCode ?? OpcUaStatus.Good;
_prior[nodeId] = (active, acked, e.Message, sev, statusCode);
// A source that classifies its conditions (#839) states the class on its transitions; one that does not (or a
// quality-only annotation) keeps whatever the condition last carried.
var conditionClass = e.ConditionClass ?? prev.Class;
_prior[nodeId] = (active, acked, e.Message, sev, statusCode, conditionClass);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
// A per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
// (every earlier source) ⇒ Good, as before — driver-wide comms loss stays the host's
// out-of-band WriteAlarmQuality annotation, not something the projector infers.
StatusCode: statusCode);
StatusCode: statusCode,
ConditionClass: conditionClass);
}
/// <summary>
@@ -195,7 +199,7 @@ public sealed class NativeAlarmProjector
snapshot = new AlarmConditionSnapshot(
Active: p.Active, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: p.Severity, Message: p.Message ?? string.Empty,
StatusCode: p.StatusCode);
StatusCode: p.StatusCode, ConditionClass: p.Class);
return true;
}