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)