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); }