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:
+2
-2
@@ -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>
|
||||
|
||||
+53
-10
@@ -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);
|
||||
|
||||
|
||||
+34
@@ -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<code></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.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user