Merge pull request '[focas] FOCAS — cnc_rdalmhistry alarm-history extension' (#372) from auto/focas/F3-a into auto/driver-gaps

This commit was merged in pull request #372.
This commit is contained in:
2026-04-26 00:10:36 -04:00
12 changed files with 1248 additions and 1 deletions

View File

@@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC.
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) | | "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
| "Do macro variables round-trip across power cycles?" | no | yes (required) | | "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 ## Follow-up candidates
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL 1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL

55
docs/drivers/FOCAS.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,255 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface
/// via <see cref="IAlarmSource"/>. Two modes:
/// <list type="bullet">
/// <item><see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) — only
/// currently-active alarms surface. Subscribe / unsubscribe / acknowledge wire up,
/// but no history poll runs. This is the conservative mode operators get when
/// they don't explicitly opt into history.</item>
/// <item><see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> — additionally
/// polls <c>cnc_rdalmhistry</c> on connect and on every
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> tick. Each
/// previously-unseen entry fires an <c>OnAlarmEvent</c> with
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp (not Now)
/// so OPC UA dashboards see the real occurrence time.</item>
/// </list>
/// </summary>
/// <remarks>
/// <para><b>Dedup</b> — an in-memory <see cref="HashSet{T}"/> keyed on
/// <c>(OccurrenceTime, AlarmNumber, AlarmType)</c> tracks every entry the projection has
/// emitted. The same triple across two polls only emits once. The set resets on reconnect
/// — first poll after reconnect re-emits everything in the ring buffer; OPC UA clients
/// that care about exactly-once semantics dedupe on their side via the
/// timestamp + number + type tuple.</para>
///
/// <para><b>HistoryDepth clamp</b> — user-supplied depth is bounded to
/// <c>[1..<see cref="FocasAlarmProjectionOptions.MaxHistoryDepth"/>]</c> so an operator
/// who types <c>10000</c> by accident doesn't blow up the wire session. The clamp lives
/// in <see cref="ResolveDepth"/>.</para>
///
/// <para><b>Active alarms</b> — first cut surfaces history only. Active alarms (raise +
/// clear via <c>cnc_rdalmmsg</c>/<c>cnc_rdalmmsg2</c>) are a follow-up; this projection's
/// subscribe path returns a handle but does not poll for active alarms today. The
/// ActiveOnly mode therefore is functionally a no-op subscribe — the IAlarmSource
/// contract still wires up so capability negotiation works + a future PR can add the
/// active-alarm poll without reshaping the projection. The plan deliberately scopes F3-a
/// to the history extension; the active poll lands as F3-b.</para>
/// </remarks>
internal sealed class FocasAlarmProjection : IAsyncDisposable
{
private readonly Func<CancellationToken, Task<IFocasClient?>> _connectAsync;
private readonly Action<AlarmEventArgs> _emit;
private readonly FocasAlarmProjectionOptions _options;
private readonly string _diagnosticPrefix;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
/// <summary>
/// Dedup set across the entire projection — alarm history is per-CNC, not
/// per-subscription, so a single set across all subscriptions matches operator
/// intent (one CNC, one ring buffer, one set of history events even if multiple
/// OPC UA clients have subscribed).
/// </summary>
private readonly HashSet<DedupKey> _seen = new();
private readonly Lock _seenLock = new();
public FocasAlarmProjection(
FocasAlarmProjectionOptions options,
Func<CancellationToken, Task<IFocasClient?>> connectAsync,
Action<AlarmEventArgs> emit,
string diagnosticPrefix = "focas-alarm-sub")
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(connectAsync);
ArgumentNullException.ThrowIfNull(emit);
_options = options;
_connectAsync = connectAsync;
_emit = emit;
_diagnosticPrefix = diagnosticPrefix;
}
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextId);
var handle = new FocasAlarmSubscriptionHandle(id, _diagnosticPrefix);
if (_options.Mode != FocasAlarmProjectionMode.ActivePlusHistory)
{
// ActiveOnly — return the handle so capability negotiation works, but skip the
// history poll entirely. The active-alarm poll lands as a follow-up PR.
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
var cts = new CancellationTokenSource();
var sub = new Subscription(handle, [..sourceNodeIds], cts);
lock (_subsLock) _subs[id] = sub;
sub.Loop = Task.Run(() => RunHistoryPollAsync(sub, cts.Token), cts.Token);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not FocasAlarmSubscriptionHandle h) return;
Subscription? sub;
lock (_subsLock)
{
if (!_subs.Remove(h.Id, out sub)) return;
}
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
/// <summary>
/// Acknowledge stub — FANUC's history surface is read-only (the ring buffer only
/// records what the CNC has cleared internally), so per-history-entry ack is a no-op.
/// A future PR may extend the active-alarm flow with a per-CNC reset call.
/// </summary>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
=> Task.CompletedTask;
public async ValueTask DisposeAsync()
{
List<Subscription> snap;
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
foreach (var sub in snap)
{
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
}
/// <summary>
/// Reset the dedup set — used after reconnect so the next history poll re-emits
/// everything in the ring buffer. Public for tests + the driver's reconnect hook.
/// </summary>
public void ResetDedup()
{
lock (_seenLock) _seen.Clear();
}
/// <summary>
/// Pull one history snapshot + emit unseen entries. Extracted from the timer loop so
/// unit tests can drive a single tick without standing up Task.Run.
/// </summary>
internal async Task<int> PollOnceAsync(Subscription sub, CancellationToken ct)
{
var client = await _connectAsync(ct).ConfigureAwait(false);
if (client is null) return 0;
var depth = ResolveDepth(_options.HistoryDepth);
IReadOnlyList<FocasAlarmHistoryEntry> entries;
try
{
entries = await client.ReadAlarmHistoryAsync(depth, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Per-tick failure — leave dedup intact, next tick retries. Matches the
// AbCip alarm projection's "non-fatal per-tick" pattern (#177).
return 0;
}
var emitted = 0;
foreach (var entry in entries)
{
var key = new DedupKey(entry.OccurrenceTime, entry.AlarmNumber, entry.AlarmType);
bool added;
lock (_seenLock) added = _seen.Add(key);
if (!added) continue;
// Each subscription gets its own copy of the event — multiple OPC UA clients
// can subscribe + each sees the historic events through their own subscription
// handle. Source node id is the first declared id (sub.SourceNodeIds[0]) when
// present; empty subscriptions get a synthetic "alarm-history" id so the
// event still threads through the IAlarmSource contract cleanly.
var sourceNodeId = sub.SourceNodeIds.Count > 0 ? sub.SourceNodeIds[0] : "alarm-history";
_emit(new AlarmEventArgs(
SubscriptionHandle: sub.Handle,
SourceNodeId: sourceNodeId,
ConditionId: $"focas-history#{entry.AlarmType}-{entry.AlarmNumber}-{entry.OccurrenceTime:O}",
AlarmType: $"FOCAS_T{entry.AlarmType}",
Message: BuildMessage(entry),
Severity: AlarmSeverity.High,
SourceTimestampUtc: entry.OccurrenceTime.UtcDateTime));
emitted++;
}
return emitted;
}
private async Task RunHistoryPollAsync(Subscription sub, CancellationToken ct)
{
// First poll fires immediately on subscribe (== "on connect" per F3-a) so operators
// get history dashboard data without waiting for the cadence to elapse.
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* swallowed in PollOnceAsync; defensive double-catch */ }
var interval = _options.HistoryPollInterval > TimeSpan.Zero
? _options.HistoryPollInterval
: FocasAlarmProjectionOptions.DefaultHistoryPollInterval;
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal */ }
}
}
/// <summary>
/// Bound user-requested depth to <c>[1..MaxHistoryDepth]</c>. <c>0</c>/negative
/// values fall back to <see cref="FocasAlarmProjectionOptions.DefaultHistoryDepth"/>
/// so misconfigured options still pull a reasonable batch.
/// </summary>
internal static int ResolveDepth(int requested)
{
if (requested <= 0) return FocasAlarmProjectionOptions.DefaultHistoryDepth;
return Math.Min(requested, FocasAlarmProjectionOptions.MaxHistoryDepth);
}
private static string BuildMessage(FocasAlarmHistoryEntry entry)
{
if (string.IsNullOrEmpty(entry.Message))
return $"FOCAS alarm T{entry.AlarmType} #{entry.AlarmNumber}";
return $"FOCAS T{entry.AlarmType} #{entry.AlarmNumber}: {entry.Message}";
}
/// <summary>Composite dedup key — see class-level remarks.</summary>
private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType);
internal sealed class Subscription
{
public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
{
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
}
public FocasAlarmSubscriptionHandle Handle { get; }
public IReadOnlyList<string> SourceNodeIds { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; set; } = Task.CompletedTask;
}
}
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle
{
public string DiagnosticId => $"{DiagnosticPrefix}-{Id}";
}

