Auto: focas-f3a — cnc_rdalmhistry alarm-history extension

Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory)
that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default,
HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with
SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on
(OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer
decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads
ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport
variants stay back-compat). FocasDriver now implements IAlarmSource.

13 new unit tests cover: mode switch, dedup, distinct-timestamp emission,
type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth
clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass.

Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new),
docs/v2/implementation/focas-wire-protocol.md (new),
docs/v2/implementation/focas-simulator-plan.md (new),
docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended).

Closes #267
This commit is contained in:
Joseph Doherty
2026-04-26 00:07:59 -04:00
parent 1922b93bd5
commit 7f9d6a778e
12 changed files with 1248 additions and 1 deletions
@@ -18,12 +18,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
private readonly FocasAlarmProjection _alarmProjection;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
@@ -119,6 +120,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>
/// Per <see cref="IAlarmSource"/> — the projection raises history events through here
/// and a future PR's active-alarm poll will join the same channel (issue #267,
/// plan PR F3-a).
/// </summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
{
@@ -130,6 +138,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new FocasAlarmProjection(
options: _options.AlarmProjection,
connectAsync: ConnectFirstDeviceAsync,
emit: args => OnAlarmEvent?.Invoke(this, args),
diagnosticPrefix: $"focas-alarm-{driverInstanceId}");
}
/// <summary>
/// Bridge for the alarm projection — returns the first device's connected
/// <see cref="IFocasClient"/> on demand. Multi-device alarm projection (one history
/// poll per CNC) is a follow-up; today the projection targets the primary device,
/// which is the only deployed shape per the F3-a plan.
/// </summary>
private async Task<IFocasClient?> ConnectFirstDeviceAsync(CancellationToken ct)
{
var device = _devices.Values.FirstOrDefault();
if (device is null) return null;
try { return await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return null; }
}
public string DriverInstanceId => _driverInstanceId;
@@ -254,6 +282,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
@@ -813,6 +842,36 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
// ---- IAlarmSource (issue #267, plan PR F3-a) ----
/// <summary>
/// Subscribe to FOCAS alarm events. When
/// <see cref="FocasDriverOptions.AlarmProjection"/>'s mode is
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>, the projection polls
/// <c>cnc_rdalmhistry</c> on connect + on the configured cadence and emits unseen
/// entries through <see cref="OnAlarmEvent"/> with the CNC's reported timestamp.
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) returns the handle
/// for capability negotiation but skips the history poll. The active-alarm poll
/// itself ships in a follow-up PR.
/// </summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
=> _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
=> _alarmProjection.UnsubscribeAsync(handle, cancellationToken);
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
=> _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken);
/// <summary>
/// Reset the alarm projection's dedup set. Called by the driver on reconnect so the
/// first poll after reconnect re-emits the ring buffer (acceptable per issue #267
/// since alarms are timestamped + clients can suppress repeats client-side).
/// </summary>
internal void ResetAlarmDedup() => _alarmProjection.ResetDedup();
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>