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
@@ -66,13 +66,13 @@
class="form-check-input" />
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
</div>
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
<div class="form-text">Legacy flag, no longer required (#647): authoring an <code>ALARM:</code> tag with native-alarm settings is the opt-in.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
<div class="form-text">One cnc_rdalmmsg2 call per tick for each device that owns an <code>ALARM:</code> tag. Default 2 s.</div>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
@* FOCAS address picker:
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
(PARAM:1020) and macro variable (MACRO:500) — always available.
(PARAM:1020) and macro variable (MACRO:500) — plus a CNC alarm selector (ALARM:*,
ALARM:Servo/401-453, ALARM:OTHER — Gitea #647) — always available.
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
@@ -27,9 +28,41 @@
<option value="@FocasManualAddressKind.Pmc">PMC</option>
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
<option value="@FocasManualAddressKind.Alarm">CNC alarm (condition)</option>
</select>
</div>
@if (_kind == FocasManualAddressKind.Pmc)
@if (_kind == FocasManualAddressKind.Alarm)
{
@* Gitea #647: an ALARM: selector — one Part 9 condition per tag. The tag must also carry the native-alarm
object (alarm section of the tag modal), or it is neither a condition nor a value. *@
<div class="col-md-3">
<label class="form-label">Alarms</label>
<select class="form-select form-select-sm" @bind="_alarmScope" @bind:after="OnChangedAsync">
@foreach (var scope in FocasAddressBuilder.AlarmScopes)
{
<option value="@scope">@(scope == "*" ? "* (any on device)" : scope == "OTHER" ? "OTHER (unmatched)" : scope)</option>
}
</select>
</div>
@if (_alarmScope is not ("*" or "OTHER"))
{
<div class="col-md-3">
<label class="form-label">Number <span class="text-muted small">(optional)</span></label>
<input type="number" class="form-control form-control-sm" min="0" placeholder="any"
@bind="_alarmLow" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">to <span class="text-muted small">(optional)</span></label>
<input type="number" class="form-control form-control-sm" min="0" placeholder="single"
@bind="_alarmHigh" @bind:after="OnChangedAsync" />
</div>
}
<div class="col-12 small text-muted">
Needs the tag's native-alarm settings enabled. Type names follow the 16i-era FOCAS table; on a
30i-series control type a raw code (e.g. <code>ALARM:T9</code>) into the address field instead.
</div>
}
else if (_kind == FocasManualAddressKind.Pmc)
{
<div class="col-md-2">
<label class="form-label">Area</label>
@@ -41,12 +74,15 @@
</select>
</div>
}
<div class="col-md-3">
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_number" @bind:after="OnChangedAsync" />
</div>
@if (_kind != FocasManualAddressKind.Macro)
@if (_kind != FocasManualAddressKind.Alarm)
{
<div class="col-md-3">
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_number" @bind:after="OnChangedAsync" />
</div>
}
@if (_kind is not (FocasManualAddressKind.Macro or FocasManualAddressKind.Alarm))
{
<div class="col-md-3">
<label class="form-label">
@@ -122,6 +158,9 @@
private string _pmcLetter = "R";
private int _number = 0;
private int? _bitIndex;
private string _alarmScope = "*";
private int? _alarmLow;
private int? _alarmHigh;
private string _built = "";
private Guid _token = Guid.Empty;
@@ -135,7 +174,7 @@
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
_built = Compose();
await CurrentAddressChanged.InvokeAsync(_built);
}
@@ -177,10 +216,14 @@
private async Task OnChangedAsync()
{
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
_built = Compose();
await CurrentAddressChanged.InvokeAsync(_built);
}
private string Compose() => _kind == FocasManualAddressKind.Alarm
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
private string TruncatedError() =>
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
@@ -18,6 +18,12 @@ public enum FocasManualAddressKind
/// <summary>Macro variable number.</summary>
Macro,
/// <summary>
/// A selector over the CNC's active alarm list (<c>ALARM:</c>, Gitea #647). Only useful on a tag that also
/// carries the native-alarm object — the pair is what becomes a Part 9 condition.
/// </summary>
Alarm,
}
/// <summary>
@@ -43,6 +49,34 @@ public static class FocasAddressBuilder
public static IReadOnlyList<string> PmcLetters { get; } =
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
/// <summary>
/// The alarm scopes the picker offers, in order: the device summary (<c>*</c>), the unmatched bucket
/// (<c>OTHER</c>), then every named alarm type. A raw <c>T&lt;code&gt;</c> type is typed by hand.
/// </summary>
public static IReadOnlyList<string> AlarmScopes { get; } =
[FocasAlarmSelector.AnyToken, FocasAlarmSelector.OtherToken, .. FocasAlarmType.Names];
/// <summary>
/// Composes a canonical <c>ALARM:</c> address, or an empty string when the parts are not a selector the
/// driver accepts. Numbers apply to a type scope only; <paramref name="numberHigh"/> without
/// <paramref name="numberLow"/> is ignored, and a range equal at both ends collapses to one number.
/// </summary>
/// <param name="scope">An entry of <see cref="AlarmScopes"/>, or a raw type such as <c>T9</c>.</param>
/// <param name="numberLow">The alarm number, or the low end of a range; null for every number.</param>
/// <param name="numberHigh">The high end of a range; null for a single number.</param>
/// <returns>The canonical address, or an empty string.</returns>
public static string BuildAlarm(string? scope, int? numberLow, int? numberHigh)
{
var body = (scope ?? "").Trim();
var isType = body.Length > 0 && body != FocasAlarmSelector.AnyToken
&& !string.Equals(body, FocasAlarmSelector.OtherToken, StringComparison.OrdinalIgnoreCase);
if (isType && numberLow is { } lo)
body += numberHigh is { } hi ? $"/{lo}-{hi}" : $"/{lo}";
// The same round-trip as Build: the parser decides, and an unusable selector shows as "no address yet".
return FocasAddress.TryParse(FocasAddress.AlarmPrefix + body)?.Canonical ?? "";
}
/// <summary>
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
@@ -10,7 +10,8 @@ public sealed class FocasTagConfigModel
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>,
/// <c>ALARM:Servo/401</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>FOCAS atomic data type.</summary>
@@ -98,8 +99,12 @@ public sealed class FocasTagConfigModel
/// <param name="address">The rejected address.</param>
/// <returns>The message.</returns>
internal static string DescribeUnparseableAddress(string address) =>
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
"not an address — commit the leaf from the browser rather than pasting its id.";
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives (#647).
FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
? $"'{address.Trim()}' is not a valid FOCAS alarm address: {alarmError}."
: $"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), a fixed-tree " +
"path (FIXED:Identity/SeriesNumber), or a CNC alarm selector (ALARM:*, ALARM:Servo/401-453, " +
"ALARM:OTHER). A browse full reference including the device host is " +
"not an address — commit the leaf from the browser rather than pasting its id.";
}