482bafdfac
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:
1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
editor, the address picker, the deploy-time inspector and driver init.
Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).
E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
261 lines
15 KiB
C#
261 lines
15 KiB
C#
using System.Text;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for (Gitea #647).</summary>
|
|
/// <param name="RawPath">The tag's RawPath — the condition's ConditionId, the only key the host routes on.</param>
|
|
/// <param name="DeviceHostAddress">The device whose alarm list the tag selects from.</param>
|
|
/// <param name="Selector">Which alarms on that device drive the condition.</param>
|
|
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or <see langword="null"/> to derive one from the alarm type.</param>
|
|
internal sealed record FocasAlarmTag(
|
|
string RawPath, string DeviceHostAddress, FocasAlarmSelector Selector, int? AuthoredSeverity);
|
|
|
|
/// <summary>A CNC alarm's identity: (type, number). Axis is NOT part of it — see <see cref="FocasAlarmMatcher"/>.</summary>
|
|
/// <param name="Type">The FOCAS alarm type code.</param>
|
|
/// <param name="Number">The alarm number.</param>
|
|
internal readonly record struct FocasAlarmKey(short Type, int Number);
|
|
|
|
/// <summary>One distinct alarm a condition matched, with every axis it was reported on.</summary>
|
|
/// <param name="Key">The alarm's identity.</param>
|
|
/// <param name="Message">The CNC's message text.</param>
|
|
/// <param name="Axes">The distinct non-zero axes it was reported on, ascending.</param>
|
|
internal sealed record FocasMatchedAlarm(FocasAlarmKey Key, string Message, IReadOnlyList<short> Axes);
|
|
|
|
/// <summary>What one condition sees in one successful alarm-list read.</summary>
|
|
/// <param name="Matched">The matched alarms in display order — the first is the one the condition shows.</param>
|
|
/// <param name="ListTruncated">Whether the read may have been cut off at the per-read cap (summary selectors only).</param>
|
|
internal sealed record FocasConditionView(IReadOnlyList<FocasMatchedAlarm> Matched, bool ListTruncated)
|
|
{
|
|
/// <summary>Gets a value indicating whether at least one matching alarm is active.</summary>
|
|
public bool Active => Matched.Count > 0;
|
|
|
|
/// <summary>Gets the alarm the condition displays, or <see langword="null"/> when inactive.</summary>
|
|
public FocasMatchedAlarm? Winner => Matched.Count > 0 ? Matched[0] : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects, per authored <c>ALARM:</c> tag, the matching entries of a device's flat CNC alarm list (Gitea #647).
|
|
/// Pure — the projection hands it one successful read and gets back what each condition sees.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <list type="bullet">
|
|
/// <item><b>Identity is (type, number).</b> Cross-family number collisions exist, so the number alone is not
|
|
/// an identity. The axis is not part of it: one servo alarm number reported on two axes is ONE alarm,
|
|
/// listing both axes.</item>
|
|
/// <item><b>Overlap is intended.</b> An alarm drives every tag that selects it — <c>ALARM:Servo</c> and
|
|
/// <c>ALARM:*</c> both go active for one servo alarm, the way one MTConnect DataItem can back several tags.</item>
|
|
/// <item><b><c>OTHER</c> is what no TYPE selector on the device claims.</b> <c>*</c> summaries and other
|
|
/// <c>OTHER</c> tags do not claim anything, so a device summary beside it never starves it.</item>
|
|
/// <item><b>The displayed alarm is deterministic, never wire order</b>: the most severe (by the type
|
|
/// table), then ascending (type, number). A CNC that reorders an unchanged list therefore cannot re-raise
|
|
/// a condition.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
internal static class FocasAlarmMatcher
|
|
{
|
|
/// <summary>Evaluates one condition against one device's alarm list.</summary>
|
|
/// <param name="selector">The condition's selector.</param>
|
|
/// <param name="alarms">The device's active alarms, as read.</param>
|
|
/// <param name="deviceSelectors">Every authored alarm selector on the same device (for <c>OTHER</c>).</param>
|
|
/// <param name="listTruncated">Whether the read came back at the per-read cap.</param>
|
|
/// <returns>What the condition sees.</returns>
|
|
public static FocasConditionView Evaluate(
|
|
FocasAlarmSelector selector,
|
|
IReadOnlyList<FocasActiveAlarm> alarms,
|
|
IReadOnlyCollection<FocasAlarmSelector> deviceSelectors,
|
|
bool listTruncated)
|
|
{
|
|
var typeSelectors = selector.Kind == FocasAlarmSelectorKind.Other
|
|
? deviceSelectors.Where(s => s.Kind == FocasAlarmSelectorKind.Type).ToList()
|
|
: null;
|
|
|
|
var matched = alarms
|
|
.Where(a => typeSelectors is null
|
|
? selector.Matches(a.Type, a.AlarmNumber)
|
|
: !typeSelectors.Any(s => s.Matches(a.Type, a.AlarmNumber)))
|
|
.GroupBy(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
|
.Select(g =>
|
|
{
|
|
var records = g.OrderBy(a => a.Axis).ToList();
|
|
var message = records.Select(a => a.Message?.Trim()).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? "";
|
|
var axes = records.Select(a => a.Axis).Where(x => x > 0).Distinct().Order().ToList();
|
|
return new FocasMatchedAlarm(g.Key, message, axes);
|
|
})
|
|
.OrderByDescending(m => FocasAlarmConditionMapper.TypeSeverity(m.Key.Type))
|
|
.ThenBy(m => m.Key.Type)
|
|
.ThenBy(m => m.Key.Number)
|
|
.ToList();
|
|
|
|
var summary = selector.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other;
|
|
return new FocasConditionView(matched, listTruncated && summary);
|
|
}
|
|
|
|
/// <summary>Whether any authored selector on the device claims the alarm (<c>*</c>, <c>OTHER</c> or a type match).</summary>
|
|
/// <param name="alarm">The alarm.</param>
|
|
/// <param name="deviceSelectors">Every authored alarm selector on the device.</param>
|
|
/// <returns><see langword="true"/> when some condition on the device will show it.</returns>
|
|
public static bool IsCovered(FocasActiveAlarm alarm, IReadOnlyCollection<FocasAlarmSelector> deviceSelectors) =>
|
|
deviceSelectors.Any(s => s.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other
|
|
|| s.Matches(alarm.Type, alarm.AlarmNumber));
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the server was last TOLD about one FOCAS condition (Gitea #647) — the diff baseline that keeps a
|
|
/// reconnect, a re-subscribe or a reordered list from re-raising (and so un-acknowledging) an alarm.
|
|
/// </summary>
|
|
/// <param name="Active">Whether the condition was last told active.</param>
|
|
/// <param name="Keys">The matched set as of the last evaluation.</param>
|
|
/// <param name="Winner">The alarm the condition was last told it displays.</param>
|
|
/// <param name="Message">The message last sent.</param>
|
|
/// <param name="Severity">The bucket last sent.</param>
|
|
/// <param name="OpcUaSeverity">The exact severity last sent, if any.</param>
|
|
internal sealed record FocasToldCondition(
|
|
bool Active,
|
|
IReadOnlySet<FocasAlarmKey> Keys,
|
|
FocasAlarmKey? Winner,
|
|
string Message,
|
|
AlarmSeverity Severity,
|
|
int? OpcUaSeverity);
|
|
|
|
/// <summary>
|
|
/// The FOCAS alarm list → OPC UA Part 9 transition, as one pure function (Gitea #647): given what the server was
|
|
/// last told about a condition and what the CNC says now, the single <see cref="AlarmEventArgs"/> to raise — or
|
|
/// none — and what the server has been told afterwards.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <list type="table">
|
|
/// <listheader><term>Told → now</term><description>Event</description></listheader>
|
|
/// <item><term>inactive → active</term><description><see cref="AlarmTransitionKind.Raise"/></description></item>
|
|
/// <item><term>active → a key not in the told set appears</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
|
/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges</description></item>
|
|
/// <item><term>active → the displayed alarm left, others remain</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
|
/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen)</description></item>
|
|
/// <item><term>active → a NON-displayed member left</term><description>nothing (no message-only events)</description></item>
|
|
/// <item><term>active → nothing matches</term><description><see cref="AlarmTransitionKind.Clear"/></description></item>
|
|
/// <item><term>unchanged</term><description>nothing</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <b>Severity, explicitly.</b> An authored <c>alarm.severity</c> is sent as
|
|
/// <see cref="AlarmEventArgs.OpcUaSeverity"/> (the host clamps it to 1..1000), with the nearest bucket in
|
|
/// <see cref="AlarmEventArgs.Severity"/> (<c><400</c> Low, <c><600</c> Medium, <c><800</c> High, else
|
|
/// Critical). Without one, the bucket comes from the displayed alarm's type through <see cref="TypeSeverity"/>
|
|
/// — Overtravel / Servo / PulseCode Critical (host: 900), Parameter / MacroAlarm Medium (500), everything
|
|
/// else including unknown codes High (700) — and no exact severity is sent. The type table is the 16i-era
|
|
/// one (see <see cref="FocasAlarmType"/>), so on a 30i-series control author a severity.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class FocasAlarmConditionMapper
|
|
{
|
|
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="view"/>, and the new record.</summary>
|
|
/// <param name="told">What the server was last told, or <see langword="null"/> when nothing yet.</param>
|
|
/// <param name="view">What the condition sees now.</param>
|
|
/// <param name="tag">The condition's tag.</param>
|
|
/// <param name="handle">The alarm subscription the event belongs to.</param>
|
|
/// <param name="nowUtc">The transition timestamp.</param>
|
|
/// <returns>The event (or <see langword="null"/>) and the new told record.</returns>
|
|
public static (AlarmEventArgs? Event, FocasToldCondition Told) Transition(
|
|
FocasToldCondition? told,
|
|
FocasConditionView view,
|
|
FocasAlarmTag tag,
|
|
IAlarmSubscriptionHandle handle,
|
|
DateTime nowUtc)
|
|
{
|
|
var keys = view.Matched.Select(m => m.Key).ToHashSet();
|
|
|
|
if (!view.Active)
|
|
{
|
|
if (told is not { Active: true })
|
|
return (null, told ?? new FocasToldCondition(false, keys, null, "", AlarmSeverity.Low, null));
|
|
var cleared = told with { Active = false, Keys = keys, Winner = null };
|
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}",
|
|
told.Winner?.Type, told.Severity, told.OpcUaSeverity), cleared);
|
|
}
|
|
|
|
var winner = view.Winner!;
|
|
var (bucket, exact) = SeverityOf(tag, winner.Key.Type);
|
|
var message = MessageOf(view);
|
|
var raised = new FocasToldCondition(true, keys, winner.Key, message, bucket, exact);
|
|
|
|
if (told is not { Active: true })
|
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, winner.Key.Type, bucket, exact), raised);
|
|
|
|
var newKey = keys.Any(k => !told.Keys.Contains(k));
|
|
if (newKey || told.Winner != winner.Key)
|
|
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, winner.Key.Type, bucket, exact), raised);
|
|
|
|
// Same statement: keep what the server was shown, but track the membership so a key that leaves and
|
|
// later comes back is new again.
|
|
return (null, told with { Keys = keys });
|
|
}
|
|
|
|
/// <summary>The severity bucket of a FOCAS alarm type (the 16i-era table — see the class remarks).</summary>
|
|
/// <param name="type">The FOCAS alarm type code.</param>
|
|
/// <returns>The bucket.</returns>
|
|
internal static AlarmSeverity TypeSeverity(short type) => type switch
|
|
{
|
|
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
|
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
|
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
|
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
|
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
|
_ => AlarmSeverity.High,
|
|
};
|
|
|
|
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
|
|
/// <param name="severity">The exact severity.</param>
|
|
/// <returns>The bucket.</returns>
|
|
internal static AlarmSeverity BucketOf(int severity) => severity switch
|
|
{
|
|
< 400 => AlarmSeverity.Low,
|
|
< 600 => AlarmSeverity.Medium,
|
|
< 800 => AlarmSeverity.High,
|
|
_ => AlarmSeverity.Critical,
|
|
};
|
|
|
|
/// <summary>The human-readable category of a FOCAS type: its table name, else <c>Type{code}</c>.</summary>
|
|
/// <param name="type">The FOCAS alarm type code.</param>
|
|
/// <returns>The category.</returns>
|
|
internal static string TypeLabel(short type) => FocasAlarmType.NameOf(type) ?? $"Type{type}";
|
|
|
|
/// <summary>
|
|
/// The condition's message: the displayed alarm's number and text, its raw type code (so a 30i-series
|
|
/// operator can author a <c>T<code></c> selector from what they see) and axes, then how many other
|
|
/// alarms the condition also matches, and a truncation note on a summary condition.
|
|
/// </summary>
|
|
/// <param name="view">What the condition sees; must be active.</param>
|
|
/// <returns>e.g. <c>401 SERVO V-READY OFF (type 4 Servo, axis 1, 2) [+2 more]</c>.</returns>
|
|
internal static string MessageOf(FocasConditionView view)
|
|
{
|
|
var w = view.Winner!;
|
|
var b = new StringBuilder();
|
|
b.Append(w.Key.Number);
|
|
if (w.Message.Length > 0) b.Append(' ').Append(w.Message);
|
|
b.Append(" (type ").Append(w.Key.Type);
|
|
if (FocasAlarmType.NameOf(w.Key.Type) is { } name) b.Append(' ').Append(name);
|
|
if (w.Axes.Count > 0) b.Append(w.Axes.Count == 1 ? ", axis " : ", axes ").Append(string.Join(", ", w.Axes));
|
|
b.Append(')');
|
|
if (view.Matched.Count > 1) b.Append(" [+").Append(view.Matched.Count - 1).Append(" more]");
|
|
if (view.ListTruncated) b.Append(" [alarm list truncated]");
|
|
return b.ToString();
|
|
}
|
|
|
|
private static (AlarmSeverity Bucket, int? Exact) SeverityOf(FocasAlarmTag tag, short type) =>
|
|
tag.AuthoredSeverity is { } authored
|
|
? (BucketOf(authored), authored)
|
|
: (TypeSeverity(type), null);
|
|
|
|
private static AlarmEventArgs Build(
|
|
FocasAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, AlarmTransitionKind kind,
|
|
string message, short? type, AlarmSeverity bucket, int? exact) =>
|
|
new(handle,
|
|
SourceNodeId: tag.DeviceHostAddress,
|
|
ConditionId: tag.RawPath,
|
|
AlarmType: type is { } t ? TypeLabel(t) : "CncAlarm",
|
|
Message: message,
|
|
Severity: bucket,
|
|
SourceTimestampUtc: nowUtc,
|
|
Kind: kind,
|
|
OpcUaSeverity: exact);
|
|
}
|