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");
+ }
+}