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:
255
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
255
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface
|
||||
/// via <see cref="IAlarmSource"/>. Two modes:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) — only
|
||||
/// currently-active alarms surface. Subscribe / unsubscribe / acknowledge wire up,
|
||||
/// but no history poll runs. This is the conservative mode operators get when
|
||||
/// they don't explicitly opt into history.</item>
|
||||
/// <item><see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> — additionally
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect and on every
|
||||
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> tick. Each
|
||||
/// previously-unseen entry fires an <c>OnAlarmEvent</c> with
|
||||
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp (not Now)
|
||||
/// so OPC UA dashboards see the real occurrence time.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Dedup</b> — an in-memory <see cref="HashSet{T}"/> keyed on
|
||||
/// <c>(OccurrenceTime, AlarmNumber, AlarmType)</c> tracks every entry the projection has
|
||||
/// emitted. The same triple across two polls only emits once. The set resets on reconnect
|
||||
/// — first poll after reconnect re-emits everything in the ring buffer; OPC UA clients
|
||||
/// that care about exactly-once semantics dedupe on their side via the
|
||||
/// timestamp + number + type tuple.</para>
|
||||
///
|
||||
/// <para><b>HistoryDepth clamp</b> — user-supplied depth is bounded to
|
||||
/// <c>[1..<see cref="FocasAlarmProjectionOptions.MaxHistoryDepth"/>]</c> so an operator
|
||||
/// who types <c>10000</c> by accident doesn't blow up the wire session. The clamp lives
|
||||
/// in <see cref="ResolveDepth"/>.</para>
|
||||
///
|
||||
/// <para><b>Active alarms</b> — first cut surfaces history only. Active alarms (raise +
|
||||
/// clear via <c>cnc_rdalmmsg</c>/<c>cnc_rdalmmsg2</c>) are a follow-up; this projection's
|
||||
/// subscribe path returns a handle but does not poll for active alarms today. The
|
||||
/// ActiveOnly mode therefore is functionally a no-op subscribe — the IAlarmSource
|
||||
/// contract still wires up so capability negotiation works + a future PR can add the
|
||||
/// active-alarm poll without reshaping the projection. The plan deliberately scopes F3-a
|
||||
/// to the history extension; the active poll lands as F3-b.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<IFocasClient?>> _connectAsync;
|
||||
private readonly Action<AlarmEventArgs> _emit;
|
||||
private readonly FocasAlarmProjectionOptions _options;
|
||||
private readonly string _diagnosticPrefix;
|
||||
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>
|
||||
/// Dedup set across the entire projection — alarm history is per-CNC, not
|
||||
/// per-subscription, so a single set across all subscriptions matches operator
|
||||
/// intent (one CNC, one ring buffer, one set of history events even if multiple
|
||||
/// OPC UA clients have subscribed).
|
||||
/// </summary>
|
||||
private readonly HashSet<DedupKey> _seen = new();
|
||||
private readonly Lock _seenLock = new();
|
||||
|
||||
public FocasAlarmProjection(
|
||||
FocasAlarmProjectionOptions options,
|
||||
Func<CancellationToken, Task<IFocasClient?>> connectAsync,
|
||||
Action<AlarmEventArgs> emit,
|
||||
string diagnosticPrefix = "focas-alarm-sub")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(connectAsync);
|
||||
ArgumentNullException.ThrowIfNull(emit);
|
||||
_options = options;
|
||||
_connectAsync = connectAsync;
|
||||
_emit = emit;
|
||||
_diagnosticPrefix = diagnosticPrefix;
|
||||
}
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id, _diagnosticPrefix);
|
||||
|
||||
if (_options.Mode != FocasAlarmProjectionMode.ActivePlusHistory)
|
||||
{
|
||||
// ActiveOnly — return the handle so capability negotiation works, but skip the
|
||||
// history poll entirely. The active-alarm poll lands as a follow-up PR.
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
sub.Loop = Task.Run(() => RunHistoryPollAsync(sub, cts.Token), cts.Token);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge stub — FANUC's history surface is read-only (the ring buffer only
|
||||
/// records what the CNC has cleared internally), so per-history-entry ack is a no-op.
|
||||
/// A future PR may extend the active-alarm flow with a per-CNC reset call.
|
||||
/// </summary>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the dedup set — used after reconnect so the next history poll re-emits
|
||||
/// everything in the ring buffer. Public for tests + the driver's reconnect hook.
|
||||
/// </summary>
|
||||
public void ResetDedup()
|
||||
{
|
||||
lock (_seenLock) _seen.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull one history snapshot + emit unseen entries. Extracted from the timer loop so
|
||||
/// unit tests can drive a single tick without standing up Task.Run.
|
||||
/// </summary>
|
||||
internal async Task<int> PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
var client = await _connectAsync(ct).ConfigureAwait(false);
|
||||
if (client is null) return 0;
|
||||
|
||||
var depth = ResolveDepth(_options.HistoryDepth);
|
||||
IReadOnlyList<FocasAlarmHistoryEntry> entries;
|
||||
try
|
||||
{
|
||||
entries = await client.ReadAlarmHistoryAsync(depth, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-tick failure — leave dedup intact, next tick retries. Matches the
|
||||
// AbCip alarm projection's "non-fatal per-tick" pattern (#177).
|
||||
return 0;
|
||||
}
|
||||
|
||||
var emitted = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var key = new DedupKey(entry.OccurrenceTime, entry.AlarmNumber, entry.AlarmType);
|
||||
bool added;
|
||||
lock (_seenLock) added = _seen.Add(key);
|
||||
if (!added) continue;
|
||||
|
||||
// Each subscription gets its own copy of the event — multiple OPC UA clients
|
||||
// can subscribe + each sees the historic events through their own subscription
|
||||
// handle. Source node id is the first declared id (sub.SourceNodeIds[0]) when
|
||||
// present; empty subscriptions get a synthetic "alarm-history" id so the
|
||||
// event still threads through the IAlarmSource contract cleanly.
|
||||
var sourceNodeId = sub.SourceNodeIds.Count > 0 ? sub.SourceNodeIds[0] : "alarm-history";
|
||||
|
||||
_emit(new AlarmEventArgs(
|
||||
SubscriptionHandle: sub.Handle,
|
||||
SourceNodeId: sourceNodeId,
|
||||
ConditionId: $"focas-history#{entry.AlarmType}-{entry.AlarmNumber}-{entry.OccurrenceTime:O}",
|
||||
AlarmType: $"FOCAS_T{entry.AlarmType}",
|
||||
Message: BuildMessage(entry),
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: entry.OccurrenceTime.UtcDateTime));
|
||||
emitted++;
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
private async Task RunHistoryPollAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
// First poll fires immediately on subscribe (== "on connect" per F3-a) so operators
|
||||
// get history dashboard data without waiting for the cadence to elapse.
|
||||
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch { /* swallowed in PollOnceAsync; defensive double-catch */ }
|
||||
|
||||
var interval = _options.HistoryPollInterval > TimeSpan.Zero
|
||||
? _options.HistoryPollInterval
|
||||
: FocasAlarmProjectionOptions.DefaultHistoryPollInterval;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bound user-requested depth to <c>[1..MaxHistoryDepth]</c>. <c>0</c>/negative
|
||||
/// values fall back to <see cref="FocasAlarmProjectionOptions.DefaultHistoryDepth"/>
|
||||
/// so misconfigured options still pull a reasonable batch.
|
||||
/// </summary>
|
||||
internal static int ResolveDepth(int requested)
|
||||
{
|
||||
if (requested <= 0) return FocasAlarmProjectionOptions.DefaultHistoryDepth;
|
||||
return Math.Min(requested, FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
}
|
||||
|
||||
private static string BuildMessage(FocasAlarmHistoryEntry entry)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Message))
|
||||
return $"FOCAS alarm T{entry.AlarmType} #{entry.AlarmNumber}";
|
||||
return $"FOCAS T{entry.AlarmType} #{entry.AlarmNumber}: {entry.Message}";
|
||||
}
|
||||
|
||||
/// <summary>Composite dedup key — see class-level remarks.</summary>
|
||||
private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType);
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||
{
|
||||
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||
}
|
||||
public FocasAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||
public CancellationTokenSource Cts { get; }
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"{DiagnosticPrefix}-{Id}";
|
||||
}
|
||||
@@ -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() =>
|
||||
|
||||
@@ -18,6 +18,86 @@ public sealed class FocasDriverOptions
|
||||
/// decimal-place division applied to position values before publishing.
|
||||
/// </summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
|
||||
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
|
||||
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
|
||||
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
|
||||
/// not the moment the projection polled) flip this to
|
||||
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||
/// </summary>
|
||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
|
||||
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
|
||||
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
|
||||
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
|
||||
/// AlarmType)</c> so a polled entry never re-fires.
|
||||
/// </summary>
|
||||
public enum FocasAlarmProjectionMode
|
||||
{
|
||||
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
|
||||
ActiveOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect and on
|
||||
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
|
||||
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
|
||||
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
|
||||
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
|
||||
/// </summary>
|
||||
ActivePlusHistory = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
|
||||
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
|
||||
/// "operator dashboard with five-minute refresh" — the single most common deployment
|
||||
/// shape per the F3-a deployment doc.
|
||||
/// </summary>
|
||||
public sealed record FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
|
||||
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
|
||||
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
|
||||
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
|
||||
/// entries the dedup key would discard anyway.
|
||||
/// </summary>
|
||||
public const int DefaultHistoryDepth = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
|
||||
/// depths above this value down — typical CNC ring buffers cap well below this and
|
||||
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
|
||||
/// session with a giant <c>cnc_rdalmhistry</c> request.
|
||||
/// </summary>
|
||||
public const int MaxHistoryDepth = 250;
|
||||
|
||||
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
|
||||
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
|
||||
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
|
||||
/// the on-connect poll fires.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
|
||||
|
||||
/// <summary>
|
||||
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
|
||||
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
|
||||
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
|
||||
/// </summary>
|
||||
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -185,6 +185,20 @@ public interface IFocasClient : IDisposable
|
||||
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Read up to <paramref name="depth"/> most-recent entries from the CNC's alarm-history
|
||||
/// ring buffer via <c>cnc_rdalmhistry</c>. Used by <see cref="FocasAlarmProjection"/>
|
||||
/// when <see cref="FocasAlarmProjectionOptions.Mode"/> is
|
||||
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> (issue #267, plan PR F3-a).
|
||||
/// Default returns an empty list so transport variants that have not yet implemented
|
||||
/// the call keep working — the projection's history poll becomes a no-op rather than
|
||||
/// faulting. Wire decode of the FWLIB <c>ODBALMHIS</c> struct lives in
|
||||
/// <see cref="Wire.FocasAlarmHistoryDecoder"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
|
||||
int depth, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
|
||||
|
||||
/// <summary>
|
||||
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
|
||||
@@ -353,6 +367,30 @@ public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessag
|
||||
/// </summary>
|
||||
public sealed record FocasCurrentBlockInfo(string Text);
|
||||
|
||||
/// <summary>
|
||||
/// One entry returned by <c>cnc_rdalmhistry</c> — a single historical alarm
|
||||
/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a).
|
||||
/// The projection emits these as historic <see cref="Core.Abstractions.AlarmEventArgs"/>
|
||||
/// with <c>SourceTimestampUtc</c> set from <see cref="OccurrenceTime"/> so OPC UA clients
|
||||
/// see the real CNC timestamp rather than the moment the projection polled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The dedup key for the projection is
|
||||
/// <c>(<see cref="OccurrenceTime"/>, <see cref="AlarmNumber"/>, <see cref="AlarmType"/>)</c>.
|
||||
/// Same triple across two polls only emits once — see
|
||||
/// <see cref="FocasAlarmProjection"/>.</para>
|
||||
///
|
||||
/// <para>FANUC ring buffers are typically capped at ~100 entries; the host parameter that
|
||||
/// governs the cap varies by series + MTB so the driver clamps user-requested depth to a
|
||||
/// conservative <c>250</c> ceiling (see <see cref="FocasAlarmProjectionOptions.HistoryDepth"/>).</para>
|
||||
/// </remarks>
|
||||
public sealed record FocasAlarmHistoryEntry(
|
||||
DateTimeOffset OccurrenceTime,
|
||||
int AxisNo,
|
||||
int AlarmType,
|
||||
int AlarmNumber,
|
||||
string Message);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// FWLIB <c>ODBALMHIS</c> struct decoder for the <c>cnc_rdalmhistry</c> alarm-history
|
||||
/// extension (issue #267, plan PR F3-a). Documents + decodes the historical-alarm
|
||||
/// payload returned by FANUC controllers when asked for the most-recent N ring-buffer
|
||||
/// entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>ODBALMHIS layout (per FOCAS reference, abridged)</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>short num_alm</c> — number of valid alarm-history records that follow.
|
||||
/// Negative on CNC-reported error.</item>
|
||||
/// <item><c>ALMHIS_data alm[N]</c> — repeated entry record. Each record carries:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>short year, month, day, hour, minute, second</c> — wall-clock
|
||||
/// time the CNC stamped on the entry. Surfaced here as
|
||||
/// <see cref="DateTimeOffset"/> in UTC; the wire field is the CNC's
|
||||
/// local time, but the deployment doc instructs operators to keep their
|
||||
/// CNC clocks on UTC for the history projection so the dedup key stays
|
||||
/// stable across DST transitions.</item>
|
||||
/// <item><c>short axis_no</c> — axis the alarm relates to (1-based;
|
||||
/// 0 means "no specific axis").</item>
|
||||
/// <item><c>short alm_type</c> — alarm type (P/S/OT/SV/SR/MC/SP/PW/IO).
|
||||
/// The numeric encoding varies slightly per series; surfaced as-is so
|
||||
/// downstream consumers don't lose detail.</item>
|
||||
/// <item><c>short alm_no</c> — alarm number within the type.</item>
|
||||
/// <item><c>short msg_len</c> — length of the message string that follows.
|
||||
/// Capped server-side at 32 chars on most series.</item>
|
||||
/// <item><c>char msg[msg_len]</c> — message text. Trimmed of trailing
|
||||
/// nulls + spaces before publishing.</item>
|
||||
/// </list>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>The simulator-mock surface assigns command id <c>0x0F1A</c> to
|
||||
/// <c>cnc_rdalmhistry</c> — see <c>docs/v2/implementation/focas-simulator-plan.md</c>.</para>
|
||||
/// </remarks>
|
||||
public static class FocasAlarmHistoryDecoder
|
||||
{
|
||||
/// <summary>Wire-protocol command identifier the simulator routes <c>cnc_rdalmhistry</c> on.</summary>
|
||||
public const ushort CommandId = 0x0F1A;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a packed ODBALMHIS payload into a list of
|
||||
/// <see cref="FocasAlarmHistoryEntry"/> records ordered most-recent-first (the
|
||||
/// FANUC ring buffer's natural order). Returns an empty list when the buffer is
|
||||
/// too small to hold the count prefix or when the CNC reported zero entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Layout of <paramref name="payload"/> in little-endian wire form:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Bytes 0..1 — <c>short num_alm</c></item>
|
||||
/// <item>Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed
|
||||
/// header (<c>year, month, day, hour, minute, second, axis_no, alm_type,
|
||||
/// alm_no, msg_len</c> — 7×short with the seventh shared between
|
||||
/// <c>axis_no</c>+packing — laid out as 10 little-endian shorts here for
|
||||
/// simplicity), followed by <c>msg_len</c> ASCII bytes. The simulator pads
|
||||
/// each block to a 4-byte boundary; this decoder follows.</item>
|
||||
/// </list>
|
||||
/// <para>Real FWLIB hands back a Marshal-shaped struct, not a packed buffer; the
|
||||
/// packed-buffer convention here is purely for the simulator + IPC transport so
|
||||
/// the wire protocol stays language-neutral. Tier-C Fwlib32-backed clients
|
||||
/// short-circuit this decoder by surfacing the struct fields directly.</para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<FocasAlarmHistoryEntry> Decode(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 2) return Array.Empty<FocasAlarmHistoryEntry>();
|
||||
|
||||
var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]);
|
||||
if (count <= 0) return Array.Empty<FocasAlarmHistoryEntry>();
|
||||
|
||||
var entries = new List<FocasAlarmHistoryEntry>(count);
|
||||
var offset = 2;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
// Each entry: 10 little-endian shorts of header (20 bytes) + msg_len bytes.
|
||||
// Header layout: year, month, day, hour, minute, second, axis_no, alm_type,
|
||||
// alm_no, msg_len.
|
||||
const int headerBytes = 20;
|
||||
if (offset + headerBytes > payload.Length) break;
|
||||
|
||||
var header = payload.Slice(offset, headerBytes);
|
||||
var year = BinaryPrimitives.ReadInt16LittleEndian(header[0..2]);
|
||||
var month = BinaryPrimitives.ReadInt16LittleEndian(header[2..4]);
|
||||
var day = BinaryPrimitives.ReadInt16LittleEndian(header[4..6]);
|
||||
var hour = BinaryPrimitives.ReadInt16LittleEndian(header[6..8]);
|
||||
var minute = BinaryPrimitives.ReadInt16LittleEndian(header[8..10]);
|
||||
var second = BinaryPrimitives.ReadInt16LittleEndian(header[10..12]);
|
||||
var axisNo = BinaryPrimitives.ReadInt16LittleEndian(header[12..14]);
|
||||
var almType = BinaryPrimitives.ReadInt16LittleEndian(header[14..16]);
|
||||
var almNo = BinaryPrimitives.ReadInt16LittleEndian(header[16..18]);
|
||||
var msgLen = BinaryPrimitives.ReadInt16LittleEndian(header[18..20]);
|
||||
|
||||
offset += headerBytes;
|
||||
if (msgLen < 0 || offset + msgLen > payload.Length) break;
|
||||
|
||||
var msgBytes = payload.Slice(offset, msgLen);
|
||||
var msg = Encoding.ASCII.GetString(msgBytes).TrimEnd('\0', ' ');
|
||||
offset += msgLen;
|
||||
|
||||
// Pad to 4-byte boundary so per-entry blocks stay self-delimiting on the wire.
|
||||
var pad = (4 - (msgLen % 4)) % 4;
|
||||
offset += pad;
|
||||
|
||||
DateTimeOffset occurrence;
|
||||
try
|
||||
{
|
||||
occurrence = new DateTimeOffset(
|
||||
year, month, day, hour, minute, second, TimeSpan.Zero);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
// CNC reported a malformed timestamp — skip the entry rather than
|
||||
// exception-spew the entire history poll. The dedup key would be
|
||||
// unstable for malformed timestamps anyway.
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new FocasAlarmHistoryEntry(
|
||||
OccurrenceTime: occurrence,
|
||||
AxisNo: axisNo,
|
||||
AlarmType: almType,
|
||||
AlarmNumber: almNo,
|
||||
Message: msg));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="entries"/> into the wire format <see cref="Decode"/>
|
||||
/// consumes. Used by the simulator-mock + tests to build canned payloads without
|
||||
/// having to know the byte-level layout. Output is a fresh array; callers don't
|
||||
/// need to manage a pooled buffer.
|
||||
/// </summary>
|
||||
public static byte[] Encode(IReadOnlyList<FocasAlarmHistoryEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
// Pre-size: 2-byte count + 20-byte header + msg + pad per entry.
|
||||
var size = 2;
|
||||
foreach (var e in entries)
|
||||
{
|
||||
var msg = e.Message ?? string.Empty;
|
||||
var msgBytes = Encoding.ASCII.GetByteCount(msg);
|
||||
size += 20 + msgBytes + ((4 - (msgBytes % 4)) % 4);
|
||||
}
|
||||
|
||||
var buf = new byte[size];
|
||||
var span = buf.AsSpan();
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span[..2], (short)Math.Min(entries.Count, short.MaxValue));
|
||||
var offset = 2;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
var msg = e.Message ?? string.Empty;
|
||||
var t = e.OccurrenceTime.ToUniversalTime();
|
||||
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 0, 2), (short)t.Year);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 2, 2), (short)t.Month);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 4, 2), (short)t.Day);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 6, 2), (short)t.Hour);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 8, 2), (short)t.Minute);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 10, 2), (short)t.Second);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 12, 2), (short)e.AxisNo);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 14, 2), (short)e.AlarmType);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 16, 2), (short)e.AlarmNumber);
|
||||
var msgLen = Encoding.ASCII.GetByteCount(msg);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 18, 2), (short)msgLen);
|
||||
offset += 20;
|
||||
|
||||
Encoding.ASCII.GetBytes(msg, span.Slice(offset, msgLen));
|
||||
offset += msgLen;
|
||||
offset += (4 - (msgLen % 4)) % 4;
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user