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:
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user