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
@@ -73,7 +73,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// as the way to publish it without authoring anything.
/// </remarks>
private bool _fixedTreeActive;
private FocasAlarmProjection? _alarmProjection;
/// <summary>
/// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because
/// it holds what the server was last told about every condition, and the server's condition nodes outlive
/// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm.
/// </summary>
private readonly FocasAlarmProjection _alarmProjection;
/// <summary>
/// RawPath → authored <c>ALARM:</c> tag (Gitea #647). Built in <see cref="InitializeAsync"/> and swapped in
/// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter
/// <see cref="_tagsByRawPath"/>: they have no value to read.
/// </summary>
private IReadOnlyDictionary<string, FocasAlarmTag> _alarmTags =
new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
// record is immutable so there is no torn-read risk on the object itself.
@@ -113,6 +126,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_rebind = rebind;
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_alarmProjection = new FocasAlarmProjection(this, _logger);
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
@@ -218,6 +232,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// segment: a tag routed to a device not in the Devices list is a driver-level config error
// that fails fast (Driver.FOCAS-003).
_skippedTags.BeginRebuild(_options.RawTags.Count);
// A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config
// removed — or moved between the value and alarm tables (#647) — must not survive in the old one.
_tagsByRawPath.Clear();
_parsedAddressesByTagName.Clear();
var alarmTags = new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
foreach (var entry in _options.RawTags)
{
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
@@ -260,6 +279,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
entry.RawPath, def.Address, reason, _driverInstanceId);
continue;
}
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object
// is what makes the host materialise a condition at this RawPath, the address is the only thing
// this driver can drive one from. Either alone does nothing, so it is skipped and reported.
if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null)
{
if (parsed.Kind == FocasAreaKind.Alarm && def.Alarm is not null)
{
alarmTags[entry.RawPath] = new FocasAlarmTag(
entry.RawPath, deviceHost, parsed.AlarmSelector!, def.Alarm.Severity);
continue;
}
var why = parsed.Kind == FocasAreaKind.Alarm
? $"'{def.Address}' is an alarm selector but the tag has no 'alarm' object"
: $"the tag carries an 'alarm' object but '{def.Address}' is not an ALARM: selector";
_skippedTags.Skip(entry.RawPath, why);
_logger.LogWarning("FOCAS tag {RawPath}: {Reason}; skipping. Driver={DriverInstanceId}",
entry.RawPath, why, _driverInstanceId);
continue;
}
_tagsByRawPath[entry.RawPath] = def with
{
DeviceHostAddress = deviceHost,
@@ -274,6 +312,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// leaves the previous snapshot standing rather than a truncated one that would read as
// "we now skip fewer tags".
_skippedTags.Commit();
Volatile.Write(ref _alarmTags, alarmTags);
if (_options.Probe.Enabled)
{
@@ -295,9 +334,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
if (_options.AlarmProjection.Enabled)
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
// config flag defaults off and the operator who committed those tags has already said
// what they want.
@@ -313,6 +349,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
}
catch (Exception ex)
@@ -329,11 +369,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
// Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild.
await _alarmProjection.PauseAsync().ConfigureAwait(false);
if (_devices.Count == 0) return;
var loops = new List<Task>();
foreach (var state in _devices.Values)
@@ -386,11 +423,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
// Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it.
await _alarmProjection.PauseAsync().ConfigureAwait(false);
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
@@ -432,6 +466,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
_devices.Clear();
_tagsByRawPath.Clear();
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
_fixedTreeActive = false;
@@ -523,6 +558,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
// An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read.
if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
continue;
}
if (!_resolver.TryResolve(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -722,6 +764,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647).
if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference))
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (!_resolver.TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
@@ -1613,57 +1661,52 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IAlarmSource ----
/// <inheritdoc />
/// <remarks>
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm
/// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647).
/// Never throws for a disabled flag: authoring an <c>ALARM:</c> tag is the opt-in, and a RawPath that is
/// not an <c>ALARM:</c> tag of this driver is simply never raised.
/// </remarks>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (_alarmProjection is null)
throw new NotSupportedException(
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
_alarmProjection.SubscribeAsync(sourceNodeIds);
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
_alarmProjection.UnsubscribeAsync(handle);
/// <inheritdoc />
/// <remarks>Read-only: nothing is written to the CNC. See <see cref="FocasAlarmProjection.AcknowledgeAsync"/>.</remarks>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
FocasAlarmProjection.AcknowledgeAsync(acknowledgements);
/// <summary>Raises an alarm event with the provided arguments.</summary>
/// <param name="args">The alarm event arguments.</param>
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>Gets the attached alarm handlers, or <see langword="null"/> when nobody is listening.</summary>
internal EventHandler<AlarmEventArgs>? AlarmHandlers => OnAlarmEvent;
/// <summary>Gets the authored <c>ALARM:</c> tags (RawPath → tag). Test seam + projection input.</summary>
internal IReadOnlyDictionary<string, FocasAlarmTag> AlarmTags => Volatile.Read(ref _alarmTags);
/// <summary>Gets the alarm projection. Test seam.</summary>
internal FocasAlarmProjection AlarmProjection => _alarmProjection;
/// <summary>Gets the alarm poll cadence — one <c>cnc_rdalmmsg2</c> per alarm-owning device per tick.</summary>
internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval;
/// <summary>
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
/// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
/// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
/// list (Gitea #647).
/// </summary>
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
/// <param name="hostAddress">The device host address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
/// <returns>The device's active alarms.</returns>
internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
{
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
foreach (var state in _devices.Values)
{
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
result.Add((state.Options.HostAddress, alarms));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
/* surface a device-local fault on the next tick */
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
}
}
return result;
if (!_devices.TryGetValue(hostAddress, out var state))
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
}
// ---- IPerCallHostResolver ----