diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md index 3ecf97d..e229d82 100644 --- a/docs/drivers/FOCAS-Test-Fixture.md +++ b/docs/drivers/FOCAS-Test-Fixture.md @@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC. | "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) | | "Do macro variables round-trip across power cycles?" | no | yes (required) | +## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a + +`FocasAlarmProjection` ships two modes: + +- **`ActiveOnly`** (default) — surfaces only currently-active alarms. + No history poll. Same back-compat shape every prior FOCAS deployment used. +- **`ActivePlusHistory`** — additionally polls `cnc_rdalmhistry` on connect + + on the configured cadence (`HistoryPollInterval`, default 5 min). Each + unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from + the CNC's reported timestamp, not Now. + +Unit-test coverage in `FocasAlarmProjectionTests`: + +- mode `ActiveOnly` — no `ReadAlarmHistoryAsync` call ever issued +- mode `ActivePlusHistory` — first poll fires on subscribe (== "on connect") +- dedup — same `(OccurrenceTime, AlarmNumber, AlarmType)` triple across two + polls only emits once +- distinct entries with different timestamps each emit separately +- same alarm number / different type still emits both (type is part of the + dedup key) +- `OccurrenceTime` is the wire timestamp (round-trips a year-old stamp + without bleeding into Now) +- `HistoryDepth` clamp — user-supplied 500 collapses to 250 on the wire; + zero / negative falls back to the 100 default +- `FocasAlarmHistoryDecoder` — round-trips through `Encode` / `Decode` and + pins the simulator command id at `0x0F1A` + +Future integration coverage (not yet shipped — no FOCAS integration test +project exists): + +- a focas-mock with a per-profile ring buffer and `mock_patch_alarmhistory` + admin endpoint will let `cnc_rdalmhistry` round-trip end-to-end through + the wire protocol +- `FocasSimFixture.SeedAlarmHistoryAsync` will let series tests prime canned + history without per-test JSON + ## Follow-up candidates 1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md new file mode 100644 index 0000000..270ecd7 --- /dev/null +++ b/docs/drivers/FOCAS.md @@ -0,0 +1,55 @@ +# FOCAS driver + +Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i / +Power Mate i families. Talks to the controller via the licensed +`Fwlib32.dll` (Tier C, process-isolated per +[`docs/v2/driver-stability.md`](../v2/driver-stability.md)). + +For range-validation and per-series capability surface see +[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md). + +## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a + +`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`: + +| Mode | Behaviour | +| --- | --- | +| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. | +| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. | + +### Config knobs + +```jsonc +{ + "AlarmProjection": { + "Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory" + "HistoryPollInterval": "00:05:00", // default 5 min + "HistoryDepth": 100 // default 100, capped at 250 + } +} +``` + +### Dedup key + +`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two +polls only emits once. The dedup set is in-memory and **resets on +reconnect** — first poll after reconnect re-emits everything in the ring +buffer. OPC UA clients that need exactly-once semantics dedupe client-side +on the same triple (the timestamp + type + number tuple is stable across +the boundary). + +### `HistoryDepth` cap + +Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an +operator who types `10000` by accident can't blast the wire session with a +giant request. Typical FANUC ring buffers cap at ~100 entries; the default +`HistoryDepth = 100` matches the most common ring-buffer size. + +### Wire surface + +- Wire-protocol command id: `0x0F1A` (see + [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)). +- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`. +- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by + surfacing the FWLIB struct fields directly into + `FocasAlarmHistoryEntry`. diff --git a/docs/v2/focas-deployment.md b/docs/v2/focas-deployment.md new file mode 100644 index 0000000..217e33b --- /dev/null +++ b/docs/v2/focas-deployment.md @@ -0,0 +1,45 @@ +# FOCAS deployment guide + +Per-driver runbook for deploying the FANUC FOCAS driver. See +[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) for the per-feature +reference and [`focas-version-matrix.md`](./focas-version-matrix.md) for +the per-CNC-series capability surface. + +## Operator config-knob cheat sheet + +| Knob | Where | Default | Notes | +| --- | --- | --- | --- | +| `Devices[].HostAddress` | `FocasDriverOptions.Devices` | — | `focas://{ip}[:{port}]` | +| `Devices[].Series` | `FocasDriverOptions.Devices` | `Unknown` | Drives per-series range validation in `FocasCapabilityMatrix`. | +| `Devices[].OverrideParameters` | `FocasDriverOptions.Devices` | `null` | MTB-specific parameter numbers for Feed/Rapid/Spindle/Jog overrides. `null` suppresses the `Override/` subtree. | +| `Probe.Enabled` | `FocasDriverOptions.Probe` | `true` | Background reachability probe. | +| `Probe.Interval` | `FocasDriverOptions.Probe` | `00:00:05` | Probe cadence. | +| `FixedTree.ApplyFigureScaling` | `FocasDriverOptions.FixedTree` | `true` | Divide position values by 10^decimal-places (issue #262). | +| **`AlarmProjection.Mode`** | **`FocasDriverOptions.AlarmProjection`** | **`ActiveOnly`** | **`ActiveOnly` keeps today's behaviour. `ActivePlusHistory` polls `cnc_rdalmhistry` on connect + on `HistoryPollInterval` ticks (issue #267, plan PR F3-a).** | +| **`AlarmProjection.HistoryPollInterval`** | **`FocasDriverOptions.AlarmProjection`** | **`00:05:00`** | **Cadence of the history poll. Operator dashboards run the default; high-frequency rigs can drop to 30 s.** | +| **`AlarmProjection.HistoryDepth`** | **`FocasDriverOptions.AlarmProjection`** | **`100`** | **Most-recent-N ring-buffer entries pulled per poll. Hard-capped at `250` so misconfigured values can't blast the wire session.** | + +## Sample `appsettings.json` snippet for `ActivePlusHistory` + +```jsonc +{ + "Drivers": { + "FOCAS": { + "Devices": [ + { "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" } + ], + "AlarmProjection": { + "Mode": "ActivePlusHistory", + "HistoryPollInterval": "00:05:00", + "HistoryDepth": 100 + } + } + } +} +``` + +The history projection emits each unseen entry through +`IAlarmSource.OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's +reported wall-clock — keep CNC clocks on UTC so the dedup key +`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST +transitions. diff --git a/docs/v2/implementation/focas-simulator-plan.md b/docs/v2/implementation/focas-simulator-plan.md new file mode 100644 index 0000000..6d7d324 --- /dev/null +++ b/docs/v2/implementation/focas-simulator-plan.md @@ -0,0 +1,102 @@ +# FOCAS simulator (focas-mock) plan + +Notes on the focas-mock simulator that the FOCAS driver's integration +tests will eventually talk to. Today there is no FOCAS integration-test +project; this doc is the contract the future fixture will be built +against. Keeping the contract tracked in repo means the wire-protocol +command ids (and their request/response payloads) don't drift between the +.NET wire client and a future Python implementation. + +## Ground rules + +- Append-only command ids. Mirror + [`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim. +- Per-profile state. The simulator hosts N CNC profiles concurrently + (`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own + alarm-history ring buffer + its own override map. +- Admin endpoints under `POST /admin/...` mutate state without going + through the wire protocol; integration tests use these to seed canned + inputs. + +## Protocol surface (current scope) + +| Cmd | API | State impact | +| --- | --- | --- | +| `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile | +| `0x0002` | `cnc_rdparam` | reads parameter map per profile | +| `0x0003` | `cnc_rdmacro` | reads macro variables per profile | +| `0x0004` | `cnc_rddiag` | reads diagnostic map per profile | +| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges | +| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile | +| ... | ... | ... | +| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** | + +## `cnc_rdalmhistry` mock behaviour + +The simulator keeps a per-profile ring buffer of alarm-history entries. +Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a +plan). + +### Request decode + +``` +[int16 LE depth] +``` + +### Response encode + +Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the +count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The +.NET-side decoder consumes the same format verbatim, so a Python encoder +written against the table in +[`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without +extra glue. + +### Admin endpoint — `POST /admin/mock_patch_alarmhistory` + +Replaces the alarm-history ring buffer for a profile. + +``` +POST /admin/mock_patch_alarmhistory +{ + "profile": "Series30i", + "entries": [ + { + "occurrenceTime": "2025-04-01T09:30:00Z", + "axisNo": 1, + "alarmType": 2, + "alarmNumber": 100, + "message": "Spindle overload" + }, + ... + ] +} +``` + +`entries` order is interpreted as ring-buffer order (most-recent first to +match FANUC's natural surface). + +### `FocasSimFixture.SeedAlarmHistoryAsync` + +The future test-support helper wraps the admin endpoint: + +```csharp +await fixture.SeedAlarmHistoryAsync( + profile: "Series30i", + entries: new [] + { + new FocasAlarmHistoryEntry( + new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), + AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"), + }); +``` + +Integration test `Series/AlarmHistoryProjectionTests.cs` will assert: + +- historic events fire once with the seeded timestamps +- second poll yields zero new events (dedup honoured end-to-end) +- active-alarm raise/clear still works alongside the history poll + +These tests are blocked on the focas-mock + integration-test project +landing; the unit-test coverage in `FocasAlarmProjectionTests` already +exercises every same-process invariant. diff --git a/docs/v2/implementation/focas-wire-protocol.md b/docs/v2/implementation/focas-wire-protocol.md new file mode 100644 index 0000000..2451142 --- /dev/null +++ b/docs/v2/implementation/focas-wire-protocol.md @@ -0,0 +1,76 @@ +# FOCAS wire protocol — packed-buffer surface + +Notes on the language-neutral packed-buffer encoding the FOCAS driver + +focas-mock simulator share. This format is **not** the FWLIB native struct +layout — Tier-C Fwlib32 backends marshal directly from the FANUC C struct. +The packed surface exists so the simulator (Python / FastAPI) and the .NET +wire client can speak a common format over IPC without piping a Win32 DLL +through both ends. + +## Command id table + +Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are +**append-only** — never renumber, never reuse. + +| Id | FOCAS API | Surface | +| --- | --- | --- | +| `0x0001` | `cnc_rdcncstat` | ODBST 9-field status struct | +| `0x0002` | `cnc_rdparam` | parameter value (one number) | +| `0x0003` | `cnc_rdmacro` | macro variable value | +| `0x0004` | `cnc_rddiag` | diagnostic value | +| ... | ... | ... | +| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** | + +## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`) + +Issued by `FocasAlarmProjection` when +`FocasDriverOptions.AlarmProjection.Mode == ActivePlusHistory`. Returns up +to `depth` most-recent ring-buffer entries. + +### Request + +| Offset | Width | Field | Notes | +| --- | --- | --- | --- | +| 0 | `int16 LE` | `depth` | clamped client-side to `[1..250]` (`FocasAlarmProjectionOptions.MaxHistoryDepth`) | + +### Response (packed buffer, little-endian) + +| Offset | Width | Field | +| --- | --- | --- | +| 0 | `int16 LE` | `num_alm` — number of entries that follow. `< 0` indicates CNC error. | +| 2 | repeated | `ALMHIS_data alm[num_alm]` (see below) | + +Each entry block: + +| Offset (rel.) | Width | Field | +| --- | --- | --- | +| 0 | `int16 LE` | `year` | +| 2 | `int16 LE` | `month` | +| 4 | `int16 LE` | `day` | +| 6 | `int16 LE` | `hour` | +| 8 | `int16 LE` | `minute` | +| 10 | `int16 LE` | `second` | +| 12 | `int16 LE` | `axis_no` (1-based; 0 = whole-CNC) | +| 14 | `int16 LE` | `alm_type` (P/S/OT/SV/SR/MC/SP/PW/IO encoded numerically) | +| 16 | `int16 LE` | `alm_no` | +| 18 | `int16 LE` | `msg_len` (0..32 typical) | +| 20 | `msg_len` | ASCII message (no null terminator) | +| `20 + msg_len` | 0..3 | pad to 4-byte boundary so per-entry blocks stay self-delimiting | + +The CNC stamps `year..second` in **its own local time**. The deployment +guide instructs operators to keep CNC clocks on UTC so the projection's +dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across +DST transitions. The .NET decoder +(`Wire/FocasAlarmHistoryDecoder.Decode`) constructs each +`DateTimeOffset` with `TimeSpan.Zero` (UTC) on that assumption. + +### Error handling + +- A negative `num_alm` short-circuits decode to an empty list — the + projection treats it as "no history this tick" and the next poll + retries. +- Malformed timestamps (e.g. month=0) are skipped per-entry instead of + faulting the whole decode; the dedup key for malformed entries would be + unstable anyway. +- `msg_len` overrunning the payload truncates the entry list at the + malformed entry rather than throwing. diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs new file mode 100644 index 0000000..26776ab --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs @@ -0,0 +1,255 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface +/// via . Two modes: +/// +/// (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. +/// — additionally +/// polls cnc_rdalmhistry on connect and on every +/// tick. Each +/// previously-unseen entry fires an OnAlarmEvent with +/// SourceTimestampUtc set from the CNC's reported timestamp (not Now) +/// so OPC UA dashboards see the real occurrence time. +/// +/// +/// +/// Dedup — an in-memory keyed on +/// (OccurrenceTime, AlarmNumber, AlarmType) 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. +/// +/// HistoryDepth clamp — user-supplied depth is bounded to +/// [1..] so an operator +/// who types 10000 by accident doesn't blow up the wire session. The clamp lives +/// in . +/// +/// Active alarms — first cut surfaces history only. Active alarms (raise + +/// clear via cnc_rdalmmsg/cnc_rdalmmsg2) 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. +/// +internal sealed class FocasAlarmProjection : IAsyncDisposable +{ + private readonly Func> _connectAsync; + private readonly Action _emit; + private readonly FocasAlarmProjectionOptions _options; + private readonly string _diagnosticPrefix; + + private readonly Dictionary _subs = new(); + private readonly Lock _subsLock = new(); + private long _nextId; + + /// + /// 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). + /// + private readonly HashSet _seen = new(); + private readonly Lock _seenLock = new(); + + public FocasAlarmProjection( + FocasAlarmProjectionOptions options, + Func> connectAsync, + Action 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 SubscribeAsync( + IReadOnlyList 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(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(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(); + } + + /// + /// 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. + /// + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + => Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + List 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(); + } + } + + /// + /// 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. + /// + public void ResetDedup() + { + lock (_seenLock) _seen.Clear(); + } + + /// + /// 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. + /// + internal async Task PollOnceAsync(Subscription sub, CancellationToken ct) + { + var client = await _connectAsync(ct).ConfigureAwait(false); + if (client is null) return 0; + + var depth = ResolveDepth(_options.HistoryDepth); + IReadOnlyList 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 */ } + } + } + + /// + /// Bound user-requested depth to [1..MaxHistoryDepth]. 0/negative + /// values fall back to + /// so misconfigured options still pull a reasonable batch. + /// + 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}"; + } + + /// Composite dedup key — see class-level remarks. + private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType); + + internal sealed class Subscription + { + public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList sourceNodeIds, CancellationTokenSource cts) + { + Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts; + } + public FocasAlarmSubscriptionHandle Handle { get; } + public IReadOnlyList SourceNodeIds { get; } + public CancellationTokenSource Cts { get; } + public Task Loop { get; set; } = Task.CompletedTask; + } +} + +/// Handle returned by . +public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle +{ + public string DiagnosticId => $"{DiagnosticPrefix}-{Id}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 2efda87..ab28fbd 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -18,12 +18,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// fail fast. /// 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 _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _statusNodesByName = @@ -119,6 +120,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; + /// + /// Per — 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). + /// + public event EventHandler? 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}"); + } + + /// + /// Bridge for the alarm projection — returns the first device's connected + /// 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. + /// + private async Task 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) ---- + + /// + /// Subscribe to FOCAS alarm events. When + /// 's mode is + /// , the projection polls + /// cnc_rdalmhistry on connect + on the configured cadence and emits unseen + /// entries through with the CNC's reported timestamp. + /// (default) returns the handle + /// for capability negotiation but skips the history poll. The active-alarm poll + /// itself ships in a follow-up PR. + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + => _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken); + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + => _alarmProjection.UnsubscribeAsync(handle, cancellationToken); + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + => _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken); + + /// + /// 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). + /// + internal void ResetAlarmDedup() => _alarmProjection.ResetDedup(); + // ---- IHostConnectivityProbe ---- public IReadOnlyList GetHostStatuses() => diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index 838a7b5..6b6747f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -18,6 +18,86 @@ public sealed class FocasDriverOptions /// decimal-place division applied to position values before publishing. /// public FocasFixedTreeOptions FixedTree { get; init; } = new(); + + /// + /// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is + /// — 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 + /// . + /// + public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new(); +} + +/// +/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default +/// matches today's behaviour — only currently-active +/// alarms surface as OPC UA events. additionally +/// polls cnc_rdalmhistry on connect + on a configurable cadence and emits the +/// ring-buffer entries as historic events, deduped by (OccurrenceTime, AlarmNumber, +/// AlarmType) so a polled entry never re-fires. +/// +public enum FocasAlarmProjectionMode +{ + /// Surface only currently-active CNC alarms. No history poll. Default. + ActiveOnly = 0, + + /// + /// Surface active alarms plus the on-CNC ring-buffer history. The projection + /// polls cnc_rdalmhistry on connect and on + /// ticks afterward. + /// Each new entry (keyed by (OccurrenceTime, AlarmNumber, AlarmType)) + /// fires an with + /// SourceTimestampUtc set from the CNC's reported timestamp, not Now. + /// + ActivePlusHistory = 1, +} + +/// +/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch + +/// the cadence / depth tuning for the cnc_rdalmhistry poll loop. Defaults match +/// "operator dashboard with five-minute refresh" — the single most common deployment +/// shape per the F3-a deployment doc. +/// +public sealed record FocasAlarmProjectionOptions +{ + /// Default poll interval — 5 minutes. Matches dashboard-class cadences. + public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5); + + /// + /// Default ring-buffer depth requested per poll — 100. 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. + /// + public const int DefaultHistoryDepth = 100; + + /// + /// Hard ceiling on . The projection clamps user-requested + /// depths above this value down — typical CNC ring buffers cap well below this and + /// letting an operator type 10000 by accident shouldn't take down the wire + /// session with a giant cnc_rdalmhistry request. + /// + public const int MaxHistoryDepth = 250; + + /// Active-only (default) vs Active-plus-history. See . + public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly; + + /// + /// Cadence at which the projection re-polls cnc_rdalmhistry when + /// is . + /// Default = 5 minutes. Only applies after + /// the on-connect poll fires. + /// + public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval; + + /// + /// Number of most-recent ring-buffer entries to request per poll. Clamped to + /// [1..] at projection startup so misconfigured + /// values can't hammer the CNC. Default = 100. + /// + public int HistoryDepth { get; init; } = DefaultHistoryDepth; } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 7e5d986..4634c8a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -185,6 +185,20 @@ public interface IFocasClient : IDisposable Task SetPathAsync(int pathId, CancellationToken cancellationToken) => Task.CompletedTask; + /// + /// Read up to most-recent entries from the CNC's alarm-history + /// ring buffer via cnc_rdalmhistry. Used by + /// when is + /// (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 ODBALMHIS struct lives in + /// . + /// + Task> ReadAlarmHistoryAsync( + int depth, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + /// /// Read a contiguous range of PMC bytes in a single wire call (FOCAS /// pmc_rdpmcrng with byte data type) for the given @@ -353,6 +367,30 @@ public sealed record FocasOperatorMessagesInfo(IReadOnlyList public sealed record FocasCurrentBlockInfo(string Text); +/// +/// One entry returned by cnc_rdalmhistry — a single historical alarm +/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a). +/// The projection emits these as historic +/// with SourceTimestampUtc set from so OPC UA clients +/// see the real CNC timestamp rather than the moment the projection polled. +/// +/// +/// The dedup key for the projection is +/// (, , ). +/// Same triple across two polls only emits once — see +/// . +/// +/// 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 250 ceiling (see ). +/// +public sealed record FocasAlarmHistoryEntry( + DateTimeOffset OccurrenceTime, + int AxisNo, + int AlarmType, + int AlarmNumber, + string Message); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasAlarmHistoryDecoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasAlarmHistoryDecoder.cs new file mode 100644 index 0000000..d56d057 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasAlarmHistoryDecoder.cs @@ -0,0 +1,182 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +/// +/// FWLIB ODBALMHIS struct decoder for the cnc_rdalmhistry 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. +/// +/// +/// ODBALMHIS layout (per FOCAS reference, abridged): +/// +/// short num_alm — number of valid alarm-history records that follow. +/// Negative on CNC-reported error. +/// ALMHIS_data alm[N] — repeated entry record. Each record carries: +/// +/// short year, month, day, hour, minute, second — wall-clock +/// time the CNC stamped on the entry. Surfaced here as +/// 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. +/// short axis_no — axis the alarm relates to (1-based; +/// 0 means "no specific axis"). +/// short alm_type — 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. +/// short alm_no — alarm number within the type. +/// short msg_len — length of the message string that follows. +/// Capped server-side at 32 chars on most series. +/// char msg[msg_len] — message text. Trimmed of trailing +/// nulls + spaces before publishing. +/// +/// +/// +/// The simulator-mock surface assigns command id 0x0F1A to +/// cnc_rdalmhistry — see docs/v2/implementation/focas-simulator-plan.md. +/// +public static class FocasAlarmHistoryDecoder +{ + /// Wire-protocol command identifier the simulator routes cnc_rdalmhistry on. + public const ushort CommandId = 0x0F1A; + + /// + /// Decode a packed ODBALMHIS payload into a list of + /// 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. + /// + /// + /// Layout of in little-endian wire form: + /// + /// Bytes 0..1 — short num_alm + /// Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed + /// header (year, month, day, hour, minute, second, axis_no, alm_type, + /// alm_no, msg_len — 7×short with the seventh shared between + /// axis_no+packing — laid out as 10 little-endian shorts here for + /// simplicity), followed by msg_len ASCII bytes. The simulator pads + /// each block to a 4-byte boundary; this decoder follows. + /// + /// 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. + /// + public static IReadOnlyList Decode(ReadOnlySpan payload) + { + if (payload.Length < 2) return Array.Empty(); + + var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]); + if (count <= 0) return Array.Empty(); + + var entries = new List(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; + } + + /// + /// Encode into the wire format + /// 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. + /// + public static byte[] Encode(IReadOnlyList 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; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index 07fa8e5..7ff1622 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -107,6 +107,29 @@ internal class FakeFocasClient : IFocasClient return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good)); } + /// + /// Canned alarm-history payload returned to . + /// Defaults to empty so tests that don't care about history get the back-compat + /// no-op behaviour. Tests asserting cnc_rdalmhistry behaviour seed entries + /// here (issue #267, plan PR F3-a). + /// + public List AlarmHistory { get; } = new(); + + /// + /// Ordered log of cnc_rdalmhistry-shaped calls observed on this fake session + /// (depth-per-call). Tests assert this length to verify the projection's poll + /// cadence + that HistoryDepth got clamped to the wire correctly. + /// + public List AlarmHistoryReadLog { get; } = new(); + + public virtual Task> ReadAlarmHistoryAsync( + int depth, CancellationToken ct) + { + AlarmHistoryReadLog.Add(depth); + IReadOnlyList snap = AlarmHistory.ToList(); + return Task.FromResult(snap); + } + public virtual void Dispose() { DisposeCount++; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs new file mode 100644 index 0000000..0281ab5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs @@ -0,0 +1,296 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Issue #267 (plan PR F3-a) — coverage for the cnc_rdalmhistry alarm-history +/// extension. Asserts mode switch, dedup, timestamp passthrough, depth clamp, and +/// the back-compat ActiveOnly path. +/// +[Trait("Category", "Unit")] +public sealed class FocasAlarmProjectionTests +{ + private const string Device = "focas://10.0.0.5:8193"; + + private static FocasAlarmHistoryEntry Entry( + DateTimeOffset when, int alarmNumber, int alarmType = 1, string msg = "Spindle overload") + => new(when, AxisNo: 1, AlarmType: alarmType, AlarmNumber: alarmNumber, Message: msg); + + // ---- Mode switch ------------------------------------------------------- + + [Fact] + public async Task ActiveOnly_Mode_Does_Not_Issue_History_Poll() + { + var factory = new FakeFocasClientFactory(); + var fake = new FakeFocasClient + { + AlarmHistory = { Entry(DateTimeOffset.UtcNow, 100) }, + }; + factory.Customise = () => fake; + + var opts = new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Device)], + Probe = new FocasProbeOptions { Enabled = false }, + AlarmProjection = new FocasAlarmProjectionOptions { Mode = FocasAlarmProjectionMode.ActiveOnly }, + }; + var drv = new FocasDriver(opts, "drv-active-only", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var emitted = new List(); + drv.OnAlarmEvent += (_, args) => emitted.Add(args); + + var handle = await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + handle.ShouldNotBeNull(); + handle.DiagnosticId.ShouldStartWith("focas-alarm-drv-active-only-"); + + // Give the projection a moment — if it were polling, the fake's log would tick. + await Task.Delay(150); + fake.AlarmHistoryReadLog.ShouldBeEmpty(); + emitted.ShouldBeEmpty(); + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task ActivePlusHistory_Mode_Polls_On_Connect_And_Emits_Entries() + { + var fake = new FakeFocasClient(); + fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100)); + fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 31, 0, TimeSpan.Zero), 200, alarmType: 2)); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5)); + var drv = new FocasDriver(opts, "drv-history", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var emitted = new List(); + drv.OnAlarmEvent += (_, args) => emitted.Add(args); + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => emitted.Count >= 2); + + emitted.Count.ShouldBe(2); + emitted[0].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 30, 0, DateTimeKind.Utc)); + emitted[1].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 31, 0, DateTimeKind.Utc)); + fake.AlarmHistoryReadLog.Count.ShouldBeGreaterThanOrEqualTo(1); + fake.AlarmHistoryReadLog[0].ShouldBe(50); // declared depth threaded through + + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- Dedup ------------------------------------------------------------- + + [Fact] + public async Task Same_Entry_Across_Two_Polls_Is_Emitted_Once() + { + var fake = new FakeFocasClient(); + var sameEntry = Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100); + fake.AlarmHistory.Add(sameEntry); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50)); + var drv = new FocasDriver(opts, "drv-dedup", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var emitted = new List(); + drv.OnAlarmEvent += (_, args) => emitted.Add(args); + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 3); + + emitted.Count.ShouldBe(1); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Distinct_Entries_With_Different_Timestamps_Each_Emit_Once() + { + var fake = new FakeFocasClient(); + // Tick 1 yields entry A. + fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 100)); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50)); + var drv = new FocasDriver(opts, "drv-distinct", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var emitted = new List(); + drv.OnAlarmEvent += (_, args) => emitted.Add(args); + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => emitted.Count >= 1); + + // Now add a second entry at a different timestamp + wait for the next tick. + fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 1, 0, TimeSpan.Zero), 200)); + await WaitForAsync(() => emitted.Count >= 2); + + emitted.Count.ShouldBe(2); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Same_AlarmNumber_With_Different_Type_Both_Emit() + { + // The dedup key includes type — alarm #100 type=1 and alarm #100 type=2 are distinct. + var fake = new FakeFocasClient(); + var ts = new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero); + fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 1)); + fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 2)); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50)); + var drv = new FocasDriver(opts, "drv-type-key", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var emitted = new List(); + drv.OnAlarmEvent += (_, args) => emitted.Add(args); + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => emitted.Count >= 2); + + emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T1"); + emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T2"); + + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- Timestamp passthrough -------------------------------------------- + + [Fact] + public async Task OccurrenceTime_Is_The_Wire_Timestamp_Not_Now() + { + var fake = new FakeFocasClient(); + var oldStamp = new DateTimeOffset(2024, 1, 15, 8, 5, 30, TimeSpan.Zero); + fake.AlarmHistory.Add(Entry(oldStamp, 100)); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5)); + var drv = new FocasDriver(opts, "drv-ts", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + AlarmEventArgs? captured = null; + drv.OnAlarmEvent += (_, args) => captured = args; + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => captured is not null); + + captured!.SourceTimestampUtc.ShouldBe(oldStamp.UtcDateTime); + // Sanity — must not be "Now" (more than a year stale). + (DateTime.UtcNow - captured.SourceTimestampUtc).ShouldBeGreaterThan(TimeSpan.FromDays(180)); + + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- HistoryDepth clamp ----------------------------------------------- + + [Fact] + public void ResolveDepth_Clamps_To_MaxHistoryDepth() + { + FocasAlarmProjection.ResolveDepth(500).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth); + FocasAlarmProjection.ResolveDepth(10_000).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth); + } + + [Fact] + public void ResolveDepth_Falls_Back_To_Default_When_NonPositive() + { + FocasAlarmProjection.ResolveDepth(0).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth); + FocasAlarmProjection.ResolveDepth(-1).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth); + } + + [Fact] + public void ResolveDepth_Returns_User_Value_When_Within_Bounds() + { + FocasAlarmProjection.ResolveDepth(1).ShouldBe(1); + FocasAlarmProjection.ResolveDepth(50).ShouldBe(50); + FocasAlarmProjection.ResolveDepth(FocasAlarmProjectionOptions.MaxHistoryDepth) + .ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth); + } + + [Fact] + public async Task User_Depth_500_Clamps_To_250_On_The_Wire() + { + var fake = new FakeFocasClient(); + var factory = new FakeFocasClientFactory { Customise = () => fake }; + + var opts = OptionsWithHistory(historyDepth: 500, interval: TimeSpan.FromMinutes(5)); + var drv = new FocasDriver(opts, "drv-clamp", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None); + await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 1); + + fake.AlarmHistoryReadLog[0].ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth); + + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- Decoder + encoder round-trip ------------------------------------- + + [Fact] + public void AlarmHistoryDecoder_RoundTrips_Through_Encode_Decode() + { + var src = new List + { + new(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 1, 2, 100, "Spindle"), + new(new DateTimeOffset(2025, 4, 1, 9, 5, 30, TimeSpan.Zero), 0, 4, 506, "OT axis Z"), + new(new DateTimeOffset(2025, 4, 1, 9, 6, 0, TimeSpan.Zero), 2, 1, 7, ""), + }; + var bytes = FocasAlarmHistoryDecoder.Encode(src); + var decoded = FocasAlarmHistoryDecoder.Decode(bytes); + + decoded.Count.ShouldBe(src.Count); + for (var i = 0; i < src.Count; i++) + { + decoded[i].OccurrenceTime.ShouldBe(src[i].OccurrenceTime); + decoded[i].AxisNo.ShouldBe(src[i].AxisNo); + decoded[i].AlarmType.ShouldBe(src[i].AlarmType); + decoded[i].AlarmNumber.ShouldBe(src[i].AlarmNumber); + decoded[i].Message.ShouldBe(src[i].Message); + } + } + + [Fact] + public void AlarmHistoryDecoder_Empty_Buffer_Yields_Empty_List() + { + FocasAlarmHistoryDecoder.Decode(ReadOnlySpan.Empty).Count.ShouldBe(0); + } + + [Fact] + public void AlarmHistoryDecoder_Has_Stable_CommandId() + { + // Don't accidentally renumber — the simulator + Tier-C backend pin on this id. + FocasAlarmHistoryDecoder.CommandId.ShouldBe(0x0F1A); + } + + // ---- Helpers ---------------------------------------------------------- + + private static FocasDriverOptions OptionsWithHistory(int historyDepth, TimeSpan interval) => new() + { + Devices = [new FocasDeviceOptions(Device)], + Probe = new FocasProbeOptions { Enabled = false }, + AlarmProjection = new FocasAlarmProjectionOptions + { + Mode = FocasAlarmProjectionMode.ActivePlusHistory, + HistoryDepth = historyDepth, + HistoryPollInterval = interval, + }, + }; + + private static async Task WaitForAsync(Func condition, int timeoutMs = 5000) + { + var deadline = Environment.TickCount + timeoutMs; + while (Environment.TickCount < deadline) + { + if (condition()) return; + await Task.Delay(20); + } + condition().ShouldBeTrue("Condition not satisfied within timeout"); + } +}