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

View 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}";
}

View File

@@ -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() =>

View File

@@ -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>

View File

@@ -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
{

View File

@@ -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;
}
}