Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
v2-ci / build (push) Successful in 4m57s
v2-ci / unit-tests (push) Failing after 21m6s

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:46:13 -04:00
36 changed files with 2847 additions and 551 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.";
}
@@ -1877,12 +1877,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
foreach (var n in nodeIds)
{
var nodeId = n.NodeId;
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out var restatement);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
if (!serviceAlertsAsPrimary) continue;
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A re-statement
// (Gitea #647 review: a Raise on an already-active condition, sent by a driver a deploy respawned) is
// not a transition — the condition was already Active, so a second "Activated" row would be a duplicate.
if (!serviceAlertsAsPrimary || restatement) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
@@ -12,25 +12,48 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked)> _prior = new(StringComparer.Ordinal);
private readonly Dictionary<string, (bool Active, bool Acked, string? Message)> _prior = new(StringComparer.Ordinal);
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
/// <param name="e">The native alarm transition.</param>
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out _);
/// <summary>
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> (Gitea #647 review): a
/// <see cref="AlarmTransitionKind.Raise"/> on a condition already projected Active WITH THE SAME MESSAGE
/// (re-review R1 — a different message is a different alarm, and un-acks). That is what a driver
/// whose told-record died with its instance sends after a deploy respawns it — the alarm it re-reads is the
/// one the server already shows, so the acknowledgement is KEPT and the host writes no second "Activated"
/// row. Only <see cref="AlarmTransitionKind.Retrigger"/> (or inactive → active) un-acknowledges; a source
/// that knows the alarm is a genuinely new statement must send Retrigger.
/// </summary>
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
/// <param name="e">The native alarm transition.</param>
/// <param name="restatement">True when <paramref name="e"/> re-stated an already-active condition.</param>
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement)
{
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Message: (string?)null);
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
// Gitea #647 re-review R1: only a Raise saying the SAME thing the condition already shows is a re-statement.
// A respawned driver cannot tell "the alarm I was showing" from "a different one that appeared while I was
// down", so a different message is treated as a new statement — un-acked, with its alerts row. Comparing the
// message is conservative: any difference (a sources' messages are deterministic for an unchanged statement)
// errs toward showing the operator an unacknowledged alarm, never toward hiding one behind a stale ack.
restatement = e.Kind == AlarmTransitionKind.Raise && prev.Active
&& string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal);
var (active, acked) = e.Kind switch
{
AlarmTransitionKind.Raise when restatement => (true, prev.Acked),
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (prev.Active, true),
AlarmTransitionKind.Clear => (false, prev.Acked),
_ => (prev.Active, prev.Acked),
};
_prior[nodeId] = (active, acked);
_prior[nodeId] = (active, acked, e.Message);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
@@ -48,7 +71,7 @@ public sealed class NativeAlarmProjector
/// <param name="nodeId">The acknowledged condition node id.</param>
public void MarkAcknowledged(string nodeId)
{
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true, p.Message);
}
/// <summary>Clears all tracked per-node state.</summary>