feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
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
This commit is contained in:
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>), plus
|
||||
/// <see cref="FocasAreaKind.Alarm"/> (a selector over the CNC's active alarm list — <c>ALARM:*</c>,
|
||||
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>, Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
@@ -35,11 +37,15 @@ public sealed record FocasAddress(
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex,
|
||||
string? FixedPath = null)
|
||||
string? FixedPath = null,
|
||||
FocasAlarmSelector? AlarmSelector = null)
|
||||
{
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address (Gitea #647).</summary>
|
||||
public const string AlarmPrefix = "ALARM:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
@@ -51,6 +57,7 @@ public sealed record FocasAddress(
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||
FocasAreaKind.Alarm => AlarmPrefix + AlarmSelector?.Canonical,
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
@@ -62,6 +69,26 @@ public sealed record FocasAddress(
|
||||
/// <returns>The canonical authored address string.</returns>
|
||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||
|
||||
/// <summary>Builds an <see cref="FocasAreaKind.Alarm"/> address over <paramref name="selector"/>.</summary>
|
||||
/// <param name="selector">The alarm selector.</param>
|
||||
/// <returns>The address.</returns>
|
||||
public static FocasAddress ForAlarm(FocasAlarmSelector selector) =>
|
||||
new(FocasAreaKind.Alarm, null, 0, null, AlarmSelector: selector);
|
||||
|
||||
/// <summary>
|
||||
/// Why <paramref name="value"/> is not a usable <c>ALARM:</c> address, or <see langword="null"/> when it
|
||||
/// is one (or is not an <c>ALARM:</c> address at all). Shared by the AdminUI editor and the deploy-time
|
||||
/// inspector so both say the same thing about the same string.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored address.</param>
|
||||
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
|
||||
public static string? DescribeAlarmAddressError(string? value)
|
||||
{
|
||||
var src = value?.Trim() ?? "";
|
||||
if (!src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..], out _, out var error) ? null : error;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
@@ -86,6 +113,14 @@ public sealed record FocasAddress(
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// Alarm selector (Gitea #647). Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||
// valid PMC letter (message display), so ALARM:… must be owned by this rule, not diagnosed as PMC.
|
||||
if (src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var selector = FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..]);
|
||||
return selector is null ? null : ForAlarm(selector);
|
||||
}
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
@@ -175,4 +210,12 @@ public enum FocasAreaKind
|
||||
/// nothing in that tree is a writable location on the CNC.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the device's active CNC alarm list (<c>ALARM:*</c>, <c>ALARM:Servo/401-453</c>,
|
||||
/// <c>ALARM:OTHER</c>) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver
|
||||
/// <c>alarm</c> object, which makes it ONE Part 9 condition at its RawPath; it has no value to read
|
||||
/// and nothing to write.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects (Gitea #647).</summary>
|
||||
public enum FocasAlarmSelectorKind
|
||||
{
|
||||
/// <summary><c>ALARM:*</c> — every active alarm on the tag's device (a device summary condition).</summary>
|
||||
Any,
|
||||
|
||||
/// <summary>
|
||||
/// <c>ALARM:OTHER</c> — active alarms on the device that no <see cref="Type"/> selector on the same
|
||||
/// device matches: the "unmatched" bucket. <see cref="Any"/> and other <see cref="Other"/> tags do not
|
||||
/// claim an alarm, so an <c>ALARM:*</c> summary beside it does not starve it.
|
||||
/// </summary>
|
||||
Other,
|
||||
|
||||
/// <summary><c>ALARM:Servo</c>, <c>ALARM:Servo/401</c>, <c>ALARM:Servo/401-453</c>, <c>ALARM:T4</c>.</summary>
|
||||
Type,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selector half of an <c>ALARM:</c> FOCAS address (Gitea #647): which entries of the CNC's flat,
|
||||
/// per-device active-alarm list (<c>cnc_rdalmmsg2</c>) drive the ONE Part 9 condition the raw tag is
|
||||
/// materialised as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Grammar of the text after <c>ALARM:</c> (case-insensitive, no embedded whitespace):</para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Form</term><description>Matches</description></listheader>
|
||||
/// <item><term><c>*</c></term><description>any active alarm on the device</description></item>
|
||||
/// <item><term><c>OTHER</c></term><description>alarms no type selector on the device matches</description></item>
|
||||
/// <item><term><c><TYPE></c></term><description>any alarm of that type</description></item>
|
||||
/// <item><term><c><TYPE>/<n></c></term><description>that type and alarm number</description></item>
|
||||
/// <item><term><c><TYPE>/<lo>-<hi></c></term><description>that type, number in the inclusive range</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <c><TYPE></c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
|
||||
/// or a raw type code <c>T<code></c> (<c>T4</c>). The names resolve through the driver's single
|
||||
/// 16i-era type table — see the ⚠️ on <see cref="FocasAlarmType"/>; the raw-code form is the
|
||||
/// series-independent one and the right choice on a 30i-series control.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One authority.</b> This parser backs the AdminUI editor (through <see cref="FocasAddress.TryParse"/>),
|
||||
/// the deploy-time inspector and the driver's init — an address one of them accepts, all of them accept.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Kind">The selector kind.</param>
|
||||
/// <param name="TypeCode">The FOCAS alarm type code; set only for <see cref="FocasAlarmSelectorKind.Type"/>.</param>
|
||||
/// <param name="TypeName">The canonical type name when the selector named one; <see langword="null"/> for a raw <c>T<code></c>.</param>
|
||||
/// <param name="NumberLow">The lowest alarm number matched, or <see langword="null"/> for every number.</param>
|
||||
/// <param name="NumberHigh">The highest alarm number matched (inclusive); equals <paramref name="NumberLow"/> for a single number.</param>
|
||||
public sealed record FocasAlarmSelector(
|
||||
FocasAlarmSelectorKind Kind,
|
||||
short? TypeCode = null,
|
||||
string? TypeName = null,
|
||||
int? NumberLow = null,
|
||||
int? NumberHigh = null)
|
||||
{
|
||||
/// <summary>The <c>*</c> body.</summary>
|
||||
public const string AnyToken = "*";
|
||||
|
||||
/// <summary>The <c>OTHER</c> body.</summary>
|
||||
public const string OtherToken = "OTHER";
|
||||
|
||||
/// <summary>Gets the canonical selector text (the part after <c>ALARM:</c>).</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => AnyToken,
|
||||
FocasAlarmSelectorKind.Other => OtherToken,
|
||||
_ => (TypeName ?? $"T{TypeCode}") + (NumberLow is not { } lo
|
||||
? ""
|
||||
: NumberHigh is { } hi && hi != lo ? $"/{lo}-{hi}" : $"/{lo}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this selector matches one CNC alarm on its own terms. <see cref="FocasAlarmSelectorKind.Other"/>
|
||||
/// always answers <see langword="false"/> here: whether an alarm is "unmatched" depends on the other
|
||||
/// selectors on the device, which the caller evaluates.
|
||||
/// </summary>
|
||||
/// <param name="type">The alarm's type code.</param>
|
||||
/// <param name="number">The alarm number.</param>
|
||||
/// <returns><see langword="true"/> when the alarm is selected.</returns>
|
||||
public bool Matches(short type, int number) => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => true,
|
||||
FocasAlarmSelectorKind.Type => type == TypeCode
|
||||
&& (NumberLow is not { } lo || (number >= lo && number <= (NumberHigh ?? lo))),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, or returns <see langword="null"/>.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <returns>The selector, or <see langword="null"/> when the text is not a valid selector.</returns>
|
||||
public static FocasAlarmSelector? TryParse(string? body) => TryParse(body, out var selector, out _) ? selector : null;
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, explaining a refusal.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <param name="selector">The selector when this returns <see langword="true"/>.</param>
|
||||
/// <param name="error">Why the text was refused, when this returns <see langword="false"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="body"/> is a valid selector.</returns>
|
||||
public static bool TryParse(string? body, out FocasAlarmSelector? selector, out string? error)
|
||||
{
|
||||
selector = null;
|
||||
error = null;
|
||||
var src = (body ?? "").Trim();
|
||||
if (src.Length == 0)
|
||||
{
|
||||
error = "an ALARM: address needs a selector (*, OTHER, a type, type/number or type/lo-hi)";
|
||||
return false;
|
||||
}
|
||||
if (src.Any(char.IsWhiteSpace))
|
||||
{
|
||||
error = $"ALARM selector '{src}' contains whitespace";
|
||||
return false;
|
||||
}
|
||||
if (src == AnyToken)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Any);
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(src, OtherToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Other);
|
||||
return true;
|
||||
}
|
||||
|
||||
var slash = src.IndexOf('/');
|
||||
var typeText = slash < 0 ? src : src[..slash];
|
||||
if (!TryParseType(typeText, out var code, out var name))
|
||||
{
|
||||
error = $"'{typeText}' is not a FOCAS alarm type — expected one of {string.Join(", ", FocasAlarmType.Names)}, " +
|
||||
"or a raw type code such as T4";
|
||||
return false;
|
||||
}
|
||||
if (slash < 0)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
var numbers = src[(slash + 1)..];
|
||||
var dash = numbers.IndexOf('-');
|
||||
if (dash < 0)
|
||||
{
|
||||
if (!TryParseNumber(numbers, out var n))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number (a non-negative integer)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, n, n);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryParseNumber(numbers[..dash], out var lo) || !TryParseNumber(numbers[(dash + 1)..], out var hi))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number range (lo-hi, non-negative integers)";
|
||||
return false;
|
||||
}
|
||||
if (lo > hi)
|
||||
{
|
||||
error = $"alarm number range '{numbers}' is reversed (lo must not exceed hi)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, lo, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseType(string text, out short code, out string? name)
|
||||
{
|
||||
name = null;
|
||||
if (FocasAlarmType.TryResolve(text, out code))
|
||||
{
|
||||
name = FocasAlarmType.NameOf(code);
|
||||
return true;
|
||||
}
|
||||
// Raw code: T<digits>. Non-negative and within the wire field's short width.
|
||||
if (text.Length >= 2 && (text[0] == 'T' || text[0] == 't')
|
||||
&& text.Skip(1).All(char.IsAsciiDigit)
|
||||
&& short.TryParse(text.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out code))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
code = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string text, out int number) =>
|
||||
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out number);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||
/// <c>cnc_rdalmmsg2</c> (and so the driver's <c>switch (short)</c> statements compile against a
|
||||
/// matching type rather than relying on implicit int→short narrowing on the constants).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>
|
||||
/// (Gitea #647). The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||
/// been confirmed against a FOCAS reference, so it is deliberately NOT encoded here — a guessed code
|
||||
/// would silently route the wrong alarms. On a 30i-series control the names below therefore label
|
||||
/// the raw type code with its 16i meaning, which may be wrong. Every alarm message the driver
|
||||
/// publishes carries the raw code (<c>type 4</c>), and an <c>ALARM:</c> address can name a raw code
|
||||
/// (<c>ALARM:T4</c>) instead of a name, which is the series-independent form.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Moved into the Contracts assembly (from the driver's <c>IFocasClient.cs</c>, same namespace) so the
|
||||
/// <see cref="FocasAlarmSelector"/> grammar — shared by the AdminUI editor and the deploy gate — can
|
||||
/// resolve type names without referencing the runtime driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to the wire's <c>cnc_rdalmmsg2</c> to mean "any type".</summary>
|
||||
public const short All = -1;
|
||||
|
||||
/// <summary><c>ALM_P</c> — parameter / program (P/S) alarms.</summary>
|
||||
public const short Parameter = 0;
|
||||
|
||||
/// <summary><c>ALM_Y</c> — pulse coder (servo encoder) alarms.</summary>
|
||||
public const short PulseCode = 1;
|
||||
|
||||
/// <summary><c>ALM_O</c> — overtravel alarms.</summary>
|
||||
public const short Overtravel = 2;
|
||||
|
||||
/// <summary><c>ALM_H</c> — overheat alarms.</summary>
|
||||
public const short Overheat = 3;
|
||||
|
||||
/// <summary><c>ALM_S</c> — servo alarms.</summary>
|
||||
public const short Servo = 4;
|
||||
|
||||
/// <summary><c>ALM_T</c> — data I/O alarms.</summary>
|
||||
public const short DataIo = 5;
|
||||
|
||||
/// <summary><c>ALM_M</c> — memory check alarms.</summary>
|
||||
public const short MemoryCheck = 6;
|
||||
|
||||
/// <summary><c>ALM_MC</c> — macro alarms (<c>#3000</c>).</summary>
|
||||
public const short MacroAlarm = 13;
|
||||
|
||||
/// <summary>
|
||||
/// The named types an <c>ALARM:</c> address may use, keyed case-insensitively by the constant's
|
||||
/// name. The canonical spelling is the constant name (<c>Servo</c>, <c>MacroAlarm</c>).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, short> ByName =
|
||||
new Dictionary<string, short>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[nameof(Parameter)] = Parameter,
|
||||
[nameof(PulseCode)] = PulseCode,
|
||||
[nameof(Overtravel)] = Overtravel,
|
||||
[nameof(Overheat)] = Overheat,
|
||||
[nameof(Servo)] = Servo,
|
||||
[nameof(DataIo)] = DataIo,
|
||||
[nameof(MemoryCheck)] = MemoryCheck,
|
||||
[nameof(MacroAlarm)] = MacroAlarm,
|
||||
};
|
||||
|
||||
/// <summary>Gets the canonical type names an <c>ALARM:</c> address accepts, in code order.</summary>
|
||||
public static IReadOnlyList<string> Names { get; } =
|
||||
[nameof(Parameter), nameof(PulseCode), nameof(Overtravel), nameof(Overheat),
|
||||
nameof(Servo), nameof(DataIo), nameof(MemoryCheck), nameof(MacroAlarm)];
|
||||
|
||||
/// <summary>Resolves a type name (case-insensitive) to its code.</summary>
|
||||
/// <param name="name">The type name, e.g. <c>servo</c>.</param>
|
||||
/// <param name="code">The resolved code.</param>
|
||||
/// <returns><see langword="true"/> when the name is in the table.</returns>
|
||||
public static bool TryResolve(string name, out short code) => ByName.TryGetValue(name, out code);
|
||||
|
||||
/// <summary>The canonical name of a code, or <see langword="null"/> when the code is not in the table.</summary>
|
||||
/// <param name="code">The FOCAS alarm type code.</param>
|
||||
/// <returns>The name, or <see langword="null"/>.</returns>
|
||||
public static string? NameOf(short code) => code switch
|
||||
{
|
||||
Parameter => nameof(Parameter),
|
||||
PulseCode => nameof(PulseCode),
|
||||
Overtravel => nameof(Overtravel),
|
||||
Overheat => nameof(Overheat),
|
||||
Servo => nameof(Servo),
|
||||
DataIo => nameof(DataIo),
|
||||
MemoryCheck => nameof(MemoryCheck),
|
||||
MacroAlarm => nameof(MacroAlarm),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -128,12 +128,19 @@ public sealed class FocasHandleRecycleOptions
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
||||
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags (Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Authoring an <c>ALARM:</c> tag IS the opt-in.</b> The projection polls exactly the devices that own a
|
||||
/// subscribed alarm tag, so a driver with none issues no <c>cnc_rdalmmsg2</c> traffic whatever
|
||||
/// <see cref="Enabled"/> says. Before #647, <see cref="Enabled"/> gated the poll and
|
||||
/// <c>SubscribeAlarmsAsync</c> THREW while it was false — so an operator who authored an alarm tag and left
|
||||
/// the default got a feed that stayed gated with a Warning on every reconnect. It is retained so existing
|
||||
/// configs still bind, and no longer has any effect.
|
||||
/// </remarks>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
||||
/// <summary>Retained for config compatibility; has no effect since Gitea #647 (see the class remarks).</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
@@ -196,13 +203,29 @@ public sealed record FocasDeviceOptions(
|
||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <param name="Alarm">
|
||||
/// The tag's cross-driver <c>alarm</c> object, when it carries one (Gitea #647) — which makes the host
|
||||
/// materialise it as a Part 9 condition at its RawPath. Only an <c>ALARM:</c> address can drive one.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
FocasTagAlarm? Alarm = null);
|
||||
|
||||
/// <summary>
|
||||
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object (Gitea #647). The host owns the
|
||||
/// rest of it (<c>alarmType</c>, <c>historizeToAveva</c>) through <c>TagConfigIntent</c>.
|
||||
/// </summary>
|
||||
/// <param name="Severity">
|
||||
/// The authored <c>alarm.severity</c> (an integer), or <see langword="null"/> when the object has none. An
|
||||
/// authored severity is sent as the condition's exact Part 9 severity; without one the severity comes from
|
||||
/// the displayed CNC alarm's type (<c>FocasAlarmConditionMapper</c>).
|
||||
/// </param>
|
||||
public sealed record FocasTagAlarm(int? Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Controls periodic connectivity probing. One <c>cnc_rdcncstat</c> call per
|
||||
|
||||
@@ -64,7 +64,8 @@ public static class FocasTagDefinitionFactory
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
Writable: writable,
|
||||
WriteIdempotent: false);
|
||||
WriteIdempotent: false,
|
||||
Alarm: ReadAlarm(root));
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
@@ -72,6 +73,20 @@ public static class FocasTagDefinitionFactory
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag's cross-driver <c>alarm</c> object (Gitea #647), or <see langword="null"/> when it carries none.
|
||||
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <returns>The alarm intent, or <see langword="null"/>.</returns>
|
||||
private static FocasTagAlarm? ReadAlarm(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
int? severity = a.TryGetProperty("severity", out var s) && s.ValueKind == JsonValueKind.Number
|
||||
&& s.TryGetInt32(out var v) ? v : null;
|
||||
return new FocasTagAlarm(severity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
@@ -235,16 +250,37 @@ public static class FocasTagDefinitionFactory
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
||||
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
||||
if (root.TryGetProperty("address", out var addr)
|
||||
&& addr.ValueKind == JsonValueKind.String
|
||||
&& addr.GetString() is { } address
|
||||
&& !string.IsNullOrWhiteSpace(address)
|
||||
&& FocasAddress.TryParse(address) is null)
|
||||
var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String
|
||||
? addr.GetString()
|
||||
: null;
|
||||
var parsed = string.IsNullOrWhiteSpace(address) ? null : FocasAddress.TryParse(address);
|
||||
if (!string.IsNullOrWhiteSpace(address) && parsed is null)
|
||||
{
|
||||
// An ALARM: address gets the selector's own reason — the same text the AdminUI editor shows.
|
||||
advisories.Add(FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"FOCAS alarm address '{address.Trim()}' is not valid: {alarmError} — the driver will skip " +
|
||||
"this tag at startup and its condition will never activate."
|
||||
: $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500, FIXED:Identity/SeriesNumber or ALARM:Servo/401) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||
// makes the host materialise a Part 9 condition; the address is the only thing the driver can drive one
|
||||
// from. Either one alone is a tag that does nothing.
|
||||
var hasAlarmObject = root.TryGetProperty("alarm", out var alarm) && alarm.ValueKind == JsonValueKind.Object;
|
||||
if (parsed is { Kind: FocasAreaKind.Alarm } && !hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
$"FOCAS address '{address!.Trim()}' is an alarm selector but the tag has no 'alarm' object — it is " +
|
||||
"not materialised as a condition and has no value to read; the driver skips it.");
|
||||
}
|
||||
else if (parsed is { Kind: not FocasAreaKind.Alarm } && hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS tag carries an 'alarm' object but its address '{address!.Trim()}' is not an ALARM: selector — " +
|
||||
"the condition is materialised but nothing will ever raise it; the driver skips the tag. " +
|
||||
"Use ALARM:*, ALARM:<type>[/<n>[-<m>]] or ALARM:OTHER.");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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);
|
||||
}
|
||||
@@ -5,196 +5,180 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
|
||||
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
|
||||
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
|
||||
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
|
||||
/// <c>Type</c>) keyed active-alarm set between ticks.
|
||||
/// Polls the CNC active-alarm list (<c>cnc_rdalmmsg2</c>) of every device that owns a subscribed
|
||||
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag (Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
|
||||
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
|
||||
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
|
||||
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
|
||||
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
|
||||
/// projection will skip devices not listed.
|
||||
/// <para>
|
||||
/// <b>Which tags are conditions.</b> The server decides: a raw tag whose <c>TagConfig</c> carries the
|
||||
/// cross-driver <c>alarm</c> object is materialised as a condition at its RawPath, and those RawPaths
|
||||
/// arrive through <see cref="SubscribeAsync"/>. Every event uses the RawPath as
|
||||
/// <see cref="AlarmEventArgs.ConditionId"/> — the exact key the host routes on — and carries a
|
||||
/// <see cref="AlarmEventArgs.Kind"/>, without which the host's projector keeps the prior state. Before
|
||||
/// #647 this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
|
||||
/// <c>"{host}#{Type}:{Number}"</c> with no kind (so nothing would have routed anyway).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failed read is not an empty list.</b> <see cref="IFocasClient.ReadAlarmsAsync"/> throws on any
|
||||
/// failure; the device is then skipped for that tick and every condition on it keeps its state. The
|
||||
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss (#477).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect #626
|
||||
/// pattern), recorded only while an <c>OnAlarmEvent</c> handler is attached. The record lives on this
|
||||
/// object, which lives as long as the driver: it survives a re-subscribe with the same set (a reconnect),
|
||||
/// a re-initialise and a shutdown/re-init cycle — the server's condition nodes outlive all three, and
|
||||
/// re-raising there would un-acknowledge an acknowledged alarm. A changed subscribed set prunes only the
|
||||
/// RawPaths that LEFT it (the server carries every survivor across the rebuild, #641).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One poll loop</b>, owned by the current subscription. Evaluation and delivery run under
|
||||
/// <see cref="_alarmLock"/> and re-check that the subscription is still current, so a superseded loop
|
||||
/// that is still finishing its last read can never emit — the duplicate-per-reconnect window the old
|
||||
/// design documented is closed. The only production handler is <c>DriverInstanceActor</c>'s
|
||||
/// non-blocking <c>Tell</c>.
|
||||
/// </para>
|
||||
/// <para><b>Read-only.</b> FOCAS has no alarm acknowledgement; the Part 9 Acknowledge is applied server-side.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
|
||||
/// <summary>Serializes evaluation + delivery + the told record. Never held across an await.</summary>
|
||||
private readonly Lock _alarmLock = new();
|
||||
|
||||
/// <summary>ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly Dictionary<string, FocasToldCondition> _told = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Per device: the alarm keys already logged as matched by no tag. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly Dictionary<string, HashSet<FocasAlarmKey>> _unmatchedLogged = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Devices whose last read came back at the per-read cap. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly HashSet<string> _truncationWarned = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Serializes loop start/stop.</summary>
|
||||
private readonly SemaphoreSlim _loopGate = new(1, 1);
|
||||
|
||||
private Subscription? _current;
|
||||
private CancellationTokenSource? _loopCts;
|
||||
private Task _loop = Task.CompletedTask;
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Initializes a new FOCAS alarm projection.</summary>
|
||||
/// <param name="driver">FOCAS driver instance.</param>
|
||||
/// <param name="pollInterval">Polling interval.</param>
|
||||
/// <param name="driver">The owning driver.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
||||
public FocasAlarmProjection(FocasDriver driver, ILogger? logger = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to alarms from the specified device sources.</summary>
|
||||
/// <param name="sourceNodeIds">Source node IDs to listen to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task returning the alarm subscription handle.</returns>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Empty filter = listen to every configured device. Otherwise only devices whose
|
||||
// host address appears in sourceNodeIds are polled.
|
||||
var filter = sourceNodeIds.Count == 0
|
||||
? null
|
||||
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
|
||||
var sub = new Subscription(handle, filter, cts);
|
||||
|
||||
// Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
|
||||
// alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
|
||||
// Connected→Reconnecting WITHOUT calling Unsubscribe), and this projection is reused across
|
||||
// every in-place reconnect, so each SubscribeAsync would otherwise leak a live, never-
|
||||
// cancelled poll loop. There is exactly one consumer per driver instance, so retiring all
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
{
|
||||
stale = [.. _subs.Values];
|
||||
_subs.Clear();
|
||||
_subs[id] = sub;
|
||||
}
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
|
||||
// Retire superseded loops out-of-band so the new subscription's return isn't blocked on a
|
||||
// full poll interval (awaiting a loop means waiting for it to exit its Task.Delay). The
|
||||
// loops already catch internally; RetireAsync still wraps every await defensively.
|
||||
foreach (var old in stale) _ = RetireAsync(old);
|
||||
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
/// <summary>Gets the live subscription, or <see langword="null"/>. Test seam.</summary>
|
||||
internal Subscription? Current => Volatile.Read(ref _current);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
|
||||
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
|
||||
/// unobserved exception can never escape (the loops already swallow their own).
|
||||
/// <para>
|
||||
/// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight
|
||||
/// read + tick complete and it observes cancellation at the next <c>Task.Delay</c>. If a device
|
||||
/// transition lands in that one-tick window, both the old and new loops can fire it once —
|
||||
/// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old
|
||||
/// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on
|
||||
/// <c>ConditionId</c>, so this is absorbed. Awaiting the retire before starting the new loop
|
||||
/// would close the window at the cost of blocking the subscribe for one read latency.
|
||||
/// </para>
|
||||
/// Replaces the live subscription with one over <paramref name="sourceNodeIds"/> (alarm-tag RawPaths) and
|
||||
/// (re)starts the poll loop. Collapses to ONE subscription: the owning <c>DriverInstanceActor</c> is the
|
||||
/// only caller and re-subscribes on every Connected entry.
|
||||
/// </summary>
|
||||
/// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
|
||||
private async Task RetireAsync(Subscription sub)
|
||||
/// <param name="sourceNodeIds">The alarm-bearing raw tags' RawPaths.</param>
|
||||
/// <returns>The subscription handle.</returns>
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); }
|
||||
try { sub.Cts.Dispose(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); }
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
var refs = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in sourceNodeIds)
|
||||
if (!string.IsNullOrWhiteSpace(r)) refs.Add(r);
|
||||
|
||||
var sub = new Subscription(new FocasAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// Forget only the conditions that LEFT the set (#641) — the SAME set (a reconnect) prunes nothing.
|
||||
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone); Volatile.Write(ref _current, sub);
|
||||
}
|
||||
await RestartLoopAsync().ConfigureAwait(false);
|
||||
return sub.Handle;
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from an alarm subscription.</summary>
|
||||
/// <param name="handle">Alarm subscription handle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <summary>Drops the live subscription when <paramref name="handle"/> is it; a superseded handle is a no-op.</summary>
|
||||
/// <param name="handle">The handle returned by <see cref="SubscribeAsync"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
lock (_alarmLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
if (Volatile.Read(ref _current) is not { } live || !ReferenceEquals(live.Handle, handle)) return;
|
||||
Volatile.Write(ref _current, null);
|
||||
}
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); }
|
||||
sub.Cts.Dispose();
|
||||
await StopLoopAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
|
||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
||||
/// than surfacing a confusing "not supported" error to the operator.
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying condition resolves.
|
||||
/// The Part 9 Acknowledge is applied server-side; this is the no-op success the host routes it to, so
|
||||
/// nothing is ever written to the CNC.
|
||||
/// </summary>
|
||||
/// <param name="acknowledgements">Alarms to acknowledge.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <summary>Disposes the alarm projection.</summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
public static Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements)
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
|
||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
||||
/// the full Task.Run loop.
|
||||
/// Stops the poll loop but KEEPS the subscription and the told record — the device/tag tables are about
|
||||
/// to be rebuilt (re-initialise, shutdown). <see cref="ResumeAsync"/> restarts it.
|
||||
/// </summary>
|
||||
/// <param name="sub">Active subscription.</param>
|
||||
/// <param name="deviceHostAddress">Device host address.</param>
|
||||
/// <param name="current">Current alarms from the device.</param>
|
||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task PauseAsync() => StopLoopAsync();
|
||||
|
||||
/// <summary>Restarts the poll loop when a subscription is live — after the driver's tables are rebuilt.</summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false);
|
||||
|
||||
private async Task RestartLoopAsync()
|
||||
{
|
||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
||||
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
|
||||
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
|
||||
|
||||
foreach (var a in current)
|
||||
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (prevKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: a.Message,
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
await StopLoopCoreAsync().ConfigureAwait(false);
|
||||
if (Volatile.Read(ref _current) is not { } sub) return;
|
||||
var cts = new CancellationTokenSource();
|
||||
_loopCts = cts;
|
||||
_loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), CancellationToken.None);
|
||||
}
|
||||
finally { _loopGate.Release(); }
|
||||
}
|
||||
|
||||
foreach (var a in prev)
|
||||
private async Task StopLoopAsync()
|
||||
{
|
||||
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||
try { await StopLoopCoreAsync().ConfigureAwait(false); }
|
||||
finally { _loopGate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Cancels and joins the loop (bounded). Caller holds <see cref="_loopGate"/>.</summary>
|
||||
private async Task StopLoopCoreAsync()
|
||||
{
|
||||
if (_loopCts is not { } cts) return;
|
||||
_loopCts = null;
|
||||
try { cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling the FOCAS alarm poll loop failed"); }
|
||||
var loop = _loop;
|
||||
try
|
||||
{
|
||||
if (nowKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: $"{a.Message} (cleared)",
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
var finished = await Task.WhenAny(loop, Task.Delay(FocasDriver.LoopJoinTimeout)).ConfigureAwait(false);
|
||||
if (finished != loop)
|
||||
_logger.LogWarning("FOCAS alarm poll loop did not stop within {Timeout}; it cannot emit once superseded",
|
||||
FocasDriver.LoopJoinTimeout);
|
||||
}
|
||||
|
||||
sub.LastByDevice[deviceHostAddress] = [.. current];
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting the FOCAS alarm poll loop failed"); }
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
@@ -203,78 +187,147 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
|
||||
{
|
||||
Tick(sub, host, alarms);
|
||||
}
|
||||
await PollOnceAsync(sub, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* per-tick failures are non-fatal — next tick retries */
|
||||
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
try { await Task.Delay(_driver.AlarmPollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
||||
|
||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm type string.</returns>
|
||||
internal static string MapAlarmType(short type) => type switch
|
||||
/// <summary>One pass: read each device that owns a subscribed alarm tag, and evaluate its conditions.</summary>
|
||||
/// <param name="sub">The subscription the pass serves.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
internal async Task PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
FocasAlarmType.Parameter => "Parameter",
|
||||
FocasAlarmType.PulseCode => "PulseCode",
|
||||
FocasAlarmType.Overtravel => "Overtravel",
|
||||
FocasAlarmType.Overheat => "Overheat",
|
||||
FocasAlarmType.Servo => "Servo",
|
||||
FocasAlarmType.DataIo => "DataIo",
|
||||
FocasAlarmType.MemoryCheck => "MemoryCheck",
|
||||
FocasAlarmType.MacroAlarm => "MacroAlarm",
|
||||
_ => $"Type{type}",
|
||||
};
|
||||
var tags = _driver.AlarmTags;
|
||||
var devices = sub.References
|
||||
.Select(r => tags.GetValueOrDefault(r))
|
||||
.OfType<FocasAlarmTag>()
|
||||
.Select(t => t.DeviceHostAddress)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var host in devices)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
IReadOnlyList<FocasActiveAlarm> alarms;
|
||||
try
|
||||
{
|
||||
alarms = await _driver.ReadDeviceAlarmsAsync(host, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Not "no alarms": keep every condition on this device exactly as the server was last told.
|
||||
_logger.LogDebug(ex, "FOCAS alarm read failed for {Host}; its conditions keep their state", host);
|
||||
continue;
|
||||
}
|
||||
Tick(sub, host, alarms, tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
|
||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
||||
/// at High (everything else on a CNC is safety-relevant).
|
||||
/// Evaluates every subscribed condition on <paramref name="deviceHostAddress"/> against one SUCCESSFUL read
|
||||
/// and delivers what changed. Extracted so tests can drive a tick without the loop.
|
||||
/// </summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm severity.</returns>
|
||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
||||
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
|
||||
/// <param name="deviceHostAddress">The device read.</param>
|
||||
/// <param name="alarms">Its active alarms.</param>
|
||||
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
|
||||
internal void Tick(
|
||||
Subscription sub,
|
||||
string deviceHostAddress,
|
||||
IReadOnlyList<FocasActiveAlarm> alarms,
|
||||
IReadOnlyDictionary<string, FocasAlarmTag> tags)
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||
_ => AlarmSeverity.High,
|
||||
};
|
||||
var deviceTags = tags.Values
|
||||
.Where(t => string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var deviceSelectors = deviceTags.Select(t => t.Selector).ToList();
|
||||
var truncated = alarms.Count >= Wire.WireFocasClient.MaxAlarmsPerRead;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
internal sealed class Subscription(
|
||||
FocasAlarmSubscriptionHandle handle,
|
||||
HashSet<string>? deviceFilter,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
/// <summary>Gets the subscription handle.</summary>
|
||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
||||
/// <summary>Gets the device filter.</summary>
|
||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
||||
/// <summary>Gets the cancellation token source.</summary>
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
/// <summary>Gets the last seen alarms by device.</summary>
|
||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
lock (_alarmLock)
|
||||
{
|
||||
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
|
||||
|
||||
NoteTruncation(deviceHostAddress, truncated, alarms.Count);
|
||||
NoteUnmatched(deviceHostAddress, alarms, deviceSelectors);
|
||||
|
||||
// Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that
|
||||
// follows still sees the difference and delivers it.
|
||||
var handlers = _driver.AlarmHandlers;
|
||||
if (handlers is null) return;
|
||||
|
||||
foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath)))
|
||||
{
|
||||
var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated);
|
||||
_told.TryGetValue(tag.RawPath, out var told);
|
||||
var (evt, nowTold) = FocasAlarmConditionMapper.Transition(told, view, tag, sub.Handle, now);
|
||||
_told[tag.RawPath] = nowTold;
|
||||
if (evt is null) continue;
|
||||
|
||||
foreach (var registration in handlers.GetInvocationList())
|
||||
{
|
||||
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(_driver, evt); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"FOCAS driver caught a fault from an alarm subscriber for {ConditionId}; delivery continues.",
|
||||
tag.RawPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Warns once (per episode) when a read came back at the per-read cap. Caller holds the lock.</summary>
|
||||
private void NoteTruncation(string host, bool truncated, int count)
|
||||
{
|
||||
if (!truncated)
|
||||
{
|
||||
_truncationWarned.Remove(host);
|
||||
return;
|
||||
}
|
||||
if (_truncationWarned.Add(host))
|
||||
_logger.LogWarning(
|
||||
"FOCAS device {Host} returned {Count} active alarms — the per-read cap; more may be active and unseen. " +
|
||||
"ALARM:* / ALARM:OTHER conditions on it are marked '[alarm list truncated]'.", host, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs (once per alarm) an active alarm that no condition on its device will show — no <c>*</c>, no
|
||||
/// <c>OTHER</c> and no matching type selector. Caller holds the lock.
|
||||
/// </summary>
|
||||
private void NoteUnmatched(string host, IReadOnlyList<FocasActiveAlarm> alarms, IReadOnlyCollection<FocasAlarmSelector> selectors)
|
||||
{
|
||||
var uncovered = alarms.Where(a => !FocasAlarmMatcher.IsCovered(a, selectors))
|
||||
.Select(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
||||
.ToHashSet();
|
||||
if (!_unmatchedLogged.TryGetValue(host, out var logged))
|
||||
_unmatchedLogged[host] = logged = [];
|
||||
foreach (var key in uncovered.Where(k => !logged.Contains(k)))
|
||||
_logger.LogInformation(
|
||||
"FOCAS device {Host} has active alarm {Number} (type {Type}) that no ALARM: tag selects — author " +
|
||||
"ALARM:OTHER or ALARM:* on the device to surface it.", host, key.Number, key.Type);
|
||||
// Remember exactly the currently-uncovered set, so an alarm that clears and comes back is logged again.
|
||||
_unmatchedLogged[host] = uncovered;
|
||||
}
|
||||
|
||||
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
|
||||
/// <param name="Handle">The handle.</param>
|
||||
/// <param name="References">The subscribed RawPaths.</param>
|
||||
internal sealed record Subscription(FocasAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
/// <summary>Handle returned by <see cref="FocasDriver.SubscribeAlarmsAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -39,6 +39,9 @@ public static class FocasCapabilityMatrix
|
||||
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||
FocasAreaKind.Fixed => null,
|
||||
// An ALARM: selector reads the device's whole active-alarm list (cnc_rdalmmsg2), which every
|
||||
// series exposes; the selector filters it driver-side, so there is no range to check (#647).
|
||||
FocasAreaKind.Alarm => null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +73,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// as the way to publish it without authoring anything.
|
||||
/// </remarks>
|
||||
private bool _fixedTreeActive;
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
/// <summary>
|
||||
/// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because
|
||||
/// it holds what the server was last told about every condition, and the server's condition nodes outlive
|
||||
/// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm.
|
||||
/// </summary>
|
||||
private readonly FocasAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>
|
||||
/// RawPath → authored <c>ALARM:</c> tag (Gitea #647). Built in <see cref="InitializeAsync"/> and swapped in
|
||||
/// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter
|
||||
/// <see cref="_tagsByRawPath"/>: they have no value to read.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, FocasAlarmTag> _alarmTags =
|
||||
new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
||||
// record is immutable so there is no torn-read risk on the object itself.
|
||||
@@ -113,6 +126,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_rebind = rebind;
|
||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||
_alarmProjection = new FocasAlarmProjection(this, _logger);
|
||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
_poll = new PollGroupEngine(
|
||||
@@ -218,6 +232,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
||||
// that fails fast (Driver.FOCAS-003).
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
// A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config
|
||||
// removed — or moved between the value and alarm tables (#647) — must not survive in the old one.
|
||||
_tagsByRawPath.Clear();
|
||||
_parsedAddressesByTagName.Clear();
|
||||
var alarmTags = new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
@@ -260,6 +279,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
entry.RawPath, def.Address, reason, _driverInstanceId);
|
||||
continue;
|
||||
}
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object
|
||||
// is what makes the host materialise a condition at this RawPath, the address is the only thing
|
||||
// this driver can drive one from. Either alone does nothing, so it is skipped and reported.
|
||||
if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null)
|
||||
{
|
||||
if (parsed.Kind == FocasAreaKind.Alarm && def.Alarm is not null)
|
||||
{
|
||||
alarmTags[entry.RawPath] = new FocasAlarmTag(
|
||||
entry.RawPath, deviceHost, parsed.AlarmSelector!, def.Alarm.Severity);
|
||||
continue;
|
||||
}
|
||||
var why = parsed.Kind == FocasAreaKind.Alarm
|
||||
? $"'{def.Address}' is an alarm selector but the tag has no 'alarm' object"
|
||||
: $"the tag carries an 'alarm' object but '{def.Address}' is not an ALARM: selector";
|
||||
_skippedTags.Skip(entry.RawPath, why);
|
||||
_logger.LogWarning("FOCAS tag {RawPath}: {Reason}; skipping. Driver={DriverInstanceId}",
|
||||
entry.RawPath, why, _driverInstanceId);
|
||||
continue;
|
||||
}
|
||||
_tagsByRawPath[entry.RawPath] = def with
|
||||
{
|
||||
DeviceHostAddress = deviceHost,
|
||||
@@ -274,6 +312,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// leaves the previous snapshot standing rather than a truncated one that would read as
|
||||
// "we now skip fewer tags".
|
||||
_skippedTags.Commit();
|
||||
Volatile.Write(ref _alarmTags, alarmTags);
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
@@ -295,9 +334,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
||||
|
||||
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
||||
// config flag defaults off and the operator who committed those tags has already said
|
||||
// what they want.
|
||||
@@ -313,6 +349,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A
|
||||
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
|
||||
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -329,11 +369,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
// Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
if (_devices.Count == 0) return;
|
||||
var loops = new List<Task>();
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -386,11 +423,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
// Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
||||
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
|
||||
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
|
||||
@@ -432,6 +466,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByRawPath.Clear();
|
||||
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_parsedAddressesByTagName.Clear();
|
||||
_fixedTreeActive = false;
|
||||
@@ -523,6 +558,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read.
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_resolver.TryResolve(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -722,6 +764,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647).
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_resolver.TryResolve(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||
@@ -1613,57 +1661,52 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm
|
||||
/// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647).
|
||||
/// Never throws for a disabled flag: authoring an <c>ALARM:</c> tag is the opt-in, and a RawPath that is
|
||||
/// not an <c>ALARM:</c> tag of this driver is simply never raised.
|
||||
/// </remarks>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmProjection is null)
|
||||
throw new NotSupportedException(
|
||||
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
||||
_alarmProjection.SubscribeAsync(sourceNodeIds);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
||||
_alarmProjection.UnsubscribeAsync(handle);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Read-only: nothing is written to the CNC. See <see cref="FocasAlarmProjection.AcknowledgeAsync"/>.</remarks>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
FocasAlarmProjection.AcknowledgeAsync(acknowledgements);
|
||||
|
||||
/// <summary>Raises an alarm event with the provided arguments.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
/// <summary>Gets the attached alarm handlers, or <see langword="null"/> when nobody is listening.</summary>
|
||||
internal EventHandler<AlarmEventArgs>? AlarmHandlers => OnAlarmEvent;
|
||||
|
||||
/// <summary>Gets the authored <c>ALARM:</c> tags (RawPath → tag). Test seam + projection input.</summary>
|
||||
internal IReadOnlyDictionary<string, FocasAlarmTag> AlarmTags => Volatile.Read(ref _alarmTags);
|
||||
|
||||
/// <summary>Gets the alarm projection. Test seam.</summary>
|
||||
internal FocasAlarmProjection AlarmProjection => _alarmProjection;
|
||||
|
||||
/// <summary>Gets the alarm poll cadence — one <c>cnc_rdalmmsg2</c> per alarm-owning device per tick.</summary>
|
||||
internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval;
|
||||
|
||||
/// <summary>
|
||||
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
|
||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
||||
/// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
|
||||
/// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
|
||||
/// list (Gitea #647).
|
||||
/// </summary>
|
||||
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
|
||||
/// <param name="hostAddress">The device host address.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
|
||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
||||
/// <returns>The device's active alarms.</returns>
|
||||
internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
|
||||
{
|
||||
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
result.Add((state.Options.HostAddress, alarms));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* surface a device-local fault on the next tick */
|
||||
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
if (!_devices.TryGetValue(hostAddress, out var state))
|
||||
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
@@ -65,12 +65,18 @@ public interface IFocasClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
||||
/// zero-or-more active alarms. Null / empty list means "no alarms currently
|
||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is
|
||||
/// active". The alarm projection polls this per device and diffs it into Part 9
|
||||
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
|
||||
/// <para>
|
||||
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
|
||||
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
|
||||
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||
/// <exception cref="Exception">Any failure to obtain the list — the session is down, the CNC refused, the wire faulted.</exception>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -446,27 +452,3 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
/// <inheritdoc />
|
||||
public IFocasClient Create() => throw new NotSupportedException(Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||
/// <c>cnc_rdalmmsg2</c> (and so <see cref="FocasAlarmProjection"/>'s <c>switch (short)</c>
|
||||
/// statements compile against a matching type rather than relying on implicit int→short
|
||||
/// narrowing on the constants).
|
||||
/// </remarks>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
||||
public const short All = -1;
|
||||
public const short Parameter = 0; // ALM_P
|
||||
public const short PulseCode = 1; // ALM_Y (servo)
|
||||
public const short Overtravel = 2; // ALM_O
|
||||
public const short Overheat = 3; // ALM_H
|
||||
public const short Servo = 4; // ALM_S
|
||||
public const short DataIo = 5; // ALM_T
|
||||
public const short MemoryCheck = 6; // ALM_M
|
||||
public const short MacroAlarm = 13; // ALM_MC
|
||||
}
|
||||
|
||||
@@ -115,19 +115,18 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Gitea #647: every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
||||
/// alike. This used to answer <c>[]</c> for all three, which the alarm projection could not tell
|
||||
/// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
||||
/// un-acknowledged. <see cref="FocasWireException"/> propagates as-is.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value.Select(Map).ToList();
|
||||
}
|
||||
catch (FocasWireException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_rdalmmsg2", result.IsOk);
|
||||
return (result.Value ?? []).Select(Map).ToList();
|
||||
|
||||
static FocasActiveAlarm Map(WireAlarm a) => new(
|
||||
AlarmNumber: a.AlarmNumber,
|
||||
@@ -468,6 +467,12 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records requested per <c>cnc_rdalmmsg2</c> call. A reply carrying exactly this many may have been
|
||||
/// truncated by the CNC; the alarm projection warns about it (Gitea #647).
|
||||
/// </summary>
|
||||
internal const short MaxAlarmsPerRead = 32;
|
||||
|
||||
private void RequireConnected()
|
||||
{
|
||||
if (!_wire.IsConnected)
|
||||
|
||||
+2
-2
@@ -66,13 +66,13 @@
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||
<div class="form-text">Legacy flag, no longer required (#647): authoring an <code>ALARM:</code> tag with native-alarm settings is the opt-in.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per tick for each device that owns an <code>ALARM:</code> tag. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+53
-10
@@ -1,6 +1,7 @@
|
||||
@* FOCAS address picker:
|
||||
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
||||
(PARAM:1020) and macro variable (MACRO:500) — always available.
|
||||
(PARAM:1020) and macro variable (MACRO:500) — plus a CNC alarm selector (ALARM:*,
|
||||
ALARM:Servo/401-453, ALARM:OTHER — Gitea #647) — always available.
|
||||
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
||||
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
||||
@@ -27,9 +28,41 @@
|
||||
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
||||
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
||||
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
||||
<option value="@FocasManualAddressKind.Alarm">CNC alarm (condition)</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_kind == FocasManualAddressKind.Pmc)
|
||||
@if (_kind == FocasManualAddressKind.Alarm)
|
||||
{
|
||||
@* Gitea #647: an ALARM: selector — one Part 9 condition per tag. The tag must also carry the native-alarm
|
||||
object (alarm section of the tag modal), or it is neither a condition nor a value. *@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Alarms</label>
|
||||
<select class="form-select form-select-sm" @bind="_alarmScope" @bind:after="OnChangedAsync">
|
||||
@foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||
{
|
||||
<option value="@scope">@(scope == "*" ? "* (any on device)" : scope == "OTHER" ? "OTHER (unmatched)" : scope)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_alarmScope is not ("*" or "OTHER"))
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Number <span class="text-muted small">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" min="0" placeholder="any"
|
||||
@bind="_alarmLow" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">to <span class="text-muted small">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" min="0" placeholder="single"
|
||||
@bind="_alarmHigh" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-12 small text-muted">
|
||||
Needs the tag's native-alarm settings enabled. Type names follow the 16i-era FOCAS table; on a
|
||||
30i-series control type a raw code (e.g. <code>ALARM:T9</code>) into the address field instead.
|
||||
</div>
|
||||
}
|
||||
else if (_kind == FocasManualAddressKind.Pmc)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Area</label>
|
||||
@@ -41,12 +74,15 @@
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
@if (_kind != FocasManualAddressKind.Macro)
|
||||
@if (_kind != FocasManualAddressKind.Alarm)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
@if (_kind is not (FocasManualAddressKind.Macro or FocasManualAddressKind.Alarm))
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">
|
||||
@@ -122,6 +158,9 @@
|
||||
private string _pmcLetter = "R";
|
||||
private int _number = 0;
|
||||
private int? _bitIndex;
|
||||
private string _alarmScope = "*";
|
||||
private int? _alarmLow;
|
||||
private int? _alarmHigh;
|
||||
private string _built = "";
|
||||
|
||||
private Guid _token = Guid.Empty;
|
||||
@@ -135,7 +174,7 @@
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
_built = Compose();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
@@ -177,10 +216,14 @@
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
_built = Compose();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private string Compose() => _kind == FocasManualAddressKind.Alarm
|
||||
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
|
||||
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
|
||||
+34
@@ -18,6 +18,12 @@ public enum FocasManualAddressKind
|
||||
|
||||
/// <summary>Macro variable number.</summary>
|
||||
Macro,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the CNC's active alarm list (<c>ALARM:</c>, Gitea #647). Only useful on a tag that also
|
||||
/// carries the native-alarm object — the pair is what becomes a Part 9 condition.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +49,34 @@ public static class FocasAddressBuilder
|
||||
public static IReadOnlyList<string> PmcLetters { get; } =
|
||||
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
||||
|
||||
/// <summary>
|
||||
/// The alarm scopes the picker offers, in order: the device summary (<c>*</c>), the unmatched bucket
|
||||
/// (<c>OTHER</c>), then every named alarm type. A raw <c>T<code></c> type is typed by hand.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AlarmScopes { get; } =
|
||||
[FocasAlarmSelector.AnyToken, FocasAlarmSelector.OtherToken, .. FocasAlarmType.Names];
|
||||
|
||||
/// <summary>
|
||||
/// Composes a canonical <c>ALARM:</c> address, or an empty string when the parts are not a selector the
|
||||
/// driver accepts. Numbers apply to a type scope only; <paramref name="numberHigh"/> without
|
||||
/// <paramref name="numberLow"/> is ignored, and a range equal at both ends collapses to one number.
|
||||
/// </summary>
|
||||
/// <param name="scope">An entry of <see cref="AlarmScopes"/>, or a raw type such as <c>T9</c>.</param>
|
||||
/// <param name="numberLow">The alarm number, or the low end of a range; null for every number.</param>
|
||||
/// <param name="numberHigh">The high end of a range; null for a single number.</param>
|
||||
/// <returns>The canonical address, or an empty string.</returns>
|
||||
public static string BuildAlarm(string? scope, int? numberLow, int? numberHigh)
|
||||
{
|
||||
var body = (scope ?? "").Trim();
|
||||
var isType = body.Length > 0 && body != FocasAlarmSelector.AnyToken
|
||||
&& !string.Equals(body, FocasAlarmSelector.OtherToken, StringComparison.OrdinalIgnoreCase);
|
||||
if (isType && numberLow is { } lo)
|
||||
body += numberHigh is { } hi ? $"/{lo}-{hi}" : $"/{lo}";
|
||||
|
||||
// The same round-trip as Build: the parser decides, and an unusable selector shows as "no address yet".
|
||||
return FocasAddress.TryParse(FocasAddress.AlarmPrefix + body)?.Canonical ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
||||
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed class FocasTagConfigModel
|
||||
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
|
||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>,
|
||||
/// <c>ALARM:Servo/401</c>. Required.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>FOCAS atomic data type.</summary>
|
||||
@@ -98,8 +99,12 @@ public sealed class FocasTagConfigModel
|
||||
/// <param name="address">The rejected address.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string DescribeUnparseableAddress(string address) =>
|
||||
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives (#647).
|
||||
FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"'{address.Trim()}' is not a valid FOCAS alarm address: {alarmError}."
|
||||
: $"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber), or a CNC alarm selector (ALARM:*, ALARM:Servo/401-453, " +
|
||||
"ALARM:OTHER). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user