feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:
1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
editor, the address picker, the deploy-time inspector and driver init.
Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).
E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>), plus
|
||||
/// <see cref="FocasAreaKind.Alarm"/> (a selector over the CNC's active alarm list — <c>ALARM:*</c>,
|
||||
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>, Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
@@ -35,11 +37,15 @@ public sealed record FocasAddress(
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex,
|
||||
string? FixedPath = null)
|
||||
string? FixedPath = null,
|
||||
FocasAlarmSelector? AlarmSelector = null)
|
||||
{
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address (Gitea #647).</summary>
|
||||
public const string AlarmPrefix = "ALARM:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
@@ -51,6 +57,7 @@ public sealed record FocasAddress(
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||
FocasAreaKind.Alarm => AlarmPrefix + AlarmSelector?.Canonical,
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
@@ -62,6 +69,26 @@ public sealed record FocasAddress(
|
||||
/// <returns>The canonical authored address string.</returns>
|
||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||
|
||||
/// <summary>Builds an <see cref="FocasAreaKind.Alarm"/> address over <paramref name="selector"/>.</summary>
|
||||
/// <param name="selector">The alarm selector.</param>
|
||||
/// <returns>The address.</returns>
|
||||
public static FocasAddress ForAlarm(FocasAlarmSelector selector) =>
|
||||
new(FocasAreaKind.Alarm, null, 0, null, AlarmSelector: selector);
|
||||
|
||||
/// <summary>
|
||||
/// Why <paramref name="value"/> is not a usable <c>ALARM:</c> address, or <see langword="null"/> when it
|
||||
/// is one (or is not an <c>ALARM:</c> address at all). Shared by the AdminUI editor and the deploy-time
|
||||
/// inspector so both say the same thing about the same string.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored address.</param>
|
||||
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
|
||||
public static string? DescribeAlarmAddressError(string? value)
|
||||
{
|
||||
var src = value?.Trim() ?? "";
|
||||
if (!src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..], out _, out var error) ? null : error;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
@@ -86,6 +113,14 @@ public sealed record FocasAddress(
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// Alarm selector (Gitea #647). Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||
// valid PMC letter (message display), so ALARM:… must be owned by this rule, not diagnosed as PMC.
|
||||
if (src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var selector = FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..]);
|
||||
return selector is null ? null : ForAlarm(selector);
|
||||
}
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
@@ -175,4 +210,12 @@ public enum FocasAreaKind
|
||||
/// nothing in that tree is a writable location on the CNC.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the device's active CNC alarm list (<c>ALARM:*</c>, <c>ALARM:Servo/401-453</c>,
|
||||
/// <c>ALARM:OTHER</c>) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver
|
||||
/// <c>alarm</c> object, which makes it ONE Part 9 condition at its RawPath; it has no value to read
|
||||
/// and nothing to write.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects (Gitea #647).</summary>
|
||||
public enum FocasAlarmSelectorKind
|
||||
{
|
||||
/// <summary><c>ALARM:*</c> — every active alarm on the tag's device (a device summary condition).</summary>
|
||||
Any,
|
||||
|
||||
/// <summary>
|
||||
/// <c>ALARM:OTHER</c> — active alarms on the device that no <see cref="Type"/> selector on the same
|
||||
/// device matches: the "unmatched" bucket. <see cref="Any"/> and other <see cref="Other"/> tags do not
|
||||
/// claim an alarm, so an <c>ALARM:*</c> summary beside it does not starve it.
|
||||
/// </summary>
|
||||
Other,
|
||||
|
||||
/// <summary><c>ALARM:Servo</c>, <c>ALARM:Servo/401</c>, <c>ALARM:Servo/401-453</c>, <c>ALARM:T4</c>.</summary>
|
||||
Type,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selector half of an <c>ALARM:</c> FOCAS address (Gitea #647): which entries of the CNC's flat,
|
||||
/// per-device active-alarm list (<c>cnc_rdalmmsg2</c>) drive the ONE Part 9 condition the raw tag is
|
||||
/// materialised as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Grammar of the text after <c>ALARM:</c> (case-insensitive, no embedded whitespace):</para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Form</term><description>Matches</description></listheader>
|
||||
/// <item><term><c>*</c></term><description>any active alarm on the device</description></item>
|
||||
/// <item><term><c>OTHER</c></term><description>alarms no type selector on the device matches</description></item>
|
||||
/// <item><term><c><TYPE></c></term><description>any alarm of that type</description></item>
|
||||
/// <item><term><c><TYPE>/<n></c></term><description>that type and alarm number</description></item>
|
||||
/// <item><term><c><TYPE>/<lo>-<hi></c></term><description>that type, number in the inclusive range</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <c><TYPE></c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
|
||||
/// or a raw type code <c>T<code></c> (<c>T4</c>). The names resolve through the driver's single
|
||||
/// 16i-era type table — see the ⚠️ on <see cref="FocasAlarmType"/>; the raw-code form is the
|
||||
/// series-independent one and the right choice on a 30i-series control.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One authority.</b> This parser backs the AdminUI editor (through <see cref="FocasAddress.TryParse"/>),
|
||||
/// the deploy-time inspector and the driver's init — an address one of them accepts, all of them accept.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Kind">The selector kind.</param>
|
||||
/// <param name="TypeCode">The FOCAS alarm type code; set only for <see cref="FocasAlarmSelectorKind.Type"/>.</param>
|
||||
/// <param name="TypeName">The canonical type name when the selector named one; <see langword="null"/> for a raw <c>T<code></c>.</param>
|
||||
/// <param name="NumberLow">The lowest alarm number matched, or <see langword="null"/> for every number.</param>
|
||||
/// <param name="NumberHigh">The highest alarm number matched (inclusive); equals <paramref name="NumberLow"/> for a single number.</param>
|
||||
public sealed record FocasAlarmSelector(
|
||||
FocasAlarmSelectorKind Kind,
|
||||
short? TypeCode = null,
|
||||
string? TypeName = null,
|
||||
int? NumberLow = null,
|
||||
int? NumberHigh = null)
|
||||
{
|
||||
/// <summary>The <c>*</c> body.</summary>
|
||||
public const string AnyToken = "*";
|
||||
|
||||
/// <summary>The <c>OTHER</c> body.</summary>
|
||||
public const string OtherToken = "OTHER";
|
||||
|
||||
/// <summary>Gets the canonical selector text (the part after <c>ALARM:</c>).</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => AnyToken,
|
||||
FocasAlarmSelectorKind.Other => OtherToken,
|
||||
_ => (TypeName ?? $"T{TypeCode}") + (NumberLow is not { } lo
|
||||
? ""
|
||||
: NumberHigh is { } hi && hi != lo ? $"/{lo}-{hi}" : $"/{lo}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this selector matches one CNC alarm on its own terms. <see cref="FocasAlarmSelectorKind.Other"/>
|
||||
/// always answers <see langword="false"/> here: whether an alarm is "unmatched" depends on the other
|
||||
/// selectors on the device, which the caller evaluates.
|
||||
/// </summary>
|
||||
/// <param name="type">The alarm's type code.</param>
|
||||
/// <param name="number">The alarm number.</param>
|
||||
/// <returns><see langword="true"/> when the alarm is selected.</returns>
|
||||
public bool Matches(short type, int number) => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => true,
|
||||
FocasAlarmSelectorKind.Type => type == TypeCode
|
||||
&& (NumberLow is not { } lo || (number >= lo && number <= (NumberHigh ?? lo))),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, or returns <see langword="null"/>.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <returns>The selector, or <see langword="null"/> when the text is not a valid selector.</returns>
|
||||
public static FocasAlarmSelector? TryParse(string? body) => TryParse(body, out var selector, out _) ? selector : null;
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, explaining a refusal.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <param name="selector">The selector when this returns <see langword="true"/>.</param>
|
||||
/// <param name="error">Why the text was refused, when this returns <see langword="false"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="body"/> is a valid selector.</returns>
|
||||
public static bool TryParse(string? body, out FocasAlarmSelector? selector, out string? error)
|
||||
{
|
||||
selector = null;
|
||||
error = null;
|
||||
var src = (body ?? "").Trim();
|
||||
if (src.Length == 0)
|
||||
{
|
||||
error = "an ALARM: address needs a selector (*, OTHER, a type, type/number or type/lo-hi)";
|
||||
return false;
|
||||
}
|
||||
if (src.Any(char.IsWhiteSpace))
|
||||
{
|
||||
error = $"ALARM selector '{src}' contains whitespace";
|
||||
return false;
|
||||
}
|
||||
if (src == AnyToken)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Any);
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(src, OtherToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Other);
|
||||
return true;
|
||||
}
|
||||
|
||||
var slash = src.IndexOf('/');
|
||||
var typeText = slash < 0 ? src : src[..slash];
|
||||
if (!TryParseType(typeText, out var code, out var name))
|
||||
{
|
||||
error = $"'{typeText}' is not a FOCAS alarm type — expected one of {string.Join(", ", FocasAlarmType.Names)}, " +
|
||||
"or a raw type code such as T4";
|
||||
return false;
|
||||
}
|
||||
if (slash < 0)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
var numbers = src[(slash + 1)..];
|
||||
var dash = numbers.IndexOf('-');
|
||||
if (dash < 0)
|
||||
{
|
||||
if (!TryParseNumber(numbers, out var n))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number (a non-negative integer)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, n, n);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryParseNumber(numbers[..dash], out var lo) || !TryParseNumber(numbers[(dash + 1)..], out var hi))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number range (lo-hi, non-negative integers)";
|
||||
return false;
|
||||
}
|
||||
if (lo > hi)
|
||||
{
|
||||
error = $"alarm number range '{numbers}' is reversed (lo must not exceed hi)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, lo, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseType(string text, out short code, out string? name)
|
||||
{
|
||||
name = null;
|
||||
if (FocasAlarmType.TryResolve(text, out code))
|
||||
{
|
||||
name = FocasAlarmType.NameOf(code);
|
||||
return true;
|
||||
}
|
||||
// Raw code: T<digits>. Non-negative and within the wire field's short width.
|
||||
if (text.Length >= 2 && (text[0] == 'T' || text[0] == 't')
|
||||
&& text.Skip(1).All(char.IsAsciiDigit)
|
||||
&& short.TryParse(text.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out code))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
code = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string text, out int number) =>
|
||||
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out number);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||
/// <c>cnc_rdalmmsg2</c> (and so the driver's <c>switch (short)</c> statements compile against a
|
||||
/// matching type rather than relying on implicit int→short narrowing on the constants).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>
|
||||
/// (Gitea #647). The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||
/// been confirmed against a FOCAS reference, so it is deliberately NOT encoded here — a guessed code
|
||||
/// would silently route the wrong alarms. On a 30i-series control the names below therefore label
|
||||
/// the raw type code with its 16i meaning, which may be wrong. Every alarm message the driver
|
||||
/// publishes carries the raw code (<c>type 4</c>), and an <c>ALARM:</c> address can name a raw code
|
||||
/// (<c>ALARM:T4</c>) instead of a name, which is the series-independent form.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Moved into the Contracts assembly (from the driver's <c>IFocasClient.cs</c>, same namespace) so the
|
||||
/// <see cref="FocasAlarmSelector"/> grammar — shared by the AdminUI editor and the deploy gate — can
|
||||
/// resolve type names without referencing the runtime driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to the wire's <c>cnc_rdalmmsg2</c> to mean "any type".</summary>
|
||||
public const short All = -1;
|
||||
|
||||
/// <summary><c>ALM_P</c> — parameter / program (P/S) alarms.</summary>
|
||||
public const short Parameter = 0;
|
||||
|
||||
/// <summary><c>ALM_Y</c> — pulse coder (servo encoder) alarms.</summary>
|
||||
public const short PulseCode = 1;
|
||||
|
||||
/// <summary><c>ALM_O</c> — overtravel alarms.</summary>
|
||||
public const short Overtravel = 2;
|
||||
|
||||
/// <summary><c>ALM_H</c> — overheat alarms.</summary>
|
||||
public const short Overheat = 3;
|
||||
|
||||
/// <summary><c>ALM_S</c> — servo alarms.</summary>
|
||||
public const short Servo = 4;
|
||||
|
||||
/// <summary><c>ALM_T</c> — data I/O alarms.</summary>
|
||||
public const short DataIo = 5;
|
||||
|
||||
/// <summary><c>ALM_M</c> — memory check alarms.</summary>
|
||||
public const short MemoryCheck = 6;
|
||||
|
||||
/// <summary><c>ALM_MC</c> — macro alarms (<c>#3000</c>).</summary>
|
||||
public const short MacroAlarm = 13;
|
||||
|
||||
/// <summary>
|
||||
/// The named types an <c>ALARM:</c> address may use, keyed case-insensitively by the constant's
|
||||
/// name. The canonical spelling is the constant name (<c>Servo</c>, <c>MacroAlarm</c>).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, short> ByName =
|
||||
new Dictionary<string, short>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[nameof(Parameter)] = Parameter,
|
||||
[nameof(PulseCode)] = PulseCode,
|
||||
[nameof(Overtravel)] = Overtravel,
|
||||
[nameof(Overheat)] = Overheat,
|
||||
[nameof(Servo)] = Servo,
|
||||
[nameof(DataIo)] = DataIo,
|
||||
[nameof(MemoryCheck)] = MemoryCheck,
|
||||
[nameof(MacroAlarm)] = MacroAlarm,
|
||||
};
|
||||
|
||||
/// <summary>Gets the canonical type names an <c>ALARM:</c> address accepts, in code order.</summary>
|
||||
public static IReadOnlyList<string> Names { get; } =
|
||||
[nameof(Parameter), nameof(PulseCode), nameof(Overtravel), nameof(Overheat),
|
||||
nameof(Servo), nameof(DataIo), nameof(MemoryCheck), nameof(MacroAlarm)];
|
||||
|
||||
/// <summary>Resolves a type name (case-insensitive) to its code.</summary>
|
||||
/// <param name="name">The type name, e.g. <c>servo</c>.</param>
|
||||
/// <param name="code">The resolved code.</param>
|
||||
/// <returns><see langword="true"/> when the name is in the table.</returns>
|
||||
public static bool TryResolve(string name, out short code) => ByName.TryGetValue(name, out code);
|
||||
|
||||
/// <summary>The canonical name of a code, or <see langword="null"/> when the code is not in the table.</summary>
|
||||
/// <param name="code">The FOCAS alarm type code.</param>
|
||||
/// <returns>The name, or <see langword="null"/>.</returns>
|
||||
public static string? NameOf(short code) => code switch
|
||||
{
|
||||
Parameter => nameof(Parameter),
|
||||
PulseCode => nameof(PulseCode),
|
||||
Overtravel => nameof(Overtravel),
|
||||
Overheat => nameof(Overheat),
|
||||
Servo => nameof(Servo),
|
||||
DataIo => nameof(DataIo),
|
||||
MemoryCheck => nameof(MemoryCheck),
|
||||
MacroAlarm => nameof(MacroAlarm),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -128,12 +128,19 @@ public sealed class FocasHandleRecycleOptions
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
||||
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags (Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Authoring an <c>ALARM:</c> tag IS the opt-in.</b> The projection polls exactly the devices that own a
|
||||
/// subscribed alarm tag, so a driver with none issues no <c>cnc_rdalmmsg2</c> traffic whatever
|
||||
/// <see cref="Enabled"/> says. Before #647, <see cref="Enabled"/> gated the poll and
|
||||
/// <c>SubscribeAlarmsAsync</c> THREW while it was false — so an operator who authored an alarm tag and left
|
||||
/// the default got a feed that stayed gated with a Warning on every reconnect. It is retained so existing
|
||||
/// configs still bind, and no longer has any effect.
|
||||
/// </remarks>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
||||
/// <summary>Retained for config compatibility; has no effect since Gitea #647 (see the class remarks).</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
@@ -196,13 +203,29 @@ public sealed record FocasDeviceOptions(
|
||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <param name="Alarm">
|
||||
/// The tag's cross-driver <c>alarm</c> object, when it carries one (Gitea #647) — which makes the host
|
||||
/// materialise it as a Part 9 condition at its RawPath. Only an <c>ALARM:</c> address can drive one.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
FocasTagAlarm? Alarm = null);
|
||||
|
||||
/// <summary>
|
||||
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object (Gitea #647). The host owns the
|
||||
/// rest of it (<c>alarmType</c>, <c>historizeToAveva</c>) through <c>TagConfigIntent</c>.
|
||||
/// </summary>
|
||||
/// <param name="Severity">
|
||||
/// The authored <c>alarm.severity</c> (an integer), or <see langword="null"/> when the object has none. An
|
||||
/// authored severity is sent as the condition's exact Part 9 severity; without one the severity comes from
|
||||
/// the displayed CNC alarm's type (<c>FocasAlarmConditionMapper</c>).
|
||||
/// </param>
|
||||
public sealed record FocasTagAlarm(int? Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Controls periodic connectivity probing. One <c>cnc_rdcncstat</c> call per
|
||||
|
||||
@@ -64,7 +64,8 @@ public static class FocasTagDefinitionFactory
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
Writable: writable,
|
||||
WriteIdempotent: false);
|
||||
WriteIdempotent: false,
|
||||
Alarm: ReadAlarm(root));
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
@@ -72,6 +73,20 @@ public static class FocasTagDefinitionFactory
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag's cross-driver <c>alarm</c> object (Gitea #647), or <see langword="null"/> when it carries none.
|
||||
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <returns>The alarm intent, or <see langword="null"/>.</returns>
|
||||
private static FocasTagAlarm? ReadAlarm(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
int? severity = a.TryGetProperty("severity", out var s) && s.ValueKind == JsonValueKind.Number
|
||||
&& s.TryGetInt32(out var v) ? v : null;
|
||||
return new FocasTagAlarm(severity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
@@ -235,16 +250,37 @@ public static class FocasTagDefinitionFactory
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
||||
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
||||
if (root.TryGetProperty("address", out var addr)
|
||||
&& addr.ValueKind == JsonValueKind.String
|
||||
&& addr.GetString() is { } address
|
||||
&& !string.IsNullOrWhiteSpace(address)
|
||||
&& FocasAddress.TryParse(address) is null)
|
||||
var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String
|
||||
? addr.GetString()
|
||||
: null;
|
||||
var parsed = string.IsNullOrWhiteSpace(address) ? null : FocasAddress.TryParse(address);
|
||||
if (!string.IsNullOrWhiteSpace(address) && parsed is null)
|
||||
{
|
||||
// An ALARM: address gets the selector's own reason — the same text the AdminUI editor shows.
|
||||
advisories.Add(FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"FOCAS alarm address '{address.Trim()}' is not valid: {alarmError} — the driver will skip " +
|
||||
"this tag at startup and its condition will never activate."
|
||||
: $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500, FIXED:Identity/SeriesNumber or ALARM:Servo/401) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||
// makes the host materialise a Part 9 condition; the address is the only thing the driver can drive one
|
||||
// from. Either one alone is a tag that does nothing.
|
||||
var hasAlarmObject = root.TryGetProperty("alarm", out var alarm) && alarm.ValueKind == JsonValueKind.Object;
|
||||
if (parsed is { Kind: FocasAreaKind.Alarm } && !hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
$"FOCAS address '{address!.Trim()}' is an alarm selector but the tag has no 'alarm' object — it is " +
|
||||
"not materialised as a condition and has no value to read; the driver skips it.");
|
||||
}
|
||||
else if (parsed is { Kind: not FocasAreaKind.Alarm } && hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS tag carries an 'alarm' object but its address '{address!.Trim()}' is not an ALARM: selector — " +
|
||||
"the condition is materialised but nothing will ever raise it; the driver skips the tag. " +
|
||||
"Use ALARM:*, ALARM:<type>[/<n>[-<m>]] or ALARM:OTHER.");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||
|
||||
Reference in New Issue
Block a user