View File

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

View File

@@ -18,6 +18,86 @@ public sealed class FocasDriverOptions
/// decimal-place division applied to position values before publishing. /// decimal-place division applied to position values before publishing.
/// </summary> /// </summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new(); public FocasFixedTreeOptions FixedTree { get; init; } = new();
/// <summary>
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
/// not the moment the projection polled) flip this to
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
/// </summary>
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
}
/// <summary>
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
/// AlarmType)</c> so a polled entry never re-fires.
/// </summary>
public enum FocasAlarmProjectionMode
{
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
ActiveOnly = 0,
/// <summary>
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
/// polls <c>cnc_rdalmhistry</c> on connect and on
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
/// </summary>
ActivePlusHistory = 1,
}
/// <summary>
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
/// "operator dashboard with five-minute refresh" — the single most common deployment
/// shape per the F3-a deployment doc.
/// </summary>
public sealed record FocasAlarmProjectionOptions
{
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
/// <summary>
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
/// entries the dedup key would discard anyway.
/// </summary>
public const int DefaultHistoryDepth = 100;
/// <summary>
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
/// depths above this value down — typical CNC ring buffers cap well below this and
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
/// session with a giant <c>cnc_rdalmhistry</c> request.
/// </summary>
public const int MaxHistoryDepth = 250;
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
/// <summary>
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
/// the on-connect poll fires.
/// </summary>
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
/// <summary>
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
/// </summary>
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
} }
/// <summary> /// <summary>

