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