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:
Joseph Doherty
2026-10-08 19:46:48 -04:00
parent 8801631739
commit 482bafdfac
27 changed files with 2110 additions and 515 deletions
@@ -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>&lt;TYPE&gt;</c></term><description>any alarm of that type</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;n&gt;</c></term><description>that type and alarm number</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;lo&gt;-&lt;hi&gt;</c></term><description>that type, number in the inclusive range</description></item>
/// </list>
/// <para>
/// <c>&lt;TYPE&gt;</c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
/// or a raw type code <c>T&lt;code&gt;</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&lt;code&gt;</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>&lt;400</c> Low, <c>&lt;600</c> Medium, <c>&lt;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&lt;code&gt;</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)