View File

@@ -185,6 +185,20 @@ public interface IFocasClient : IDisposable
Task SetPathAsync(int pathId, CancellationToken cancellationToken) Task SetPathAsync(int pathId, CancellationToken cancellationToken)
=> Task.CompletedTask; => Task.CompletedTask;
/// <summary>
/// Read up to <paramref name="depth"/> most-recent entries from the CNC's alarm-history
/// ring buffer via <c>cnc_rdalmhistry</c>. Used by <see cref="FocasAlarmProjection"/>
/// when <see cref="FocasAlarmProjectionOptions.Mode"/> is
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> (issue #267, plan PR F3-a).
/// Default returns an empty list so transport variants that have not yet implemented
/// the call keep working — the projection's history poll becomes a no-op rather than
/// faulting. Wire decode of the FWLIB <c>ODBALMHIS</c> struct lives in
/// <see cref="Wire.FocasAlarmHistoryDecoder"/>.
/// </summary>
Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
int depth, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
/// <summary> /// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS /// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/> /// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
@@ -353,6 +367,30 @@ public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessag
/// </summary> /// </summary>
public sealed record FocasCurrentBlockInfo(string Text); public sealed record FocasCurrentBlockInfo(string Text);
/// <summary>
/// One entry returned by <c>cnc_rdalmhistry</c> — a single historical alarm
/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a).
/// The projection emits these as historic <see cref="Core.Abstractions.AlarmEventArgs"/>
/// with <c>SourceTimestampUtc</c> set from <see cref="OccurrenceTime"/> so OPC UA clients
/// see the real CNC timestamp rather than the moment the projection polled.
/// </summary>
/// <remarks>
/// <para>The dedup key for the projection is
/// <c>(<see cref="OccurrenceTime"/>, <see cref="AlarmNumber"/>, <see cref="AlarmType"/>)</c>.
/// Same triple across two polls only emits once — see
/// <see cref="FocasAlarmProjection"/>.</para>
///
/// <para>FANUC ring buffers are typically capped at ~100 entries; the host parameter that
/// governs the cap varies by series + MTB so the driver clamps user-requested depth to a
/// conservative <c>250</c> ceiling (see <see cref="FocasAlarmProjectionOptions.HistoryDepth"/>).</para>
/// </remarks>
public sealed record FocasAlarmHistoryEntry(
DateTimeOffset OccurrenceTime,
int AxisNo,
int AlarmType,
int AlarmNumber,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary> /// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory public interface IFocasClientFactory
{ {

View File

@@ -0,0 +1,182 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// FWLIB <c>ODBALMHIS</c> struct decoder for the <c>cnc_rdalmhistry</c> alarm-history
/// extension (issue #267, plan PR F3-a). Documents + decodes the historical-alarm
/// payload returned by FANUC controllers when asked for the most-recent N ring-buffer
/// entries.
/// </summary>
/// <remarks>
/// <para><b>ODBALMHIS layout (per FOCAS reference, abridged)</b>:</para>
/// <list type="bullet">
/// <item><c>short num_alm</c> — number of valid alarm-history records that follow.
/// Negative on CNC-reported error.</item>
/// <item><c>ALMHIS_data alm[N]</c> — repeated entry record. Each record carries:
/// <list type="bullet">
/// <item><c>short year, month, day, hour, minute, second</c> — wall-clock
/// time the CNC stamped on the entry. Surfaced here as
/// <see cref="DateTimeOffset"/> in UTC; the wire field is the CNC's
/// local time, but the deployment doc instructs operators to keep their
/// CNC clocks on UTC for the history projection so the dedup key stays
/// stable across DST transitions.</item>
/// <item><c>short axis_no</c> — axis the alarm relates to (1-based;
/// 0 means "no specific axis").</item>
/// <item><c>short alm_type</c> — alarm type (P/S/OT/SV/SR/MC/SP/PW/IO).
/// The numeric encoding varies slightly per series; surfaced as-is so
/// downstream consumers don't lose detail.</item>
/// <item><c>short alm_no</c> — alarm number within the type.</item>
/// <item><c>short msg_len</c> — length of the message string that follows.
/// Capped server-side at 32 chars on most series.</item>
/// <item><c>char msg[msg_len]</c> — message text. Trimmed of trailing
/// nulls + spaces before publishing.</item>
/// </list>
/// </item>
/// </list>
/// <para>The simulator-mock surface assigns command id <c>0x0F1A</c> to
/// <c>cnc_rdalmhistry</c> — see <c>docs/v2/implementation/focas-simulator-plan.md</c>.</para>
/// </remarks>
public static class FocasAlarmHistoryDecoder
{
/// <summary>Wire-protocol command identifier the simulator routes <c>cnc_rdalmhistry</c> on.</summary>
public const ushort CommandId = 0x0F1A;
/// <summary>
/// Decode a packed ODBALMHIS payload into a list of
/// <see cref="FocasAlarmHistoryEntry"/> records ordered most-recent-first (the
/// FANUC ring buffer's natural order). Returns an empty list when the buffer is
/// too small to hold the count prefix or when the CNC reported zero entries.
/// </summary>
/// <remarks>
/// <para>Layout of <paramref name="payload"/> in little-endian wire form:</para>
/// <list type="number">
/// <item>Bytes 0..1 — <c>short num_alm</c></item>
/// <item>Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed
/// header (<c>year, month, day, hour, minute, second, axis_no, alm_type,
/// alm_no, msg_len</c> — 7×short with the seventh shared between
/// <c>axis_no</c>+packing — laid out as 10 little-endian shorts here for
/// simplicity), followed by <c>msg_len</c> ASCII bytes. The simulator pads
/// each block to a 4-byte boundary; this decoder follows.</item>
/// </list>
/// <para>Real FWLIB hands back a Marshal-shaped struct, not a packed buffer; the
/// packed-buffer convention here is purely for the simulator + IPC transport so
/// the wire protocol stays language-neutral. Tier-C Fwlib32-backed clients
/// short-circuit this decoder by surfacing the struct fields directly.</para>
/// </remarks>
public static IReadOnlyList<FocasAlarmHistoryEntry> Decode(ReadOnlySpan<byte> payload)
{
if (payload.Length < 2) return Array.Empty<FocasAlarmHistoryEntry>();
var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]);
if (count <= 0) return Array.Empty<FocasAlarmHistoryEntry>();
var entries = new List<FocasAlarmHistoryEntry>(count);
var offset = 2;
for (var i = 0; i < count; i++)
{
// Each entry: 10 little-endian shorts of header (20 bytes) + msg_len bytes.
// Header layout: year, month, day, hour, minute, second, axis_no, alm_type,
// alm_no, msg_len.
const int headerBytes = 20;
if (offset + headerBytes > payload.Length) break;
var header = payload.Slice(offset, headerBytes);
var year = BinaryPrimitives.ReadInt16LittleEndian(header[0..2]);
var month = BinaryPrimitives.ReadInt16LittleEndian(header[2..4]);
var day = BinaryPrimitives.ReadInt16LittleEndian(header[4..6]);
var hour = BinaryPrimitives.ReadInt16LittleEndian(header[6..8]);
var minute = BinaryPrimitives.ReadInt16LittleEndian(header[8..10]);
var second = BinaryPrimitives.ReadInt16LittleEndian(header[10..12]);
var axisNo = BinaryPrimitives.ReadInt16LittleEndian(header[12..14]);
var almType = BinaryPrimitives.ReadInt16LittleEndian(header[14..16]);
var almNo = BinaryPrimitives.ReadInt16LittleEndian(header[16..18]);
var msgLen = BinaryPrimitives.ReadInt16LittleEndian(header[18..20]);
offset += headerBytes;
if (msgLen < 0 || offset + msgLen > payload.Length) break;
var msgBytes = payload.Slice(offset, msgLen);
var msg = Encoding.ASCII.GetString(msgBytes).TrimEnd('\0', ' ');
offset += msgLen;
// Pad to 4-byte boundary so per-entry blocks stay self-delimiting on the wire.
var pad = (4 - (msgLen % 4)) % 4;
offset += pad;
DateTimeOffset occurrence;
try
{
occurrence = new DateTimeOffset(
year, month, day, hour, minute, second, TimeSpan.Zero);
}
catch (ArgumentOutOfRangeException)
{
// CNC reported a malformed timestamp — skip the entry rather than
// exception-spew the entire history poll. The dedup key would be
// unstable for malformed timestamps anyway.
continue;
}
entries.Add(new FocasAlarmHistoryEntry(
OccurrenceTime: occurrence,
AxisNo: axisNo,
AlarmType: almType,
AlarmNumber: almNo,
Message: msg));
}
return entries;
}
/// <summary>
/// Encode <paramref name="entries"/> into the wire format <see cref="Decode"/>
/// consumes. Used by the simulator-mock + tests to build canned payloads without
/// having to know the byte-level layout. Output is a fresh array; callers don't
/// need to manage a pooled buffer.
/// </summary>
public static byte[] Encode(IReadOnlyList<FocasAlarmHistoryEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
// Pre-size: 2-byte count + 20-byte header + msg + pad per entry.
var size = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var msgBytes = Encoding.ASCII.GetByteCount(msg);
size += 20 + msgBytes + ((4 - (msgBytes % 4)) % 4);
}
var buf = new byte[size];
var span = buf.AsSpan();
BinaryPrimitives.WriteInt16LittleEndian(span[..2], (short)Math.Min(entries.Count, short.MaxValue));
var offset = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var t = e.OccurrenceTime.ToUniversalTime();
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 0, 2), (short)t.Year);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 2, 2), (short)t.Month);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 4, 2), (short)t.Day);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 6, 2), (short)t.Hour);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 8, 2), (short)t.Minute);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 10, 2), (short)t.Second);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 12, 2), (short)e.AxisNo);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 14, 2), (short)e.AlarmType);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 16, 2), (short)e.AlarmNumber);
var msgLen = Encoding.ASCII.GetByteCount(msg);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 18, 2), (short)msgLen);
offset += 20;
Encoding.ASCII.GetBytes(msg, span.Slice(offset, msgLen));
offset += msgLen;
offset += (4 - (msgLen % 4)) % 4;
}
return buf;
}
}

