using System.Text;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// An authored ALARM: raw tag the driver drives a Part 9 condition for (Gitea #647).
/// The tag's RawPath — the condition's ConditionId, the only key the host routes on.
/// The device whose alarm list the tag selects from.
/// Which alarms on that device drive the condition.
/// The authored alarm.severity, or to derive one from the alarm type.
internal sealed record FocasAlarmTag(
string RawPath, string DeviceHostAddress, FocasAlarmSelector Selector, int? AuthoredSeverity);
/// A CNC alarm's identity: (type, number). Axis is NOT part of it — see .
/// The FOCAS alarm type code.
/// The alarm number.
internal readonly record struct FocasAlarmKey(short Type, int Number);
/// One distinct alarm a condition matched, with every axis it was reported on.
/// The alarm's identity.
/// The CNC's message text.
/// The distinct non-zero axes it was reported on, ascending.
internal sealed record FocasMatchedAlarm(FocasAlarmKey Key, string Message, IReadOnlyList Axes);
/// What one condition sees in one successful alarm-list read.
/// The matched alarms in display order — the first is the one the condition shows.
/// Whether the read may have been cut off at the per-read cap (summary selectors only).
internal sealed record FocasConditionView(IReadOnlyList Matched, bool ListTruncated)
{
/// Gets a value indicating whether at least one matching alarm is active.
public bool Active => Matched.Count > 0;
/// Gets the alarm the condition displays, or when inactive.
public FocasMatchedAlarm? Winner => Matched.Count > 0 ? Matched[0] : null;
}
///
/// Selects, per authored ALARM: 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.
///
///
///
/// - Identity is (type, number). 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.
/// - Overlap is intended. An alarm drives every tag that selects it — ALARM:Servo and
/// ALARM:* both go active for one servo alarm, the way one MTConnect DataItem can back several tags.
/// - OTHER is what no TYPE selector on the device claims. * summaries and other
/// OTHER tags do not claim anything, so a device summary beside it never starves it.
/// - The displayed alarm is deterministic, never wire order: the most severe (by the type
/// table), then ascending (type, number). A CNC that reorders an unchanged list therefore cannot re-raise
/// a condition.
///
///
internal static class FocasAlarmMatcher
{
/// Evaluates one condition against one device's alarm list.
/// The condition's selector.
/// The device's active alarms, as read.
/// Every authored alarm selector on the same device (for OTHER).
/// Whether the read came back at the per-read cap.
/// What the condition sees.
public static FocasConditionView Evaluate(
FocasAlarmSelector selector,
IReadOnlyList alarms,
IReadOnlyCollection 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);
}
/// Whether any authored selector on the device claims the alarm (*, OTHER or a type match).
/// The alarm.
/// Every authored alarm selector on the device.
/// when some condition on the device will show it.
public static bool IsCovered(FocasActiveAlarm alarm, IReadOnlyCollection deviceSelectors) =>
deviceSelectors.Any(s => s.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other
|| s.Matches(alarm.Type, alarm.AlarmNumber));
}
///
/// 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.
///
/// Whether the condition was last told active.
/// The matched set as of the last evaluation.
/// The alarm the condition was last told it displays.
/// The message last sent.
/// The bucket last sent.
/// The exact severity last sent, if any.
internal sealed record FocasToldCondition(
bool Active,
IReadOnlySet Keys,
FocasAlarmKey? Winner,
string Message,
AlarmSeverity Severity,
int? OpcUaSeverity);
///
/// 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 to raise — or
/// none — and what the server has been told afterwards.
///
///
///
/// Told → nowEvent
/// - inactive → active
/// - active → a key not in the told set appears
/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges
/// - active → the displayed alarm left, others remain
/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen)
/// - active → a NON-displayed member leftnothing (no message-only events)
/// - active → nothing matches
/// - unchangednothing
///
///
/// Severity, explicitly. An authored alarm.severity is sent as
/// (the host clamps it to 1..1000), with the nearest bucket in
/// (<400 Low, <600 Medium, <800 High, else
/// Critical). Without one, the bucket comes from the displayed alarm's type through
/// — 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 ), so on a 30i-series control author a severity.
///
///
internal static class FocasAlarmConditionMapper
{
/// The event moving the server from to , and the new record.
/// What the server was last told, or when nothing yet.
/// What the condition sees now.
/// The condition's tag.
/// The alarm subscription the event belongs to.
/// The transition timestamp.
/// The event (or ) and the new told record.
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 });
}
/// The severity bucket of a FOCAS alarm type (the 16i-era table — see the class remarks).
/// The FOCAS alarm type code.
/// The bucket.
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,
};
/// The nearest bucket of an exact 1..1000 severity.
/// The exact severity.
/// The bucket.
internal static AlarmSeverity BucketOf(int severity) => severity switch
{
< 400 => AlarmSeverity.Low,
< 600 => AlarmSeverity.Medium,
< 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// The human-readable category of a FOCAS type: its table name, else Type{code}.
/// The FOCAS alarm type code.
/// The category.
internal static string TypeLabel(short type) => FocasAlarmType.NameOf(type) ?? $"Type{type}";
///
/// The condition's message: the displayed alarm's number and text, its raw type code (so a 30i-series
/// operator can author a T<code> selector from what they see) and axes, then how many other
/// alarms the condition also matches, and a truncation note on a summary condition.
///
/// What the condition sees; must be active.
/// e.g. 401 SERVO V-READY OFF (type 4 Servo, axis 1, 2) [+2 more].
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);
}