View File

@@ -107,6 +107,29 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good)); return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
} }
/// <summary>
/// Canned alarm-history payload returned to <see cref="ReadAlarmHistoryAsync"/>.
/// Defaults to empty so tests that don't care about history get the back-compat
/// no-op behaviour. Tests asserting <c>cnc_rdalmhistry</c> behaviour seed entries
/// here (issue #267, plan PR F3-a).
/// </summary>
public List<FocasAlarmHistoryEntry> AlarmHistory { get; } = new();
/// <summary>
/// Ordered log of <c>cnc_rdalmhistry</c>-shaped calls observed on this fake session
/// (depth-per-call). Tests assert this length to verify the projection's poll
/// cadence + that <c>HistoryDepth</c> got clamped to the wire correctly.
/// </summary>
public List<int> AlarmHistoryReadLog { get; } = new();
public virtual Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
int depth, CancellationToken ct)
{
AlarmHistoryReadLog.Add(depth);
IReadOnlyList<FocasAlarmHistoryEntry> snap = AlarmHistory.ToList();
return Task.FromResult(snap);
}
public virtual void Dispose() public virtual void Dispose()
{ {
DisposeCount++; DisposeCount++;

View File

@@ -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;
/// <summary>
/// Issue #267 (plan PR F3-a) — coverage for the <c>cnc_rdalmhistry</c> alarm-history
/// extension. Asserts mode switch, dedup, timestamp passthrough, depth clamp, and
/// the back-compat ActiveOnly path.
/// </summary>
[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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<FocasAlarmHistoryEntry>
{
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<byte>.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<ushort>(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<bool> 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");
}
}