feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:
1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
editor, the address picker, the deploy-time inspector and driver init.
Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).
E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1117,6 +1117,12 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
|
||||
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
|
||||
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
|
||||
- **FOCAS alarms (#647):** an `ALARM:` address (`*`, `<Type>`, `<Type>/<n>`, `<Type>/<lo>-<hi>`, `T<code>`, `OTHER`) +
|
||||
the `alarm` object = ONE condition at the RawPath (either alone is skipped + reported). Overlapping selectors all
|
||||
fire; `OTHER` = what no type selector claims. ⚠️ A failed `cnc_rdalmmsg2` THROWS (it used to return `[]` ⇒ mass
|
||||
clear + un-acked re-raise); the told record lives on the driver, so reconnect/re-init re-raise nothing. Type names
|
||||
are the 16i-era table only (30i codes unconfirmed, not invented — use `T<code>` there). Read-only; ack server-side.
|
||||
`AlarmProjection.Enabled` is a no-op. E2E: `Runtime.Tests/FocasNativeAlarmEndToEndTests`. `docs/drivers/FOCAS.md`.
|
||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
|
||||
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||
@@ -1158,7 +1164,7 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
|
||||
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
|
||||
discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact
|
||||
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS is still unroutable (`docs/drivers/FOCAS.md`).
|
||||
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS routes since #647 (below).
|
||||
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
||||
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
|
||||
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||
|
||||
@@ -56,7 +56,8 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
|
||||
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
|
||||
under #641); FOCAS still does (see `drivers/FOCAS.md`). A native source must also set `Kind`: an
|
||||
under #641), and so did FOCAS (`"{host}#{Type}:{Number}"`, fixed under #647 — one condition per `ALARM:` tag, see
|
||||
`drivers/FOCAS.md`). A native source must also set `Kind`: an
|
||||
`Unspecified` kind projects no state change.
|
||||
|
||||
### Native condition state survives a full rebuild (Gitea #641)
|
||||
|
||||
+88
-32
@@ -90,6 +90,7 @@ on.
|
||||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||||
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
||||
| `ALARM:*` / `ALARM:Servo/401-453` / `ALARM:OTHER` | CNC alarm selector | One Part 9 condition per tag, with the `alarm` object — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) |
|
||||
|
||||
PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are
|
||||
the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while
|
||||
@@ -151,11 +152,11 @@ reference them will throw at startup with a message pointing here.
|
||||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
||||
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||||
| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
|
||||
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | Opt-in — set `AlarmProjection.Enabled=true`; diffs `(AlarmNumber, Type)` between ticks |
|
||||
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | One condition per authored `ALARM:` tag at its RawPath — see [Alarm conditions](#alarm-conditions-alarm-gitea-647) |
|
||||
|
||||
Ack is a no-op — FANUC clears alarms on its own once the underlying condition
|
||||
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
|
||||
`BadNotSupported`.
|
||||
`BadNotSupported`; the Part 9 acknowledge is applied server-side.
|
||||
|
||||
## Fixed node tree
|
||||
|
||||
@@ -334,41 +335,94 @@ Rules worth knowing:
|
||||
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
|
||||
key edited through that form is dropped), so set it in the raw config.
|
||||
|
||||
## Alarm projection
|
||||
## Alarm conditions (`ALARM:`, Gitea #647)
|
||||
|
||||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
||||
on sites that don't consume CNC alarms. Opt in per driver instance:
|
||||
A FANUC CNC exposes **one flat active-alarm list per device** (`cnc_rdalmmsg2`), not one structure per
|
||||
alarm. The driver maps that list onto Part 9 conditions through authored raw tags: a tag whose address is an
|
||||
`ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object becomes **exactly one**
|
||||
condition, with `ConditionId` = the tag's RawPath (the key the host routes on). Same opt-in-per-tag shape as
|
||||
MTConnect CONDITION alarms (#626).
|
||||
|
||||
```jsonc
|
||||
"Config": {
|
||||
"Devices": [ ... ],
|
||||
"Tags": [ ... ],
|
||||
"AlarmProjection": {
|
||||
"Enabled": true,
|
||||
"PollInterval": "00:00:02"
|
||||
}
|
||||
}
|
||||
// raw tag TagConfig
|
||||
{ "address": "ALARM:Servo/401-453", "dataType": "Int32",
|
||||
"alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
|
||||
```
|
||||
|
||||
Every alarm transition fires `OnAlarmEvent` with:
|
||||
| Address | Condition is active while… |
|
||||
|---|---|
|
||||
| `ALARM:*` | any alarm is active on the tag's device (device summary) |
|
||||
| `ALARM:Servo` / `ALARM:T4` | any alarm of that type is active (a type name, or a raw type code `T<code>`) |
|
||||
| `ALARM:Servo/401` | that type + number is active |
|
||||
| `ALARM:Servo/401-453` | that type, number in the inclusive range |
|
||||
| `ALARM:OTHER` | an alarm is active that **no type selector** on the same device claims — the "unmatched" bucket |
|
||||
|
||||
- `SourceNodeId` = the device host address (FOCAS has no per-node alarm model;
|
||||
the CNC exposes a single flat active-alarm list per session)
|
||||
- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
|
||||
- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
|
||||
`Parameter`, `MacroAlarm`)
|
||||
- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
|
||||
→ `Medium`; everything else → `High`
|
||||
Type names: `Parameter`, `PulseCode`, `Overtravel`, `Overheat`, `Servo`, `DataIo`, `MemoryCheck`,
|
||||
`MacroAlarm` (case-insensitive). The grammar is `FocasAlarmSelector` in the Contracts assembly, and it is the
|
||||
**one authority**: the `/raw` tag modal, the address picker ("CNC alarm (condition)"), the deploy-time
|
||||
inspector and the driver's init all parse with it, and a malformed selector gets the same reason text from
|
||||
the editor and the gate.
|
||||
|
||||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
||||
so downstream consumers can ignore the clear if they only care about raises.
|
||||
⚠️ **The type table is the 16i/18i/21i-era `ALM_*` numbering** (`Parameter=0, PulseCode=1, Overtravel=2,
|
||||
Overheat=3, Servo=4, DataIo=5, MemoryCheck=6, MacroAlarm=13`) and is the only one the driver knows. The
|
||||
30i/31i/32i series number `alm_type` differently; that table has not been confirmed against a FOCAS
|
||||
reference, so it is deliberately **not** encoded — a guessed code would silently route the wrong alarms.
|
||||
On a 30i-series control (the Makino is a 31i-B) a type *name* may therefore select the wrong family. Use the
|
||||
raw-code form there: every condition message carries the raw code (`… (type 4 Servo, axis 1)`), so observe
|
||||
it, then author `ALARM:T<code>`. Number ranges are series-independent.
|
||||
|
||||
> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition
|
||||
> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths —
|
||||
> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is
|
||||
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
|
||||
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
|
||||
> not one condition per tag), so it is tracked separately rather than patched.
|
||||
**Semantics.**
|
||||
|
||||
- **Identity is (type, number).** Cross-family number collisions exist. The axis is not part of the key: one
|
||||
alarm number reported on two axes is one alarm, and the message lists both axes.
|
||||
- **Overlap is intended.** One servo alarm drives `ALARM:Servo` *and* `ALARM:*`. `*` summaries and `OTHER`
|
||||
tags never claim an alarm, so a summary beside `OTHER` does not starve it.
|
||||
- **Displayed alarm** = the most severe matching one (by the type table), ties to the lowest (type, number) —
|
||||
never wire order, so a reordered list cannot re-raise. Message:
|
||||
`401 SERVO V-READY OFF (type 4 Servo, axis 1) [+2 more]`; a summary condition whose read hit the per-read
|
||||
cap (32) adds `[alarm list truncated]` (and the driver warns once).
|
||||
- **Transitions** (`AlarmEventArgs.Kind` — the host's projector reads nothing else):
|
||||
inactive → active = `Raise`; a key **not in the matched set** appears = `Retrigger` (un-acknowledges: a new
|
||||
CNC alarm is a statement the operator has not seen); the **displayed** alarm leaves while others remain =
|
||||
`Retrigger` with the next one down (the MTConnect precedent); a **non-displayed** member leaves = nothing;
|
||||
nothing matches = `Clear`.
|
||||
- **Severity, explicitly.** An authored integer `alarm.severity` is the condition's exact Part 9 severity
|
||||
(`OpcUaSeverity`; the host clamps 1..1000). Without one, the bucket comes from the displayed alarm's type:
|
||||
Overtravel / Servo / PulseCode → Critical (900), Parameter / MacroAlarm → Medium (500), everything else
|
||||
including unknown codes → High (700). On a 30i-series control, author a severity rather than rely on the
|
||||
16i-era table. Note the AdminUI native-alarm editor always writes a `severity`, so a UI-authored tag always
|
||||
uses its authored value; the type table applies to an `alarm` object that carries none (CSV import, API).
|
||||
- **Unmatched alarms** — active, but selected by no `*`, `OTHER` or type tag on the device — are logged once
|
||||
each at Information.
|
||||
|
||||
**Delivery and failure.**
|
||||
|
||||
- **A failed read is not "no alarms".** `IFocasClient.ReadAlarmsAsync` throws on a down session, a non-OK
|
||||
return code or a wire fault; that device is skipped for the tick and its conditions keep their state. (It
|
||||
used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them un-acknowledged
|
||||
on recovery.) Driver-wide comms loss is the host's `BadNoCommunication` quality annotation (#477) — Active
|
||||
is kept.
|
||||
- **Diffed against what the server was TOLD**, recorded only while a handler is attached, and held for the
|
||||
driver's lifetime: a reconnect (same-set re-subscribe), a re-initialise and a shutdown/init cycle re-raise
|
||||
nothing, so an acknowledgement survives them. A changed subscribed set (an alarm-bearing deploy) prunes only
|
||||
the RawPaths that left (#641).
|
||||
- **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a
|
||||
**subscribed** alarm tag; devices without one are never polled for alarms.
|
||||
- **Opt-in is the tag.** `AlarmProjection.Enabled` is retained so old configs bind, and has no effect. Before
|
||||
#647 `SubscribeAlarmsAsync` threw while it was false, so an authored alarm tag on a default config stayed
|
||||
gated with a Warning on every reconnect.
|
||||
- **Read-only.** Nothing is ever written to the CNC. Acknowledge and AddComment are applied server-side (native
|
||||
conditions support only those two methods); `AcknowledgeAsync` is a no-op success. The CNC clears an alarm
|
||||
when its cause is reset.
|
||||
|
||||
**Authoring mistakes are skipped and reported** (the driver's skipped-tags ledger + a deploy-time advisory):
|
||||
an `ALARM:` address without the `alarm` object (not a condition, and no value to read), and an `alarm` object
|
||||
on a non-`ALARM:` address (a condition nothing can raise). Reading an `ALARM:` tag's RawPath through the driver
|
||||
returns `BadNotSupported`; writing it, `BadNotWritable`.
|
||||
|
||||
**Known residuals.** Whether the alarm *list* itself flickers at a CNC reset (the status word does, one poll)
|
||||
is unobserved; if the live gate shows it, a clear-debounce knob is the follow-up. Real-machine raise/clear is
|
||||
only observable passively — never induce an alarm on a production control.
|
||||
|
||||
## Handle recycling
|
||||
|
||||
@@ -397,8 +451,10 @@ latency spike once per cadence.
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
|
||||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
||||
clear diffing tests.
|
||||
driver surface via `FakeFocasClient`. `FocasAlarmSelectorTests` pins the `ALARM:` grammar and the inspector;
|
||||
`FocasAlarmProjectionTests` the transitions, `OTHER`, failed-read-keeps-state and the told record. The
|
||||
end-to-end proof that a CNC alarm reaches a condition is `FocasNativeAlarmEndToEndTests` in
|
||||
`Runtime.Tests` (real driver → `DriverHostActor.ForwardNativeAlarm` → `NativeAlarmProjector`).
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||
hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
|
||||
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
|
||||
@@ -418,7 +474,7 @@ latency spike once per cadence.
|
||||
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
|
||||
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
|
||||
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
|
||||
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
|
||||
| An `ALARM:` condition never activates | The tag lacks the `alarm` object, or an `alarm` object sits on a non-`ALARM:` address (both are skipped — check the skipped-tags report); or, on a 30i-series control, a type *name* selects the wrong 16i-era code | Pair the address with the `alarm` object; on a 30i use the raw `ALARM:T<code>` form, reading the code from an `ALARM:*` condition's message |
|
||||
| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting |
|
||||
| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault |
|
||||
| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag |
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>), plus
|
||||
/// <see cref="FocasAreaKind.Alarm"/> (a selector over the CNC's active alarm list — <c>ALARM:*</c>,
|
||||
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>, Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
@@ -35,11 +37,15 @@ public sealed record FocasAddress(
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex,
|
||||
string? FixedPath = null)
|
||||
string? FixedPath = null,
|
||||
FocasAlarmSelector? AlarmSelector = null)
|
||||
{
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address (Gitea #647).</summary>
|
||||
public const string AlarmPrefix = "ALARM:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
@@ -51,6 +57,7 @@ public sealed record FocasAddress(
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||
FocasAreaKind.Alarm => AlarmPrefix + AlarmSelector?.Canonical,
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
@@ -62,6 +69,26 @@ public sealed record FocasAddress(
|
||||
/// <returns>The canonical authored address string.</returns>
|
||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||
|
||||
/// <summary>Builds an <see cref="FocasAreaKind.Alarm"/> address over <paramref name="selector"/>.</summary>
|
||||
/// <param name="selector">The alarm selector.</param>
|
||||
/// <returns>The address.</returns>
|
||||
public static FocasAddress ForAlarm(FocasAlarmSelector selector) =>
|
||||
new(FocasAreaKind.Alarm, null, 0, null, AlarmSelector: selector);
|
||||
|
||||
/// <summary>
|
||||
/// Why <paramref name="value"/> is not a usable <c>ALARM:</c> address, or <see langword="null"/> when it
|
||||
/// is one (or is not an <c>ALARM:</c> address at all). Shared by the AdminUI editor and the deploy-time
|
||||
/// inspector so both say the same thing about the same string.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored address.</param>
|
||||
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
|
||||
public static string? DescribeAlarmAddressError(string? value)
|
||||
{
|
||||
var src = value?.Trim() ?? "";
|
||||
if (!src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..], out _, out var error) ? null : error;
|
||||
}
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
@@ -86,6 +113,14 @@ public sealed record FocasAddress(
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// Alarm selector (Gitea #647). Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||
// valid PMC letter (message display), so ALARM:… must be owned by this rule, not diagnosed as PMC.
|
||||
if (src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var selector = FocasAlarmSelector.TryParse(src[AlarmPrefix.Length..]);
|
||||
return selector is null ? null : ForAlarm(selector);
|
||||
}
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
@@ -175,4 +210,12 @@ public enum FocasAreaKind
|
||||
/// nothing in that tree is a writable location on the CNC.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the device's active CNC alarm list (<c>ALARM:*</c>, <c>ALARM:Servo/401-453</c>,
|
||||
/// <c>ALARM:OTHER</c>) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver
|
||||
/// <c>alarm</c> object, which makes it ONE Part 9 condition at its RawPath; it has no value to read
|
||||
/// and nothing to write.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects (Gitea #647).</summary>
|
||||
public enum FocasAlarmSelectorKind
|
||||
{
|
||||
/// <summary><c>ALARM:*</c> — every active alarm on the tag's device (a device summary condition).</summary>
|
||||
Any,
|
||||
|
||||
/// <summary>
|
||||
/// <c>ALARM:OTHER</c> — active alarms on the device that no <see cref="Type"/> selector on the same
|
||||
/// device matches: the "unmatched" bucket. <see cref="Any"/> and other <see cref="Other"/> tags do not
|
||||
/// claim an alarm, so an <c>ALARM:*</c> summary beside it does not starve it.
|
||||
/// </summary>
|
||||
Other,
|
||||
|
||||
/// <summary><c>ALARM:Servo</c>, <c>ALARM:Servo/401</c>, <c>ALARM:Servo/401-453</c>, <c>ALARM:T4</c>.</summary>
|
||||
Type,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selector half of an <c>ALARM:</c> FOCAS address (Gitea #647): which entries of the CNC's flat,
|
||||
/// per-device active-alarm list (<c>cnc_rdalmmsg2</c>) drive the ONE Part 9 condition the raw tag is
|
||||
/// materialised as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Grammar of the text after <c>ALARM:</c> (case-insensitive, no embedded whitespace):</para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Form</term><description>Matches</description></listheader>
|
||||
/// <item><term><c>*</c></term><description>any active alarm on the device</description></item>
|
||||
/// <item><term><c>OTHER</c></term><description>alarms no type selector on the device matches</description></item>
|
||||
/// <item><term><c><TYPE></c></term><description>any alarm of that type</description></item>
|
||||
/// <item><term><c><TYPE>/<n></c></term><description>that type and alarm number</description></item>
|
||||
/// <item><term><c><TYPE>/<lo>-<hi></c></term><description>that type, number in the inclusive range</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <c><TYPE></c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
|
||||
/// or a raw type code <c>T<code></c> (<c>T4</c>). The names resolve through the driver's single
|
||||
/// 16i-era type table — see the ⚠️ on <see cref="FocasAlarmType"/>; the raw-code form is the
|
||||
/// series-independent one and the right choice on a 30i-series control.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One authority.</b> This parser backs the AdminUI editor (through <see cref="FocasAddress.TryParse"/>),
|
||||
/// the deploy-time inspector and the driver's init — an address one of them accepts, all of them accept.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Kind">The selector kind.</param>
|
||||
/// <param name="TypeCode">The FOCAS alarm type code; set only for <see cref="FocasAlarmSelectorKind.Type"/>.</param>
|
||||
/// <param name="TypeName">The canonical type name when the selector named one; <see langword="null"/> for a raw <c>T<code></c>.</param>
|
||||
/// <param name="NumberLow">The lowest alarm number matched, or <see langword="null"/> for every number.</param>
|
||||
/// <param name="NumberHigh">The highest alarm number matched (inclusive); equals <paramref name="NumberLow"/> for a single number.</param>
|
||||
public sealed record FocasAlarmSelector(
|
||||
FocasAlarmSelectorKind Kind,
|
||||
short? TypeCode = null,
|
||||
string? TypeName = null,
|
||||
int? NumberLow = null,
|
||||
int? NumberHigh = null)
|
||||
{
|
||||
/// <summary>The <c>*</c> body.</summary>
|
||||
public const string AnyToken = "*";
|
||||
|
||||
/// <summary>The <c>OTHER</c> body.</summary>
|
||||
public const string OtherToken = "OTHER";
|
||||
|
||||
/// <summary>Gets the canonical selector text (the part after <c>ALARM:</c>).</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => AnyToken,
|
||||
FocasAlarmSelectorKind.Other => OtherToken,
|
||||
_ => (TypeName ?? $"T{TypeCode}") + (NumberLow is not { } lo
|
||||
? ""
|
||||
: NumberHigh is { } hi && hi != lo ? $"/{lo}-{hi}" : $"/{lo}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this selector matches one CNC alarm on its own terms. <see cref="FocasAlarmSelectorKind.Other"/>
|
||||
/// always answers <see langword="false"/> here: whether an alarm is "unmatched" depends on the other
|
||||
/// selectors on the device, which the caller evaluates.
|
||||
/// </summary>
|
||||
/// <param name="type">The alarm's type code.</param>
|
||||
/// <param name="number">The alarm number.</param>
|
||||
/// <returns><see langword="true"/> when the alarm is selected.</returns>
|
||||
public bool Matches(short type, int number) => Kind switch
|
||||
{
|
||||
FocasAlarmSelectorKind.Any => true,
|
||||
FocasAlarmSelectorKind.Type => type == TypeCode
|
||||
&& (NumberLow is not { } lo || (number >= lo && number <= (NumberHigh ?? lo))),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, or returns <see langword="null"/>.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <returns>The selector, or <see langword="null"/> when the text is not a valid selector.</returns>
|
||||
public static FocasAlarmSelector? TryParse(string? body) => TryParse(body, out var selector, out _) ? selector : null;
|
||||
|
||||
/// <summary>Parses the text after <c>ALARM:</c>, explaining a refusal.</summary>
|
||||
/// <param name="body">The selector text.</param>
|
||||
/// <param name="selector">The selector when this returns <see langword="true"/>.</param>
|
||||
/// <param name="error">Why the text was refused, when this returns <see langword="false"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="body"/> is a valid selector.</returns>
|
||||
public static bool TryParse(string? body, out FocasAlarmSelector? selector, out string? error)
|
||||
{
|
||||
selector = null;
|
||||
error = null;
|
||||
var src = (body ?? "").Trim();
|
||||
if (src.Length == 0)
|
||||
{
|
||||
error = "an ALARM: address needs a selector (*, OTHER, a type, type/number or type/lo-hi)";
|
||||
return false;
|
||||
}
|
||||
if (src.Any(char.IsWhiteSpace))
|
||||
{
|
||||
error = $"ALARM selector '{src}' contains whitespace";
|
||||
return false;
|
||||
}
|
||||
if (src == AnyToken)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Any);
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(src, OtherToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Other);
|
||||
return true;
|
||||
}
|
||||
|
||||
var slash = src.IndexOf('/');
|
||||
var typeText = slash < 0 ? src : src[..slash];
|
||||
if (!TryParseType(typeText, out var code, out var name))
|
||||
{
|
||||
error = $"'{typeText}' is not a FOCAS alarm type — expected one of {string.Join(", ", FocasAlarmType.Names)}, " +
|
||||
"or a raw type code such as T4";
|
||||
return false;
|
||||
}
|
||||
if (slash < 0)
|
||||
{
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
var numbers = src[(slash + 1)..];
|
||||
var dash = numbers.IndexOf('-');
|
||||
if (dash < 0)
|
||||
{
|
||||
if (!TryParseNumber(numbers, out var n))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number (a non-negative integer)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, n, n);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryParseNumber(numbers[..dash], out var lo) || !TryParseNumber(numbers[(dash + 1)..], out var hi))
|
||||
{
|
||||
error = $"'{numbers}' is not an alarm number range (lo-hi, non-negative integers)";
|
||||
return false;
|
||||
}
|
||||
if (lo > hi)
|
||||
{
|
||||
error = $"alarm number range '{numbers}' is reversed (lo must not exceed hi)";
|
||||
return false;
|
||||
}
|
||||
selector = new FocasAlarmSelector(FocasAlarmSelectorKind.Type, code, name, lo, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseType(string text, out short code, out string? name)
|
||||
{
|
||||
name = null;
|
||||
if (FocasAlarmType.TryResolve(text, out code))
|
||||
{
|
||||
name = FocasAlarmType.NameOf(code);
|
||||
return true;
|
||||
}
|
||||
// Raw code: T<digits>. Non-negative and within the wire field's short width.
|
||||
if (text.Length >= 2 && (text[0] == 'T' || text[0] == 't')
|
||||
&& text.Skip(1).All(char.IsAsciiDigit)
|
||||
&& short.TryParse(text.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out code))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
code = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string text, out int number) =>
|
||||
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out number);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||
/// <c>cnc_rdalmmsg2</c> (and so the driver's <c>switch (short)</c> statements compile against a
|
||||
/// matching type rather than relying on implicit int→short narrowing on the constants).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>
|
||||
/// (Gitea #647). The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||
/// been confirmed against a FOCAS reference, so it is deliberately NOT encoded here — a guessed code
|
||||
/// would silently route the wrong alarms. On a 30i-series control the names below therefore label
|
||||
/// the raw type code with its 16i meaning, which may be wrong. Every alarm message the driver
|
||||
/// publishes carries the raw code (<c>type 4</c>), and an <c>ALARM:</c> address can name a raw code
|
||||
/// (<c>ALARM:T4</c>) instead of a name, which is the series-independent form.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Moved into the Contracts assembly (from the driver's <c>IFocasClient.cs</c>, same namespace) so the
|
||||
/// <see cref="FocasAlarmSelector"/> grammar — shared by the AdminUI editor and the deploy gate — can
|
||||
/// resolve type names without referencing the runtime driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to the wire's <c>cnc_rdalmmsg2</c> to mean "any type".</summary>
|
||||
public const short All = -1;
|
||||
|
||||
/// <summary><c>ALM_P</c> — parameter / program (P/S) alarms.</summary>
|
||||
public const short Parameter = 0;
|
||||
|
||||
/// <summary><c>ALM_Y</c> — pulse coder (servo encoder) alarms.</summary>
|
||||
public const short PulseCode = 1;
|
||||
|
||||
/// <summary><c>ALM_O</c> — overtravel alarms.</summary>
|
||||
public const short Overtravel = 2;
|
||||
|
||||
/// <summary><c>ALM_H</c> — overheat alarms.</summary>
|
||||
public const short Overheat = 3;
|
||||
|
||||
/// <summary><c>ALM_S</c> — servo alarms.</summary>
|
||||
public const short Servo = 4;
|
||||
|
||||
/// <summary><c>ALM_T</c> — data I/O alarms.</summary>
|
||||
public const short DataIo = 5;
|
||||
|
||||
/// <summary><c>ALM_M</c> — memory check alarms.</summary>
|
||||
public const short MemoryCheck = 6;
|
||||
|
||||
/// <summary><c>ALM_MC</c> — macro alarms (<c>#3000</c>).</summary>
|
||||
public const short MacroAlarm = 13;
|
||||
|
||||
/// <summary>
|
||||
/// The named types an <c>ALARM:</c> address may use, keyed case-insensitively by the constant's
|
||||
/// name. The canonical spelling is the constant name (<c>Servo</c>, <c>MacroAlarm</c>).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, short> ByName =
|
||||
new Dictionary<string, short>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[nameof(Parameter)] = Parameter,
|
||||
[nameof(PulseCode)] = PulseCode,
|
||||
[nameof(Overtravel)] = Overtravel,
|
||||
[nameof(Overheat)] = Overheat,
|
||||
[nameof(Servo)] = Servo,
|
||||
[nameof(DataIo)] = DataIo,
|
||||
[nameof(MemoryCheck)] = MemoryCheck,
|
||||
[nameof(MacroAlarm)] = MacroAlarm,
|
||||
};
|
||||
|
||||
/// <summary>Gets the canonical type names an <c>ALARM:</c> address accepts, in code order.</summary>
|
||||
public static IReadOnlyList<string> Names { get; } =
|
||||
[nameof(Parameter), nameof(PulseCode), nameof(Overtravel), nameof(Overheat),
|
||||
nameof(Servo), nameof(DataIo), nameof(MemoryCheck), nameof(MacroAlarm)];
|
||||
|
||||
/// <summary>Resolves a type name (case-insensitive) to its code.</summary>
|
||||
/// <param name="name">The type name, e.g. <c>servo</c>.</param>
|
||||
/// <param name="code">The resolved code.</param>
|
||||
/// <returns><see langword="true"/> when the name is in the table.</returns>
|
||||
public static bool TryResolve(string name, out short code) => ByName.TryGetValue(name, out code);
|
||||
|
||||
/// <summary>The canonical name of a code, or <see langword="null"/> when the code is not in the table.</summary>
|
||||
/// <param name="code">The FOCAS alarm type code.</param>
|
||||
/// <returns>The name, or <see langword="null"/>.</returns>
|
||||
public static string? NameOf(short code) => code switch
|
||||
{
|
||||
Parameter => nameof(Parameter),
|
||||
PulseCode => nameof(PulseCode),
|
||||
Overtravel => nameof(Overtravel),
|
||||
Overheat => nameof(Overheat),
|
||||
Servo => nameof(Servo),
|
||||
DataIo => nameof(DataIo),
|
||||
MemoryCheck => nameof(MemoryCheck),
|
||||
MacroAlarm => nameof(MacroAlarm),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -128,12 +128,19 @@ public sealed class FocasHandleRecycleOptions
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
||||
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags (Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Authoring an <c>ALARM:</c> tag IS the opt-in.</b> The projection polls exactly the devices that own a
|
||||
/// subscribed alarm tag, so a driver with none issues no <c>cnc_rdalmmsg2</c> traffic whatever
|
||||
/// <see cref="Enabled"/> says. Before #647, <see cref="Enabled"/> gated the poll and
|
||||
/// <c>SubscribeAlarmsAsync</c> THREW while it was false — so an operator who authored an alarm tag and left
|
||||
/// the default got a feed that stayed gated with a Warning on every reconnect. It is retained so existing
|
||||
/// configs still bind, and no longer has any effect.
|
||||
/// </remarks>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
||||
/// <summary>Retained for config compatibility; has no effect since Gitea #647 (see the class remarks).</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
@@ -196,13 +203,29 @@ public sealed record FocasDeviceOptions(
|
||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <param name="Alarm">
|
||||
/// The tag's cross-driver <c>alarm</c> object, when it carries one (Gitea #647) — which makes the host
|
||||
/// materialise it as a Part 9 condition at its RawPath. Only an <c>ALARM:</c> address can drive one.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
FocasTagAlarm? Alarm = null);
|
||||
|
||||
/// <summary>
|
||||
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object (Gitea #647). The host owns the
|
||||
/// rest of it (<c>alarmType</c>, <c>historizeToAveva</c>) through <c>TagConfigIntent</c>.
|
||||
/// </summary>
|
||||
/// <param name="Severity">
|
||||
/// The authored <c>alarm.severity</c> (an integer), or <see langword="null"/> when the object has none. An
|
||||
/// authored severity is sent as the condition's exact Part 9 severity; without one the severity comes from
|
||||
/// the displayed CNC alarm's type (<c>FocasAlarmConditionMapper</c>).
|
||||
/// </param>
|
||||
public sealed record FocasTagAlarm(int? Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Controls periodic connectivity probing. One <c>cnc_rdcncstat</c> call per
|
||||
|
||||
@@ -64,7 +64,8 @@ public static class FocasTagDefinitionFactory
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
Writable: writable,
|
||||
WriteIdempotent: false);
|
||||
WriteIdempotent: false,
|
||||
Alarm: ReadAlarm(root));
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
@@ -72,6 +73,20 @@ public static class FocasTagDefinitionFactory
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag's cross-driver <c>alarm</c> object (Gitea #647), or <see langword="null"/> when it carries none.
|
||||
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <returns>The alarm intent, or <see langword="null"/>.</returns>
|
||||
private static FocasTagAlarm? ReadAlarm(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
int? severity = a.TryGetProperty("severity", out var s) && s.ValueKind == JsonValueKind.Number
|
||||
&& s.TryGetInt32(out var v) ? v : null;
|
||||
return new FocasTagAlarm(severity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
@@ -235,16 +250,37 @@ public static class FocasTagDefinitionFactory
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
||||
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
||||
if (root.TryGetProperty("address", out var addr)
|
||||
&& addr.ValueKind == JsonValueKind.String
|
||||
&& addr.GetString() is { } address
|
||||
&& !string.IsNullOrWhiteSpace(address)
|
||||
&& FocasAddress.TryParse(address) is null)
|
||||
var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String
|
||||
? addr.GetString()
|
||||
: null;
|
||||
var parsed = string.IsNullOrWhiteSpace(address) ? null : FocasAddress.TryParse(address);
|
||||
if (!string.IsNullOrWhiteSpace(address) && parsed is null)
|
||||
{
|
||||
// An ALARM: address gets the selector's own reason — the same text the AdminUI editor shows.
|
||||
advisories.Add(FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"FOCAS alarm address '{address.Trim()}' is not valid: {alarmError} — the driver will skip " +
|
||||
"this tag at startup and its condition will never activate."
|
||||
: $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500, FIXED:Identity/SeriesNumber or ALARM:Servo/401) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||
// makes the host materialise a Part 9 condition; the address is the only thing the driver can drive one
|
||||
// from. Either one alone is a tag that does nothing.
|
||||
var hasAlarmObject = root.TryGetProperty("alarm", out var alarm) && alarm.ValueKind == JsonValueKind.Object;
|
||||
if (parsed is { Kind: FocasAreaKind.Alarm } && !hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
$"FOCAS address '{address!.Trim()}' is an alarm selector but the tag has no 'alarm' object — it is " +
|
||||
"not materialised as a condition and has no value to read; the driver skips it.");
|
||||
}
|
||||
else if (parsed is { Kind: not FocasAreaKind.Alarm } && hasAlarmObject)
|
||||
{
|
||||
advisories.Add(
|
||||
$"FOCAS tag carries an 'alarm' object but its address '{address!.Trim()}' is not an ALARM: selector — " +
|
||||
"the condition is materialised but nothing will ever raise it; the driver skips the tag. " +
|
||||
"Use ALARM:*, ALARM:<type>[/<n>[-<m>]] or ALARM:OTHER.");
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for (Gitea #647).</summary>
|
||||
/// <param name="RawPath">The tag's RawPath — the condition's ConditionId, the only key the host routes on.</param>
|
||||
/// <param name="DeviceHostAddress">The device whose alarm list the tag selects from.</param>
|
||||
/// <param name="Selector">Which alarms on that device drive the condition.</param>
|
||||
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or <see langword="null"/> to derive one from the alarm type.</param>
|
||||
internal sealed record FocasAlarmTag(
|
||||
string RawPath, string DeviceHostAddress, FocasAlarmSelector Selector, int? AuthoredSeverity);
|
||||
|
||||
/// <summary>A CNC alarm's identity: (type, number). Axis is NOT part of it — see <see cref="FocasAlarmMatcher"/>.</summary>
|
||||
/// <param name="Type">The FOCAS alarm type code.</param>
|
||||
/// <param name="Number">The alarm number.</param>
|
||||
internal readonly record struct FocasAlarmKey(short Type, int Number);
|
||||
|
||||
/// <summary>One distinct alarm a condition matched, with every axis it was reported on.</summary>
|
||||
/// <param name="Key">The alarm's identity.</param>
|
||||
/// <param name="Message">The CNC's message text.</param>
|
||||
/// <param name="Axes">The distinct non-zero axes it was reported on, ascending.</param>
|
||||
internal sealed record FocasMatchedAlarm(FocasAlarmKey Key, string Message, IReadOnlyList<short> Axes);
|
||||
|
||||
/// <summary>What one condition sees in one successful alarm-list read.</summary>
|
||||
/// <param name="Matched">The matched alarms in display order — the first is the one the condition shows.</param>
|
||||
/// <param name="ListTruncated">Whether the read may have been cut off at the per-read cap (summary selectors only).</param>
|
||||
internal sealed record FocasConditionView(IReadOnlyList<FocasMatchedAlarm> Matched, bool ListTruncated)
|
||||
{
|
||||
/// <summary>Gets a value indicating whether at least one matching alarm is active.</summary>
|
||||
public bool Active => Matched.Count > 0;
|
||||
|
||||
/// <summary>Gets the alarm the condition displays, or <see langword="null"/> when inactive.</summary>
|
||||
public FocasMatchedAlarm? Winner => Matched.Count > 0 ? Matched[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects, per authored <c>ALARM:</c> tag, the matching entries of a device's flat CNC alarm list (Gitea #647).
|
||||
/// Pure — the projection hands it one successful read and gets back what each condition sees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Identity is (type, number).</b> Cross-family number collisions exist, so the number alone is not
|
||||
/// an identity. The axis is not part of it: one servo alarm number reported on two axes is ONE alarm,
|
||||
/// listing both axes.</item>
|
||||
/// <item><b>Overlap is intended.</b> An alarm drives every tag that selects it — <c>ALARM:Servo</c> and
|
||||
/// <c>ALARM:*</c> both go active for one servo alarm, the way one MTConnect DataItem can back several tags.</item>
|
||||
/// <item><b><c>OTHER</c> is what no TYPE selector on the device claims.</b> <c>*</c> summaries and other
|
||||
/// <c>OTHER</c> tags do not claim anything, so a device summary beside it never starves it.</item>
|
||||
/// <item><b>The displayed alarm is deterministic, never wire order</b>: the most severe (by the type
|
||||
/// table), then ascending (type, number). A CNC that reorders an unchanged list therefore cannot re-raise
|
||||
/// a condition.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class FocasAlarmMatcher
|
||||
{
|
||||
/// <summary>Evaluates one condition against one device's alarm list.</summary>
|
||||
/// <param name="selector">The condition's selector.</param>
|
||||
/// <param name="alarms">The device's active alarms, as read.</param>
|
||||
/// <param name="deviceSelectors">Every authored alarm selector on the same device (for <c>OTHER</c>).</param>
|
||||
/// <param name="listTruncated">Whether the read came back at the per-read cap.</param>
|
||||
/// <returns>What the condition sees.</returns>
|
||||
public static FocasConditionView Evaluate(
|
||||
FocasAlarmSelector selector,
|
||||
IReadOnlyList<FocasActiveAlarm> alarms,
|
||||
IReadOnlyCollection<FocasAlarmSelector> deviceSelectors,
|
||||
bool listTruncated)
|
||||
{
|
||||
var typeSelectors = selector.Kind == FocasAlarmSelectorKind.Other
|
||||
? deviceSelectors.Where(s => s.Kind == FocasAlarmSelectorKind.Type).ToList()
|
||||
: null;
|
||||
|
||||
var matched = alarms
|
||||
.Where(a => typeSelectors is null
|
||||
? selector.Matches(a.Type, a.AlarmNumber)
|
||||
: !typeSelectors.Any(s => s.Matches(a.Type, a.AlarmNumber)))
|
||||
.GroupBy(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
||||
.Select(g =>
|
||||
{
|
||||
var records = g.OrderBy(a => a.Axis).ToList();
|
||||
var message = records.Select(a => a.Message?.Trim()).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? "";
|
||||
var axes = records.Select(a => a.Axis).Where(x => x > 0).Distinct().Order().ToList();
|
||||
return new FocasMatchedAlarm(g.Key, message, axes);
|
||||
})
|
||||
.OrderByDescending(m => FocasAlarmConditionMapper.TypeSeverity(m.Key.Type))
|
||||
.ThenBy(m => m.Key.Type)
|
||||
.ThenBy(m => m.Key.Number)
|
||||
.ToList();
|
||||
|
||||
var summary = selector.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other;
|
||||
return new FocasConditionView(matched, listTruncated && summary);
|
||||
}
|
||||
|
||||
/// <summary>Whether any authored selector on the device claims the alarm (<c>*</c>, <c>OTHER</c> or a type match).</summary>
|
||||
/// <param name="alarm">The alarm.</param>
|
||||
/// <param name="deviceSelectors">Every authored alarm selector on the device.</param>
|
||||
/// <returns><see langword="true"/> when some condition on the device will show it.</returns>
|
||||
public static bool IsCovered(FocasActiveAlarm alarm, IReadOnlyCollection<FocasAlarmSelector> deviceSelectors) =>
|
||||
deviceSelectors.Any(s => s.Kind is FocasAlarmSelectorKind.Any or FocasAlarmSelectorKind.Other
|
||||
|| s.Matches(alarm.Type, alarm.AlarmNumber));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the server was last TOLD about one FOCAS condition (Gitea #647) — the diff baseline that keeps a
|
||||
/// reconnect, a re-subscribe or a reordered list from re-raising (and so un-acknowledging) an alarm.
|
||||
/// </summary>
|
||||
/// <param name="Active">Whether the condition was last told active.</param>
|
||||
/// <param name="Keys">The matched set as of the last evaluation.</param>
|
||||
/// <param name="Winner">The alarm the condition was last told it displays.</param>
|
||||
/// <param name="Message">The message last sent.</param>
|
||||
/// <param name="Severity">The bucket last sent.</param>
|
||||
/// <param name="OpcUaSeverity">The exact severity last sent, if any.</param>
|
||||
internal sealed record FocasToldCondition(
|
||||
bool Active,
|
||||
IReadOnlySet<FocasAlarmKey> Keys,
|
||||
FocasAlarmKey? Winner,
|
||||
string Message,
|
||||
AlarmSeverity Severity,
|
||||
int? OpcUaSeverity);
|
||||
|
||||
/// <summary>
|
||||
/// The FOCAS alarm list → OPC UA Part 9 transition, as one pure function (Gitea #647): given what the server was
|
||||
/// last told about a condition and what the CNC says now, the single <see cref="AlarmEventArgs"/> to raise — or
|
||||
/// none — and what the server has been told afterwards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Told → now</term><description>Event</description></listheader>
|
||||
/// <item><term>inactive → active</term><description><see cref="AlarmTransitionKind.Raise"/></description></item>
|
||||
/// <item><term>active → a key not in the told set appears</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges</description></item>
|
||||
/// <item><term>active → the displayed alarm left, others remain</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen)</description></item>
|
||||
/// <item><term>active → a NON-displayed member left</term><description>nothing (no message-only events)</description></item>
|
||||
/// <item><term>active → nothing matches</term><description><see cref="AlarmTransitionKind.Clear"/></description></item>
|
||||
/// <item><term>unchanged</term><description>nothing</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Severity, explicitly.</b> An authored <c>alarm.severity</c> is sent as
|
||||
/// <see cref="AlarmEventArgs.OpcUaSeverity"/> (the host clamps it to 1..1000), with the nearest bucket in
|
||||
/// <see cref="AlarmEventArgs.Severity"/> (<c><400</c> Low, <c><600</c> Medium, <c><800</c> High, else
|
||||
/// Critical). Without one, the bucket comes from the displayed alarm's type through <see cref="TypeSeverity"/>
|
||||
/// — Overtravel / Servo / PulseCode Critical (host: 900), Parameter / MacroAlarm Medium (500), everything
|
||||
/// else including unknown codes High (700) — and no exact severity is sent. The type table is the 16i-era
|
||||
/// one (see <see cref="FocasAlarmType"/>), so on a 30i-series control author a severity.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FocasAlarmConditionMapper
|
||||
{
|
||||
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="view"/>, and the new record.</summary>
|
||||
/// <param name="told">What the server was last told, or <see langword="null"/> when nothing yet.</param>
|
||||
/// <param name="view">What the condition sees now.</param>
|
||||
/// <param name="tag">The condition's tag.</param>
|
||||
/// <param name="handle">The alarm subscription the event belongs to.</param>
|
||||
/// <param name="nowUtc">The transition timestamp.</param>
|
||||
/// <returns>The event (or <see langword="null"/>) and the new told record.</returns>
|
||||
public static (AlarmEventArgs? Event, FocasToldCondition Told) Transition(
|
||||
FocasToldCondition? told,
|
||||
FocasConditionView view,
|
||||
FocasAlarmTag tag,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var keys = view.Matched.Select(m => m.Key).ToHashSet();
|
||||
|
||||
if (!view.Active)
|
||||
{
|
||||
if (told is not { Active: true })
|
||||
return (null, told ?? new FocasToldCondition(false, keys, null, "", AlarmSeverity.Low, null));
|
||||
var cleared = told with { Active = false, Keys = keys, Winner = null };
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}",
|
||||
told.Winner?.Type, told.Severity, told.OpcUaSeverity), cleared);
|
||||
}
|
||||
|
||||
var winner = view.Winner!;
|
||||
var (bucket, exact) = SeverityOf(tag, winner.Key.Type);
|
||||
var message = MessageOf(view);
|
||||
var raised = new FocasToldCondition(true, keys, winner.Key, message, bucket, exact);
|
||||
|
||||
if (told is not { Active: true })
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, winner.Key.Type, bucket, exact), raised);
|
||||
|
||||
var newKey = keys.Any(k => !told.Keys.Contains(k));
|
||||
if (newKey || told.Winner != winner.Key)
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, winner.Key.Type, bucket, exact), raised);
|
||||
|
||||
// Same statement: keep what the server was shown, but track the membership so a key that leaves and
|
||||
// later comes back is new again.
|
||||
return (null, told with { Keys = keys });
|
||||
}
|
||||
|
||||
/// <summary>The severity bucket of a FOCAS alarm type (the 16i-era table — see the class remarks).</summary>
|
||||
/// <param name="type">The FOCAS alarm type code.</param>
|
||||
/// <returns>The bucket.</returns>
|
||||
internal static AlarmSeverity TypeSeverity(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||
_ => AlarmSeverity.High,
|
||||
};
|
||||
|
||||
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
|
||||
/// <param name="severity">The exact severity.</param>
|
||||
/// <returns>The bucket.</returns>
|
||||
internal static AlarmSeverity BucketOf(int severity) => severity switch
|
||||
{
|
||||
< 400 => AlarmSeverity.Low,
|
||||
< 600 => AlarmSeverity.Medium,
|
||||
< 800 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
/// <summary>The human-readable category of a FOCAS type: its table name, else <c>Type{code}</c>.</summary>
|
||||
/// <param name="type">The FOCAS alarm type code.</param>
|
||||
/// <returns>The category.</returns>
|
||||
internal static string TypeLabel(short type) => FocasAlarmType.NameOf(type) ?? $"Type{type}";
|
||||
|
||||
/// <summary>
|
||||
/// The condition's message: the displayed alarm's number and text, its raw type code (so a 30i-series
|
||||
/// operator can author a <c>T<code></c> selector from what they see) and axes, then how many other
|
||||
/// alarms the condition also matches, and a truncation note on a summary condition.
|
||||
/// </summary>
|
||||
/// <param name="view">What the condition sees; must be active.</param>
|
||||
/// <returns>e.g. <c>401 SERVO V-READY OFF (type 4 Servo, axis 1, 2) [+2 more]</c>.</returns>
|
||||
internal static string MessageOf(FocasConditionView view)
|
||||
{
|
||||
var w = view.Winner!;
|
||||
var b = new StringBuilder();
|
||||
b.Append(w.Key.Number);
|
||||
if (w.Message.Length > 0) b.Append(' ').Append(w.Message);
|
||||
b.Append(" (type ").Append(w.Key.Type);
|
||||
if (FocasAlarmType.NameOf(w.Key.Type) is { } name) b.Append(' ').Append(name);
|
||||
if (w.Axes.Count > 0) b.Append(w.Axes.Count == 1 ? ", axis " : ", axes ").Append(string.Join(", ", w.Axes));
|
||||
b.Append(')');
|
||||
if (view.Matched.Count > 1) b.Append(" [+").Append(view.Matched.Count - 1).Append(" more]");
|
||||
if (view.ListTruncated) b.Append(" [alarm list truncated]");
|
||||
return b.ToString();
|
||||
}
|
||||
|
||||
private static (AlarmSeverity Bucket, int? Exact) SeverityOf(FocasAlarmTag tag, short type) =>
|
||||
tag.AuthoredSeverity is { } authored
|
||||
? (BucketOf(authored), authored)
|
||||
: (TypeSeverity(type), null);
|
||||
|
||||
private static AlarmEventArgs Build(
|
||||
FocasAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc, AlarmTransitionKind kind,
|
||||
string message, short? type, AlarmSeverity bucket, int? exact) =>
|
||||
new(handle,
|
||||
SourceNodeId: tag.DeviceHostAddress,
|
||||
ConditionId: tag.RawPath,
|
||||
AlarmType: type is { } t ? TypeLabel(t) : "CncAlarm",
|
||||
Message: message,
|
||||
Severity: bucket,
|
||||
SourceTimestampUtc: nowUtc,
|
||||
Kind: kind,
|
||||
OpcUaSeverity: exact);
|
||||
}
|
||||
@@ -5,196 +5,180 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
|
||||
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
|
||||
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
|
||||
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
|
||||
/// <c>Type</c>) keyed active-alarm set between ticks.
|
||||
/// Polls the CNC active-alarm list (<c>cnc_rdalmmsg2</c>) of every device that owns a subscribed
|
||||
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag (Gitea #647).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
|
||||
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
|
||||
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
|
||||
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
|
||||
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
|
||||
/// projection will skip devices not listed.
|
||||
/// <para>
|
||||
/// <b>Which tags are conditions.</b> The server decides: a raw tag whose <c>TagConfig</c> carries the
|
||||
/// cross-driver <c>alarm</c> object is materialised as a condition at its RawPath, and those RawPaths
|
||||
/// arrive through <see cref="SubscribeAsync"/>. Every event uses the RawPath as
|
||||
/// <see cref="AlarmEventArgs.ConditionId"/> — the exact key the host routes on — and carries a
|
||||
/// <see cref="AlarmEventArgs.Kind"/>, without which the host's projector keeps the prior state. Before
|
||||
/// #647 this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
|
||||
/// <c>"{host}#{Type}:{Number}"</c> with no kind (so nothing would have routed anyway).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failed read is not an empty list.</b> <see cref="IFocasClient.ReadAlarmsAsync"/> throws on any
|
||||
/// failure; the device is then skipped for that tick and every condition on it keeps its state. The
|
||||
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss (#477).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect #626
|
||||
/// pattern), recorded only while an <c>OnAlarmEvent</c> handler is attached. The record lives on this
|
||||
/// object, which lives as long as the driver: it survives a re-subscribe with the same set (a reconnect),
|
||||
/// a re-initialise and a shutdown/re-init cycle — the server's condition nodes outlive all three, and
|
||||
/// re-raising there would un-acknowledge an acknowledged alarm. A changed subscribed set prunes only the
|
||||
/// RawPaths that LEFT it (the server carries every survivor across the rebuild, #641).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One poll loop</b>, owned by the current subscription. Evaluation and delivery run under
|
||||
/// <see cref="_alarmLock"/> and re-check that the subscription is still current, so a superseded loop
|
||||
/// that is still finishing its last read can never emit — the duplicate-per-reconnect window the old
|
||||
/// design documented is closed. The only production handler is <c>DriverInstanceActor</c>'s
|
||||
/// non-blocking <c>Tell</c>.
|
||||
/// </para>
|
||||
/// <para><b>Read-only.</b> FOCAS has no alarm acknowledgement; the Part 9 Acknowledge is applied server-side.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
|
||||
/// <summary>Serializes evaluation + delivery + the told record. Never held across an await.</summary>
|
||||
private readonly Lock _alarmLock = new();
|
||||
|
||||
/// <summary>ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly Dictionary<string, FocasToldCondition> _told = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Per device: the alarm keys already logged as matched by no tag. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly Dictionary<string, HashSet<FocasAlarmKey>> _unmatchedLogged = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Devices whose last read came back at the per-read cap. Guarded by <see cref="_alarmLock"/>.</summary>
|
||||
private readonly HashSet<string> _truncationWarned = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Serializes loop start/stop.</summary>
|
||||
private readonly SemaphoreSlim _loopGate = new(1, 1);
|
||||
|
||||
private Subscription? _current;
|
||||
private CancellationTokenSource? _loopCts;
|
||||
private Task _loop = Task.CompletedTask;
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Initializes a new FOCAS alarm projection.</summary>
|
||||
/// <param name="driver">FOCAS driver instance.</param>
|
||||
/// <param name="pollInterval">Polling interval.</param>
|
||||
/// <param name="driver">The owning driver.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
||||
public FocasAlarmProjection(FocasDriver driver, ILogger? logger = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to alarms from the specified device sources.</summary>
|
||||
/// <param name="sourceNodeIds">Source node IDs to listen to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task returning the alarm subscription handle.</returns>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Empty filter = listen to every configured device. Otherwise only devices whose
|
||||
// host address appears in sourceNodeIds are polled.
|
||||
var filter = sourceNodeIds.Count == 0
|
||||
? null
|
||||
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
|
||||
var sub = new Subscription(handle, filter, cts);
|
||||
|
||||
// Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
|
||||
// alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
|
||||
// Connected→Reconnecting WITHOUT calling Unsubscribe), and this projection is reused across
|
||||
// every in-place reconnect, so each SubscribeAsync would otherwise leak a live, never-
|
||||
// cancelled poll loop. There is exactly one consumer per driver instance, so retiring all
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
{
|
||||
stale = [.. _subs.Values];
|
||||
_subs.Clear();
|
||||
_subs[id] = sub;
|
||||
}
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
|
||||
// Retire superseded loops out-of-band so the new subscription's return isn't blocked on a
|
||||
// full poll interval (awaiting a loop means waiting for it to exit its Task.Delay). The
|
||||
// loops already catch internally; RetireAsync still wraps every await defensively.
|
||||
foreach (var old in stale) _ = RetireAsync(old);
|
||||
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
/// <summary>Gets the live subscription, or <see langword="null"/>. Test seam.</summary>
|
||||
internal Subscription? Current => Volatile.Read(ref _current);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
|
||||
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
|
||||
/// unobserved exception can never escape (the loops already swallow their own).
|
||||
/// <para>
|
||||
/// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight
|
||||
/// read + tick complete and it observes cancellation at the next <c>Task.Delay</c>. If a device
|
||||
/// transition lands in that one-tick window, both the old and new loops can fire it once —
|
||||
/// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old
|
||||
/// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on
|
||||
/// <c>ConditionId</c>, so this is absorbed. Awaiting the retire before starting the new loop
|
||||
/// would close the window at the cost of blocking the subscribe for one read latency.
|
||||
/// </para>
|
||||
/// Replaces the live subscription with one over <paramref name="sourceNodeIds"/> (alarm-tag RawPaths) and
|
||||
/// (re)starts the poll loop. Collapses to ONE subscription: the owning <c>DriverInstanceActor</c> is the
|
||||
/// only caller and re-subscribes on every Connected entry.
|
||||
/// </summary>
|
||||
/// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
|
||||
private async Task RetireAsync(Subscription sub)
|
||||
/// <param name="sourceNodeIds">The alarm-bearing raw tags' RawPaths.</param>
|
||||
/// <returns>The subscription handle.</returns>
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); }
|
||||
try { sub.Cts.Dispose(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); }
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
var refs = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in sourceNodeIds)
|
||||
if (!string.IsNullOrWhiteSpace(r)) refs.Add(r);
|
||||
|
||||
var sub = new Subscription(new FocasAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// Forget only the conditions that LEFT the set (#641) — the SAME set (a reconnect) prunes nothing.
|
||||
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone); Volatile.Write(ref _current, sub);
|
||||
}
|
||||
await RestartLoopAsync().ConfigureAwait(false);
|
||||
return sub.Handle;
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from an alarm subscription.</summary>
|
||||
/// <param name="handle">Alarm subscription handle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <summary>Drops the live subscription when <paramref name="handle"/> is it; a superseded handle is a no-op.</summary>
|
||||
/// <param name="handle">The handle returned by <see cref="SubscribeAsync"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
lock (_alarmLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
if (Volatile.Read(ref _current) is not { } live || !ReferenceEquals(live.Handle, handle)) return;
|
||||
Volatile.Write(ref _current, null);
|
||||
}
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); }
|
||||
sub.Cts.Dispose();
|
||||
await StopLoopAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
|
||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
||||
/// than surfacing a confusing "not supported" error to the operator.
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying condition resolves.
|
||||
/// The Part 9 Acknowledge is applied server-side; this is the no-op success the host routes it to, so
|
||||
/// nothing is ever written to the CNC.
|
||||
/// </summary>
|
||||
/// <param name="acknowledgements">Alarms to acknowledge.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <summary>Disposes the alarm projection.</summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
public static Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements)
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); }
|
||||
try { await sub.Loop.ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
|
||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
||||
/// the full Task.Run loop.
|
||||
/// Stops the poll loop but KEEPS the subscription and the told record — the device/tag tables are about
|
||||
/// to be rebuilt (re-initialise, shutdown). <see cref="ResumeAsync"/> restarts it.
|
||||
/// </summary>
|
||||
/// <param name="sub">Active subscription.</param>
|
||||
/// <param name="deviceHostAddress">Device host address.</param>
|
||||
/// <param name="current">Current alarms from the device.</param>
|
||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task PauseAsync() => StopLoopAsync();
|
||||
|
||||
/// <summary>Restarts the poll loop when a subscription is live — after the driver's tables are rebuilt.</summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false);
|
||||
|
||||
private async Task RestartLoopAsync()
|
||||
{
|
||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
||||
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
|
||||
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
|
||||
|
||||
foreach (var a in current)
|
||||
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (prevKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: a.Message,
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
await StopLoopCoreAsync().ConfigureAwait(false);
|
||||
if (Volatile.Read(ref _current) is not { } sub) return;
|
||||
var cts = new CancellationTokenSource();
|
||||
_loopCts = cts;
|
||||
_loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), CancellationToken.None);
|
||||
}
|
||||
finally { _loopGate.Release(); }
|
||||
}
|
||||
|
||||
foreach (var a in prev)
|
||||
private async Task StopLoopAsync()
|
||||
{
|
||||
await _loopGate.WaitAsync().ConfigureAwait(false);
|
||||
try { await StopLoopCoreAsync().ConfigureAwait(false); }
|
||||
finally { _loopGate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Cancels and joins the loop (bounded). Caller holds <see cref="_loopGate"/>.</summary>
|
||||
private async Task StopLoopCoreAsync()
|
||||
{
|
||||
if (_loopCts is not { } cts) return;
|
||||
_loopCts = null;
|
||||
try { cts.Cancel(); }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling the FOCAS alarm poll loop failed"); }
|
||||
var loop = _loop;
|
||||
try
|
||||
{
|
||||
if (nowKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: $"{a.Message} (cleared)",
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
var finished = await Task.WhenAny(loop, Task.Delay(FocasDriver.LoopJoinTimeout)).ConfigureAwait(false);
|
||||
if (finished != loop)
|
||||
_logger.LogWarning("FOCAS alarm poll loop did not stop within {Timeout}; it cannot emit once superseded",
|
||||
FocasDriver.LoopJoinTimeout);
|
||||
}
|
||||
|
||||
sub.LastByDevice[deviceHostAddress] = [.. current];
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting the FOCAS alarm poll loop failed"); }
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
@@ -203,78 +187,147 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
|
||||
{
|
||||
Tick(sub, host, alarms);
|
||||
}
|
||||
await PollOnceAsync(sub, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* per-tick failures are non-fatal — next tick retries */
|
||||
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
try { await Task.Delay(_driver.AlarmPollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
||||
|
||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm type string.</returns>
|
||||
internal static string MapAlarmType(short type) => type switch
|
||||
/// <summary>One pass: read each device that owns a subscribed alarm tag, and evaluate its conditions.</summary>
|
||||
/// <param name="sub">The subscription the pass serves.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
internal async Task PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
FocasAlarmType.Parameter => "Parameter",
|
||||
FocasAlarmType.PulseCode => "PulseCode",
|
||||
FocasAlarmType.Overtravel => "Overtravel",
|
||||
FocasAlarmType.Overheat => "Overheat",
|
||||
FocasAlarmType.Servo => "Servo",
|
||||
FocasAlarmType.DataIo => "DataIo",
|
||||
FocasAlarmType.MemoryCheck => "MemoryCheck",
|
||||
FocasAlarmType.MacroAlarm => "MacroAlarm",
|
||||
_ => $"Type{type}",
|
||||
};
|
||||
var tags = _driver.AlarmTags;
|
||||
var devices = sub.References
|
||||
.Select(r => tags.GetValueOrDefault(r))
|
||||
.OfType<FocasAlarmTag>()
|
||||
.Select(t => t.DeviceHostAddress)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var host in devices)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
IReadOnlyList<FocasActiveAlarm> alarms;
|
||||
try
|
||||
{
|
||||
alarms = await _driver.ReadDeviceAlarmsAsync(host, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Not "no alarms": keep every condition on this device exactly as the server was last told.
|
||||
_logger.LogDebug(ex, "FOCAS alarm read failed for {Host}; its conditions keep their state", host);
|
||||
continue;
|
||||
}
|
||||
Tick(sub, host, alarms, tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
|
||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
||||
/// at High (everything else on a CNC is safety-relevant).
|
||||
/// Evaluates every subscribed condition on <paramref name="deviceHostAddress"/> against one SUCCESSFUL read
|
||||
/// and delivers what changed. Extracted so tests can drive a tick without the loop.
|
||||
/// </summary>
|
||||
/// <param name="type">FOCAS alarm type.</param>
|
||||
/// <returns>The mapped alarm severity.</returns>
|
||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
||||
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
|
||||
/// <param name="deviceHostAddress">The device read.</param>
|
||||
/// <param name="alarms">Its active alarms.</param>
|
||||
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
|
||||
internal void Tick(
|
||||
Subscription sub,
|
||||
string deviceHostAddress,
|
||||
IReadOnlyList<FocasActiveAlarm> alarms,
|
||||
IReadOnlyDictionary<string, FocasAlarmTag> tags)
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||
_ => AlarmSeverity.High,
|
||||
};
|
||||
var deviceTags = tags.Values
|
||||
.Where(t => string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var deviceSelectors = deviceTags.Select(t => t.Selector).ToList();
|
||||
var truncated = alarms.Count >= Wire.WireFocasClient.MaxAlarmsPerRead;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
internal sealed class Subscription(
|
||||
FocasAlarmSubscriptionHandle handle,
|
||||
HashSet<string>? deviceFilter,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
/// <summary>Gets the subscription handle.</summary>
|
||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
||||
/// <summary>Gets the device filter.</summary>
|
||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
||||
/// <summary>Gets the cancellation token source.</summary>
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
/// <summary>Gets the last seen alarms by device.</summary>
|
||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
lock (_alarmLock)
|
||||
{
|
||||
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
|
||||
|
||||
NoteTruncation(deviceHostAddress, truncated, alarms.Count);
|
||||
NoteUnmatched(deviceHostAddress, alarms, deviceSelectors);
|
||||
|
||||
// Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that
|
||||
// follows still sees the difference and delivers it.
|
||||
var handlers = _driver.AlarmHandlers;
|
||||
if (handlers is null) return;
|
||||
|
||||
foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath)))
|
||||
{
|
||||
var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated);
|
||||
_told.TryGetValue(tag.RawPath, out var told);
|
||||
var (evt, nowTold) = FocasAlarmConditionMapper.Transition(told, view, tag, sub.Handle, now);
|
||||
_told[tag.RawPath] = nowTold;
|
||||
if (evt is null) continue;
|
||||
|
||||
foreach (var registration in handlers.GetInvocationList())
|
||||
{
|
||||
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(_driver, evt); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"FOCAS driver caught a fault from an alarm subscriber for {ConditionId}; delivery continues.",
|
||||
tag.RawPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Warns once (per episode) when a read came back at the per-read cap. Caller holds the lock.</summary>
|
||||
private void NoteTruncation(string host, bool truncated, int count)
|
||||
{
|
||||
if (!truncated)
|
||||
{
|
||||
_truncationWarned.Remove(host);
|
||||
return;
|
||||
}
|
||||
if (_truncationWarned.Add(host))
|
||||
_logger.LogWarning(
|
||||
"FOCAS device {Host} returned {Count} active alarms — the per-read cap; more may be active and unseen. " +
|
||||
"ALARM:* / ALARM:OTHER conditions on it are marked '[alarm list truncated]'.", host, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs (once per alarm) an active alarm that no condition on its device will show — no <c>*</c>, no
|
||||
/// <c>OTHER</c> and no matching type selector. Caller holds the lock.
|
||||
/// </summary>
|
||||
private void NoteUnmatched(string host, IReadOnlyList<FocasActiveAlarm> alarms, IReadOnlyCollection<FocasAlarmSelector> selectors)
|
||||
{
|
||||
var uncovered = alarms.Where(a => !FocasAlarmMatcher.IsCovered(a, selectors))
|
||||
.Select(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
|
||||
.ToHashSet();
|
||||
if (!_unmatchedLogged.TryGetValue(host, out var logged))
|
||||
_unmatchedLogged[host] = logged = [];
|
||||
foreach (var key in uncovered.Where(k => !logged.Contains(k)))
|
||||
_logger.LogInformation(
|
||||
"FOCAS device {Host} has active alarm {Number} (type {Type}) that no ALARM: tag selects — author " +
|
||||
"ALARM:OTHER or ALARM:* on the device to surface it.", host, key.Number, key.Type);
|
||||
// Remember exactly the currently-uncovered set, so an alarm that clears and comes back is logged again.
|
||||
_unmatchedLogged[host] = uncovered;
|
||||
}
|
||||
|
||||
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
|
||||
/// <param name="Handle">The handle.</param>
|
||||
/// <param name="References">The subscribed RawPaths.</param>
|
||||
internal sealed record Subscription(FocasAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
/// <summary>Handle returned by <see cref="FocasDriver.SubscribeAlarmsAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -39,6 +39,9 @@ public static class FocasCapabilityMatrix
|
||||
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||
FocasAreaKind.Fixed => null,
|
||||
// An ALARM: selector reads the device's whole active-alarm list (cnc_rdalmmsg2), which every
|
||||
// series exposes; the selector filters it driver-side, so there is no range to check (#647).
|
||||
FocasAreaKind.Alarm => null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +73,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// as the way to publish it without authoring anything.
|
||||
/// </remarks>
|
||||
private bool _fixedTreeActive;
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
/// <summary>
|
||||
/// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because
|
||||
/// it holds what the server was last told about every condition, and the server's condition nodes outlive
|
||||
/// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm.
|
||||
/// </summary>
|
||||
private readonly FocasAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>
|
||||
/// RawPath → authored <c>ALARM:</c> tag (Gitea #647). Built in <see cref="InitializeAsync"/> and swapped in
|
||||
/// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter
|
||||
/// <see cref="_tagsByRawPath"/>: they have no value to read.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, FocasAlarmTag> _alarmTags =
|
||||
new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
||||
// record is immutable so there is no torn-read risk on the object itself.
|
||||
@@ -113,6 +126,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_rebind = rebind;
|
||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||
_alarmProjection = new FocasAlarmProjection(this, _logger);
|
||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
_poll = new PollGroupEngine(
|
||||
@@ -218,6 +232,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
||||
// that fails fast (Driver.FOCAS-003).
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
// A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config
|
||||
// removed — or moved between the value and alarm tables (#647) — must not survive in the old one.
|
||||
_tagsByRawPath.Clear();
|
||||
_parsedAddressesByTagName.Clear();
|
||||
var alarmTags = new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
@@ -260,6 +279,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
entry.RawPath, def.Address, reason, _driverInstanceId);
|
||||
continue;
|
||||
}
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object
|
||||
// is what makes the host materialise a condition at this RawPath, the address is the only thing
|
||||
// this driver can drive one from. Either alone does nothing, so it is skipped and reported.
|
||||
if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null)
|
||||
{
|
||||
if (parsed.Kind == FocasAreaKind.Alarm && def.Alarm is not null)
|
||||
{
|
||||
alarmTags[entry.RawPath] = new FocasAlarmTag(
|
||||
entry.RawPath, deviceHost, parsed.AlarmSelector!, def.Alarm.Severity);
|
||||
continue;
|
||||
}
|
||||
var why = parsed.Kind == FocasAreaKind.Alarm
|
||||
? $"'{def.Address}' is an alarm selector but the tag has no 'alarm' object"
|
||||
: $"the tag carries an 'alarm' object but '{def.Address}' is not an ALARM: selector";
|
||||
_skippedTags.Skip(entry.RawPath, why);
|
||||
_logger.LogWarning("FOCAS tag {RawPath}: {Reason}; skipping. Driver={DriverInstanceId}",
|
||||
entry.RawPath, why, _driverInstanceId);
|
||||
continue;
|
||||
}
|
||||
_tagsByRawPath[entry.RawPath] = def with
|
||||
{
|
||||
DeviceHostAddress = deviceHost,
|
||||
@@ -274,6 +312,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// leaves the previous snapshot standing rather than a truncated one that would read as
|
||||
// "we now skip fewer tags".
|
||||
_skippedTags.Commit();
|
||||
Volatile.Write(ref _alarmTags, alarmTags);
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
@@ -295,9 +334,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
||||
|
||||
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
||||
// config flag defaults off and the operator who committed those tags has already said
|
||||
// what they want.
|
||||
@@ -313,6 +349,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A
|
||||
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
|
||||
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
|
||||
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -329,11 +369,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
// Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
if (_devices.Count == 0) return;
|
||||
var loops = new List<Task>();
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -386,11 +423,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
// Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
||||
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
|
||||
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
|
||||
@@ -432,6 +466,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByRawPath.Clear();
|
||||
Volatile.Write(ref _alarmTags, new Dictionary<string, FocasAlarmTag>(StringComparer.Ordinal));
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_parsedAddressesByTagName.Clear();
|
||||
_fixedTreeActive = false;
|
||||
@@ -523,6 +558,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read.
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_resolver.TryResolve(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -722,6 +764,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647).
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_resolver.TryResolve(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
|
||||
@@ -1613,57 +1661,52 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm
|
||||
/// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647).
|
||||
/// Never throws for a disabled flag: authoring an <c>ALARM:</c> tag is the opt-in, and a RawPath that is
|
||||
/// not an <c>ALARM:</c> tag of this driver is simply never raised.
|
||||
/// </remarks>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmProjection is null)
|
||||
throw new NotSupportedException(
|
||||
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
||||
_alarmProjection.SubscribeAsync(sourceNodeIds);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
||||
_alarmProjection.UnsubscribeAsync(handle);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Read-only: nothing is written to the CNC. See <see cref="FocasAlarmProjection.AcknowledgeAsync"/>.</remarks>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
FocasAlarmProjection.AcknowledgeAsync(acknowledgements);
|
||||
|
||||
/// <summary>Raises an alarm event with the provided arguments.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
/// <summary>Gets the attached alarm handlers, or <see langword="null"/> when nobody is listening.</summary>
|
||||
internal EventHandler<AlarmEventArgs>? AlarmHandlers => OnAlarmEvent;
|
||||
|
||||
/// <summary>Gets the authored <c>ALARM:</c> tags (RawPath → tag). Test seam + projection input.</summary>
|
||||
internal IReadOnlyDictionary<string, FocasAlarmTag> AlarmTags => Volatile.Read(ref _alarmTags);
|
||||
|
||||
/// <summary>Gets the alarm projection. Test seam.</summary>
|
||||
internal FocasAlarmProjection AlarmProjection => _alarmProjection;
|
||||
|
||||
/// <summary>Gets the alarm poll cadence — one <c>cnc_rdalmmsg2</c> per alarm-owning device per tick.</summary>
|
||||
internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval;
|
||||
|
||||
/// <summary>
|
||||
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
|
||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
||||
/// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
|
||||
/// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
|
||||
/// list (Gitea #647).
|
||||
/// </summary>
|
||||
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
|
||||
/// <param name="hostAddress">The device host address.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
|
||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
||||
/// <returns>The device's active alarms.</returns>
|
||||
internal async Task<IReadOnlyList<FocasActiveAlarm>> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
|
||||
{
|
||||
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
result.Add((state.Options.HostAddress, alarms));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* surface a device-local fault on the next tick */
|
||||
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
if (!_devices.TryGetValue(hostAddress, out var state))
|
||||
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
@@ -65,12 +65,18 @@ public interface IFocasClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
||||
/// zero-or-more active alarms. Null / empty list means "no alarms currently
|
||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is
|
||||
/// active". The alarm projection polls this per device and diffs it into Part 9
|
||||
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
|
||||
/// <para>
|
||||
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
|
||||
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
|
||||
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||
/// <exception cref="Exception">Any failure to obtain the list — the session is down, the CNC refused, the wire faulted.</exception>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -446,27 +452,3 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
/// <inheritdoc />
|
||||
public IFocasClient Create() => throw new NotSupportedException(Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||
/// <c>cnc_rdalmmsg2</c> (and so <see cref="FocasAlarmProjection"/>'s <c>switch (short)</c>
|
||||
/// statements compile against a matching type rather than relying on implicit int→short
|
||||
/// narrowing on the constants).
|
||||
/// </remarks>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
||||
public const short All = -1;
|
||||
public const short Parameter = 0; // ALM_P
|
||||
public const short PulseCode = 1; // ALM_Y (servo)
|
||||
public const short Overtravel = 2; // ALM_O
|
||||
public const short Overheat = 3; // ALM_H
|
||||
public const short Servo = 4; // ALM_S
|
||||
public const short DataIo = 5; // ALM_T
|
||||
public const short MemoryCheck = 6; // ALM_M
|
||||
public const short MacroAlarm = 13; // ALM_MC
|
||||
}
|
||||
|
||||
@@ -115,19 +115,18 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Gitea #647: every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
||||
/// alike. This used to answer <c>[]</c> for all three, which the alarm projection could not tell
|
||||
/// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
||||
/// un-acknowledged. <see cref="FocasWireException"/> propagates as-is.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_wire.IsConnected) return [];
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null) return [];
|
||||
return result.Value.Select(Map).ToList();
|
||||
}
|
||||
catch (FocasWireException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_rdalmmsg2", result.IsOk);
|
||||
return (result.Value ?? []).Select(Map).ToList();
|
||||
|
||||
static FocasActiveAlarm Map(WireAlarm a) => new(
|
||||
AlarmNumber: a.AlarmNumber,
|
||||
@@ -468,6 +467,12 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records requested per <c>cnc_rdalmmsg2</c> call. A reply carrying exactly this many may have been
|
||||
/// truncated by the CNC; the alarm projection warns about it (Gitea #647).
|
||||
/// </summary>
|
||||
internal const short MaxAlarmsPerRead = 32;
|
||||
|
||||
private void RequireConnected()
|
||||
{
|
||||
if (!_wire.IsConnected)
|
||||
|
||||
+2
-2
@@ -66,13 +66,13 @@
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||
<div class="form-text">Legacy flag, no longer required (#647): authoring an <code>ALARM:</code> tag with native-alarm settings is the opt-in.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per tick for each device that owns an <code>ALARM:</code> tag. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+53
-10
@@ -1,6 +1,7 @@
|
||||
@* FOCAS address picker:
|
||||
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
||||
(PARAM:1020) and macro variable (MACRO:500) — always available.
|
||||
(PARAM:1020) and macro variable (MACRO:500) — plus a CNC alarm selector (ALARM:*,
|
||||
ALARM:Servo/401-453, ALARM:OTHER — Gitea #647) — always available.
|
||||
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
||||
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
||||
@@ -27,9 +28,41 @@
|
||||
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
||||
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
||||
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
||||
<option value="@FocasManualAddressKind.Alarm">CNC alarm (condition)</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_kind == FocasManualAddressKind.Pmc)
|
||||
@if (_kind == FocasManualAddressKind.Alarm)
|
||||
{
|
||||
@* Gitea #647: an ALARM: selector — one Part 9 condition per tag. The tag must also carry the native-alarm
|
||||
object (alarm section of the tag modal), or it is neither a condition nor a value. *@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Alarms</label>
|
||||
<select class="form-select form-select-sm" @bind="_alarmScope" @bind:after="OnChangedAsync">
|
||||
@foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||
{
|
||||
<option value="@scope">@(scope == "*" ? "* (any on device)" : scope == "OTHER" ? "OTHER (unmatched)" : scope)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_alarmScope is not ("*" or "OTHER"))
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Number <span class="text-muted small">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" min="0" placeholder="any"
|
||||
@bind="_alarmLow" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">to <span class="text-muted small">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" min="0" placeholder="single"
|
||||
@bind="_alarmHigh" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-12 small text-muted">
|
||||
Needs the tag's native-alarm settings enabled. Type names follow the 16i-era FOCAS table; on a
|
||||
30i-series control type a raw code (e.g. <code>ALARM:T9</code>) into the address field instead.
|
||||
</div>
|
||||
}
|
||||
else if (_kind == FocasManualAddressKind.Pmc)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Area</label>
|
||||
@@ -41,12 +74,15 @@
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
@if (_kind != FocasManualAddressKind.Macro)
|
||||
@if (_kind != FocasManualAddressKind.Alarm)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
@if (_kind is not (FocasManualAddressKind.Macro or FocasManualAddressKind.Alarm))
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">
|
||||
@@ -122,6 +158,9 @@
|
||||
private string _pmcLetter = "R";
|
||||
private int _number = 0;
|
||||
private int? _bitIndex;
|
||||
private string _alarmScope = "*";
|
||||
private int? _alarmLow;
|
||||
private int? _alarmHigh;
|
||||
private string _built = "";
|
||||
|
||||
private Guid _token = Guid.Empty;
|
||||
@@ -135,7 +174,7 @@
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
_built = Compose();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
@@ -177,10 +216,14 @@
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
_built = Compose();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private string Compose() => _kind == FocasManualAddressKind.Alarm
|
||||
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
|
||||
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
|
||||
+34
@@ -18,6 +18,12 @@ public enum FocasManualAddressKind
|
||||
|
||||
/// <summary>Macro variable number.</summary>
|
||||
Macro,
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the CNC's active alarm list (<c>ALARM:</c>, Gitea #647). Only useful on a tag that also
|
||||
/// carries the native-alarm object — the pair is what becomes a Part 9 condition.
|
||||
/// </summary>
|
||||
Alarm,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +49,34 @@ public static class FocasAddressBuilder
|
||||
public static IReadOnlyList<string> PmcLetters { get; } =
|
||||
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
||||
|
||||
/// <summary>
|
||||
/// The alarm scopes the picker offers, in order: the device summary (<c>*</c>), the unmatched bucket
|
||||
/// (<c>OTHER</c>), then every named alarm type. A raw <c>T<code></c> type is typed by hand.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AlarmScopes { get; } =
|
||||
[FocasAlarmSelector.AnyToken, FocasAlarmSelector.OtherToken, .. FocasAlarmType.Names];
|
||||
|
||||
/// <summary>
|
||||
/// Composes a canonical <c>ALARM:</c> address, or an empty string when the parts are not a selector the
|
||||
/// driver accepts. Numbers apply to a type scope only; <paramref name="numberHigh"/> without
|
||||
/// <paramref name="numberLow"/> is ignored, and a range equal at both ends collapses to one number.
|
||||
/// </summary>
|
||||
/// <param name="scope">An entry of <see cref="AlarmScopes"/>, or a raw type such as <c>T9</c>.</param>
|
||||
/// <param name="numberLow">The alarm number, or the low end of a range; null for every number.</param>
|
||||
/// <param name="numberHigh">The high end of a range; null for a single number.</param>
|
||||
/// <returns>The canonical address, or an empty string.</returns>
|
||||
public static string BuildAlarm(string? scope, int? numberLow, int? numberHigh)
|
||||
{
|
||||
var body = (scope ?? "").Trim();
|
||||
var isType = body.Length > 0 && body != FocasAlarmSelector.AnyToken
|
||||
&& !string.Equals(body, FocasAlarmSelector.OtherToken, StringComparison.OrdinalIgnoreCase);
|
||||
if (isType && numberLow is { } lo)
|
||||
body += numberHigh is { } hi ? $"/{lo}-{hi}" : $"/{lo}";
|
||||
|
||||
// The same round-trip as Build: the parser decides, and an unusable selector shows as "no address yet".
|
||||
return FocasAddress.TryParse(FocasAddress.AlarmPrefix + body)?.Canonical ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
||||
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed class FocasTagConfigModel
|
||||
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
|
||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>,
|
||||
/// <c>ALARM:Servo/401</c>. Required.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>FOCAS atomic data type.</summary>
|
||||
@@ -98,8 +99,12 @@ public sealed class FocasTagConfigModel
|
||||
/// <param name="address">The rejected address.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string DescribeUnparseableAddress(string address) =>
|
||||
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
// An ALARM: address gets its selector's own reason — the same text the deploy-time inspector gives (#647).
|
||||
FocasAddress.DescribeAlarmAddressError(address) is { } alarmError
|
||||
? $"'{address.Trim()}' is not a valid FOCAS alarm address: {alarmError}."
|
||||
: $"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber), or a CNC alarm selector (ALARM:*, ALARM:Servo/401-453, " +
|
||||
"ALARM:OTHER). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
}
|
||||
|
||||
+39
-17
@@ -154,9 +154,13 @@ public sealed class WireBackendCoverageTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm raise then clear emits both events via the wire backend.</summary>
|
||||
/// <summary>
|
||||
/// Gitea #647 — CNC alarms injected into the mock drive ONE condition per <c>ALARM:</c> tag, keyed by the tag's
|
||||
/// RawPath, with a <see cref="AlarmTransitionKind"/>: an Overtravel alarm raises both <c>ALARM:Overtravel</c>
|
||||
/// and <c>ALARM:*</c>; a second (servo) alarm retriggers only the summary; clearing the list clears both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
|
||||
public async Task Alarm_selectors_raise_retrigger_and_clear_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -165,16 +169,15 @@ public sealed class WireBackendCoverageTests
|
||||
// Start with no active alarms.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
|
||||
const string overtravelPath = "Plant/Cnc/m1/overtravel";
|
||||
const string anyPath = "Plant/Cnc/m1/any";
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = [],
|
||||
RawTags = [AlarmTag(overtravelPath, "ALARM:Overtravel"), AlarmTag(anyPath, "ALARM:*")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(200) },
|
||||
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
@@ -183,27 +186,46 @@ public sealed class WireBackendCoverageTests
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], ct);
|
||||
var sub = await drv.SubscribeAlarmsAsync([overtravelPath, anyPath], ct);
|
||||
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" } },
|
||||
}, ct);
|
||||
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Raise) == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Raise one alarm.
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[]
|
||||
{
|
||||
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
new { alm_no = 401, type = 4, axis = 2, msg = "TEST SERVO" },
|
||||
},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
|
||||
await WaitFor(() => Snapshot(events).Any(e => e.Kind == AlarmTransitionKind.Retrigger), TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
|
||||
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Clear) == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(DeviceHost);
|
||||
var all = Snapshot(events);
|
||||
all.Where(e => e.ConditionId == overtravelPath).Select(e => e.Kind).ToArray()
|
||||
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear });
|
||||
all.Where(e => e.ConditionId == anyPath).Select(e => e.Kind).ToArray()
|
||||
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear });
|
||||
all[0].AlarmType.ShouldBe("Overtravel");
|
||||
all[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
all.ShouldAllBe(e => e.SourceNodeId == DeviceHost);
|
||||
}
|
||||
|
||||
RawTagEntry AlarmTag(string rawPath, string address) => new(rawPath,
|
||||
"{\"address\":\"" + address + "\",\"dataType\":\"Int32\",\"alarm\":{\"alarmType\":\"AlarmCondition\"}}",
|
||||
WriteIdempotent: false, DeviceName: DeviceHost);
|
||||
|
||||
static List<AlarmEventArgs> Snapshot(List<AlarmEventArgs> events)
|
||||
{
|
||||
lock (events) return [.. events];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,10 +123,29 @@ internal class FakeFocasClient : IFocasClient
|
||||
/// <summary>Gets the list of active alarms.</summary>
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the alarm read throws (Gitea #647: a failed read).</summary>
|
||||
public bool ThrowOnReadAlarms { get; set; }
|
||||
|
||||
/// <summary>Gets the number of alarm reads served (successful or not).</summary>
|
||||
public int AlarmReadCount => Volatile.Read(ref _alarmReadCount);
|
||||
|
||||
private int _alarmReadCount;
|
||||
|
||||
/// <summary>Replaces the active alarm list atomically with respect to the poll thread.</summary>
|
||||
/// <param name="alarms">The new active alarms.</param>
|
||||
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||
{
|
||||
lock (Alarms) { Alarms.Clear(); Alarms.AddRange(alarms); }
|
||||
}
|
||||
|
||||
/// <summary>Reads active alarms asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _alarmReadCount);
|
||||
if (ThrowOnReadAlarms) throw new InvalidOperationException("cnc_rdalmmsg2 failed (fake)");
|
||||
lock (Alarms) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
}
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
/// <summary>Gets or sets the system information.</summary>
|
||||
|
||||
@@ -1,196 +1,439 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 — FOCAS CNC alarms as Part 9 conditions at <c>ALARM:</c> raw tags. Every event must carry
|
||||
/// <c>ConditionId == RawPath</c> and a <see cref="AlarmTransitionKind"/>, or the host drops / ignores it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
private const string Host2 = "focas://10.0.0.6:8193";
|
||||
private const string ServoPath = "Plant/Cnc/m1/servo";
|
||||
private const string AnyPath = "Plant/Cnc/m1/any";
|
||||
private const string OtherPath = "Plant/Cnc/m1/other";
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
||||
private static FocasActiveAlarm Servo401(short axis = 1) =>
|
||||
new(401, FocasAlarmType.Servo, axis, "SERVO V-READY OFF");
|
||||
|
||||
private static FocasActiveAlarm Overtravel506 => new(506, FocasAlarmType.Overtravel, 2, "OVER TRAVEL +2");
|
||||
|
||||
private static FocasActiveAlarm Overheat700 => new(700, FocasAlarmType.Overheat, 0, "OVERHEAT: CONTROL UNIT");
|
||||
|
||||
private static string AlarmTagConfig(string address, int? severity = null) =>
|
||||
JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["address"] = address,
|
||||
["dataType"] = "Int32",
|
||||
["alarm"] = severity is { } s
|
||||
? new Dictionary<string, object> { ["alarmType"] = "AlarmCondition", ["severity"] = s }
|
||||
: new Dictionary<string, object> { ["alarmType"] = "AlarmCondition" },
|
||||
});
|
||||
|
||||
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
|
||||
params RawTagEntry[] tags) => NewDriver([new FocasDeviceOptions(Host, DeviceName: "m1")], tags);
|
||||
|
||||
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
|
||||
IReadOnlyList<FocasDeviceOptions> devices, params RawTagEntry[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
RawTags = [],
|
||||
Devices = [.. devices],
|
||||
RawTags = [.. tags],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = alarmsEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
||||
},
|
||||
// Enabled is left at its default (false) on purpose: authoring an ALARM: tag is the opt-in (#647).
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe without enable throws NotSupported.</summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
||||
private static RawTagEntry Tag(string rawPath, string address, int? severity = null, string device = "m1") =>
|
||||
new(rawPath, AlarmTagConfig(address, severity), WriteIdempotent: false, DeviceName: device);
|
||||
|
||||
private static FakeFocasClient ClientOf(FakeFocasClientFactory factory) => factory.Clients.Single();
|
||||
|
||||
/// <summary>Drives one tick for the driver's live subscription, the way the poll loop would.</summary>
|
||||
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
|
||||
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
|
||||
|
||||
private static List<AlarmEventArgs> Capture(FocasDriver drv)
|
||||
{
|
||||
var (drv, _) = NewDriver(alarmsEnabled: false);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that raise then clear emits both events.</summary>
|
||||
[Fact]
|
||||
public async Task Raise_then_clear_emits_both_events()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
||||
// poke the alarm list so we don't race the poll loop.
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
var client = factory.Clients[0];
|
||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
||||
|
||||
// Clear — the clear event wraps the original message with "(cleared)".
|
||||
client.Alarms.Clear();
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(Host);
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
|
||||
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first
|
||||
/// calling Unsubscribe, and the driver object (and its projection) survives the in-place
|
||||
/// reconnect. Each SubscribeAlarmsAsync used to start a fresh, never-cancelled poll loop —
|
||||
/// so after N reconnects there were N live loops all polling the device and all firing the
|
||||
/// same raise/clear transition, producing DUPLICATE alarm events.
|
||||
///
|
||||
/// This test simulates two re-subscribes (one reconnect) against the same source, then
|
||||
/// drives ONE device raise. After the collapse-to-single-loop fix exactly one loop is alive
|
||||
/// so the raise must fire exactly once. Before the fix both loops fire it ⇒ two events.
|
||||
/// A driver whose loop is not polling, so ticks are driven by the test only. The subscription is real; the
|
||||
/// loop is paused right after so its first read cannot race the hand-driven ticks.
|
||||
/// </summary>
|
||||
private static async Task<FocasDriver> InitAndSubscribeForTicksAsync(
|
||||
FocasDriver drv, FakeFocasClientFactory factory, params string[] refs)
|
||||
{
|
||||
// The real loop still starts on every subscribe; a CNC whose alarm read always fails makes its ticks
|
||||
// no-ops (a failed read keeps state), so only the hand-driven ticks below move anything.
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync(refs, CancellationToken.None);
|
||||
await drv.AlarmProjection.PauseAsync();
|
||||
return drv;
|
||||
}
|
||||
|
||||
// ---- the transition contract ------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Raise_then_unchanged_then_clear_carries_RawPath_and_Kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Servo401());
|
||||
Tick(drv, Servo401());
|
||||
Tick(drv);
|
||||
|
||||
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||
events.ShouldAllBe(e => e.ConditionId == ServoPath && e.SourceNodeId == Host);
|
||||
events[0].AlarmType.ShouldBe("Servo");
|
||||
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axis 1)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overlapping_selectors_all_fire_and_OTHER_only_takes_what_no_type_selector_claims()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"), Tag(OtherPath, "ALARM:OTHER"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath, OtherPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Servo401());
|
||||
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath },
|
||||
customMessage: "the servo alarm drives both ALARM:Servo and ALARM:*; ALARM:* does not claim it away from OTHER, but ALARM:Servo does");
|
||||
|
||||
events.Clear();
|
||||
Tick(drv, Servo401(), Overheat700);
|
||||
events.ShouldContain(e => e.ConditionId == OtherPath && e.Kind == AlarmTransitionKind.Raise,
|
||||
"an overheat alarm no type selector claims lands in OTHER");
|
||||
events.ShouldContain(e => e.ConditionId == AnyPath && e.Kind == AlarmTransitionKind.Retrigger);
|
||||
events.ShouldNotContain(e => e.ConditionId == ServoPath, "ALARM:Servo's matched set did not change");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_key_retriggers_while_a_non_displayed_member_leaving_emits_nothing()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Servo401()); // Raise — Servo is the winner (Critical)
|
||||
Tick(drv, Servo401(), Overheat700); // Retrigger — a key the operator has not seen
|
||||
Tick(drv, Servo401()); // the non-displayed Overheat left: nothing
|
||||
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger]);
|
||||
events[1].Message.ShouldEndWith("[+1 more]");
|
||||
|
||||
events.Clear();
|
||||
Tick(drv, Servo401(), Overheat700); // Overheat back: new to the set again ⇒ Retrigger
|
||||
Tick(drv, Overheat700); // the DISPLAYED alarm left: retrigger with the next one down
|
||||
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Retrigger]);
|
||||
events[1].AlarmType.ShouldBe("Overheat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reordered_list_never_re_raises_and_the_displayed_alarm_is_the_most_severe()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Overheat700, Servo401(), Overtravel506);
|
||||
Tick(drv, Overtravel506, Servo401(), Overheat700);
|
||||
Tick(drv, Servo401(), Overheat700, Overtravel506);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
// Overtravel and Servo are both Critical; the tie goes to the lower type code (Overtravel = 2).
|
||||
events[0].AlarmType.ShouldBe("Overtravel");
|
||||
events[0].Message.ShouldStartWith("506 OVER TRAVEL +2 (type 2 Overtravel, axis 2)");
|
||||
events[0].Message.ShouldEndWith("[+2 more]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_alarm_number_on_two_axes_is_one_alarm_listing_both_axes()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo/401-453"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Servo401(axis: 2), Servo401(axis: 1));
|
||||
Tick(drv, Servo401(axis: 1)); // the same key on fewer axes is the same alarm: nothing
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axes 1, 2)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Severity_is_the_authored_value_when_present_else_the_type_bucket()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo", severity: 350), Tag(AnyPath, "ALARM:*"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, Servo401());
|
||||
|
||||
var authored = events.Single(e => e.ConditionId == ServoPath);
|
||||
authored.OpcUaSeverity.ShouldBe(350);
|
||||
authored.Severity.ShouldBe(AlarmSeverity.Low);
|
||||
|
||||
var derived = events.Single(e => e.ConditionId == AnyPath);
|
||||
derived.OpcUaSeverity.ShouldBeNull();
|
||||
derived.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
FocasAlarmConditionMapper.TypeSeverity(42).ShouldBe(AlarmSeverity.High, "an unknown code is High, not ignored");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unnamed_type_code_is_shown_raw_so_it_can_be_selected()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"), Tag(ServoPath, "ALARM:T9"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath, ServoPath);
|
||||
var events = Capture(drv);
|
||||
|
||||
Tick(drv, new FocasActiveAlarm(750, 9, 0, "SPINDLE ALARM"));
|
||||
|
||||
events.Count.ShouldBe(2);
|
||||
events.ShouldAllBe(e => e.AlarmType == "Type9" && e.Message == "750 SPINDLE ALARM (type 9)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_superseded_subscription_emits_nothing()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
var stale = drv.AlarmProjection.Current!;
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await drv.AlarmProjection.PauseAsync();
|
||||
var events = Capture(drv);
|
||||
|
||||
drv.AlarmProjection.Tick(stale, Host, [Servo401()], drv.AlarmTags);
|
||||
|
||||
events.ShouldBeEmpty("a retired loop finishing its last read must not deliver");
|
||||
}
|
||||
|
||||
// ---- the told record --------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_is_recorded_while_no_handler_is_attached()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
|
||||
Tick(drv, Servo401()); // nobody listening (the host detached while reconnecting)
|
||||
var events = Capture(drv);
|
||||
Tick(drv, Servo401());
|
||||
|
||||
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise],
|
||||
"the raise that happened while detached is delivered once a handler is back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_same_set_resubscribe_re_raises_nothing_and_a_changed_set_prunes_only_the_leavers()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||
var events = Capture(drv);
|
||||
Tick(drv, Servo401());
|
||||
events.Count.ShouldBe(2);
|
||||
|
||||
// A reconnect: the actor re-subscribes the SAME set.
|
||||
events.Clear();
|
||||
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
|
||||
await drv.AlarmProjection.PauseAsync();
|
||||
Tick(drv, Servo401());
|
||||
events.ShouldBeEmpty("re-raising an unchanged alarm would un-acknowledge it");
|
||||
|
||||
// A deploy drops AnyPath, then a later one brings it back: only AnyPath is told again.
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
|
||||
await drv.AlarmProjection.PauseAsync();
|
||||
Tick(drv, Servo401());
|
||||
events.Select(e => e.ConditionId).ShouldBe([AnyPath]);
|
||||
events[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
}
|
||||
|
||||
// ---- the poll loop, end to end over the fake client -------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 defect 4 — a failed read keeps state. Pincer: a throwing read must NOT clear, and (the
|
||||
/// falsification control) a genuinely empty read MUST — so the first half cannot pass vacuously.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Resubscribe_collapses_to_single_loop_no_duplicate_raise()
|
||||
public async Task A_failed_read_keeps_the_condition_active_while_an_empty_read_clears_it()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var events = Capture(drv);
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0);
|
||||
var client = ClientOf(factory);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
// Count only the raise (the initial 0->1) — clears wrap the message in "(cleared)".
|
||||
if (!e.Message.Contains("cleared")) lock (raises) raises.Add(e);
|
||||
};
|
||||
client.SetAlarms(Servo401());
|
||||
await WaitFor(() => Count(events) == 1);
|
||||
|
||||
// First subscribe creates + connects the per-device client and starts polling. Wait for
|
||||
// it + a couple empty-list ticks so the loop's LastByDevice baseline is seeded empty.
|
||||
var sub1 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(120);
|
||||
client.ThrowOnReadAlarms = true;
|
||||
var readsAtFailure = client.AlarmReadCount;
|
||||
await WaitFor(() => client.AlarmReadCount >= readsAtFailure + 5);
|
||||
Count(events).ShouldBe(1, "a failed read is not an empty list");
|
||||
|
||||
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reuses the same
|
||||
// connected DeviceState.Client (EnsureConnectedAsync), so it polls the same alarm list.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await Task.Delay(120); // let sub2's loop seed its own empty LastByDevice baseline
|
||||
client.ThrowOnReadAlarms = false;
|
||||
client.SetAlarms();
|
||||
await WaitFor(() => Count(events) == 2);
|
||||
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
|
||||
// One device raise transition. Add to every created client so that whichever instance the
|
||||
// live loop(s) poll, the raise is observed (EnsureConnectedAsync may have churned clients).
|
||||
foreach (var c in factory.Clients)
|
||||
c.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
|
||||
// Wait until at least one raise is observed, then give any leaked second loop ample time
|
||||
// (well past several 30ms poll intervals) to ALSO fire its duplicate before we assert.
|
||||
await WaitFor(() => { lock (raises) return raises.Count > 0; }, TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(200);
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
lock (raises)
|
||||
{
|
||||
raises.Count.ShouldBe(1,
|
||||
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
|
||||
public async Task A_reinitialise_neither_re_raises_nor_loses_the_subscription()
|
||||
{
|
||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
||||
// subscription — simpler than standing up the full loop.
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var events = Capture(drv);
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0);
|
||||
factory.Clients[0].SetAlarms(Servo401());
|
||||
await WaitFor(() => Count(events) == 1);
|
||||
|
||||
// A re-initialise without teardown (#630) builds a fresh client; the CNC still reports the alarm.
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.SetAlarms(Servo401());
|
||||
return c;
|
||||
};
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 1 && factory.Clients[^1].AlarmReadCount >= 3);
|
||||
Count(events).ShouldBe(1, "the server was already told; a re-raise would un-acknowledge it");
|
||||
|
||||
factory.Clients[^1].SetAlarms();
|
||||
await WaitFor(() => Count(events) == 2);
|
||||
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear, "the poll resumed after the re-initialise");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_devices_that_own_a_subscribed_alarm_tag_are_polled()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
[new FocasDeviceOptions(Host, DeviceName: "m1"), new FocasDeviceOptions(Host2, DeviceName: "m2")],
|
||||
Tag(ServoPath, "ALARM:Servo"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
|
||||
|
||||
factory.Clients.Count.ShouldBe(1, "the second device owns no alarm tag, so it is never connected for alarms");
|
||||
drv.GetDeviceState(Host2)!.Client.ShouldBeNull();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribing_with_the_projection_flag_off_does_not_throw()
|
||||
{
|
||||
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
||||
var sub = new FocasAlarmProjection.Subscription(
|
||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
||||
new CancellationTokenSource());
|
||||
var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Tick 1 — raise two alarms.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(2);
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
|
||||
// Tick 2 — same alarms stay active → no new events.
|
||||
events.Clear();
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldEndWith("(cleared)");
|
||||
events[0].AlarmType.ShouldBe("Parameter");
|
||||
handle.ShouldNotBeNull();
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that severity mapping matches docs.</summary>
|
||||
// ---- authoring at init ------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_matches_docs()
|
||||
public async Task Alarm_tags_are_conditions_not_values_and_half_authored_ones_are_skipped_and_reported()
|
||||
{
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
var noObject = new RawTagEntry("Plant/Cnc/m1/noobj",
|
||||
JsonSerializer.Serialize(new { address = "ALARM:Servo", dataType = "Int32" }), false, "m1");
|
||||
var pmcWithObject = new RawTagEntry("Plant/Cnc/m1/pmc",
|
||||
JsonSerializer.Serialize(new { address = "R100", dataType = "Byte", alarm = new { severity = 700 } }), false, "m1");
|
||||
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo/401", severity: 800), noObject, pmcWithObject);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.AlarmTags.Keys.ShouldBe([ServoPath]);
|
||||
drv.AlarmTags[ServoPath].AuthoredSeverity.ShouldBe(800);
|
||||
drv.AlarmTags[ServoPath].Selector.Canonical.ShouldBe("Servo/401");
|
||||
|
||||
var skipped = drv.GetSkippedTags()!;
|
||||
skipped.Sample.Select(s => s.RawPath).Order().ToArray().ShouldBe(new[] { "Plant/Cnc/m1/noobj", "Plant/Cnc/m1/pmc" });
|
||||
|
||||
var read = await drv.ReadAsync([ServoPath], CancellationToken.None);
|
||||
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||
var write = await drv.WriteAsync([new WriteRequest(ServoPath, 1)], CancellationToken.None);
|
||||
write[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
/// <summary>
|
||||
/// A re-initialise without teardown (#630) adopting a config that turns a value tag into an alarm tag, or
|
||||
/// drops it, must not leave the old value-table entry serving reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reinitialise_rebuilds_the_value_and_alarm_tables_from_scratch()
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
const string path = "Plant/Cnc/m1/t";
|
||||
FocasDriverOptions Options(params RawTagEntry[] tags) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||
RawTags = [.. tags],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
};
|
||||
var asValue = Options(new RawTagEntry(path, """{"address":"R100","dataType":"Byte"}""", false, "m1"));
|
||||
var asAlarm = Options(Tag(path, "ALARM:*"));
|
||||
var removed = Options();
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(asValue, "drv-1", factory,
|
||||
rebind: json => new FocasDriverBinding(json switch
|
||||
{
|
||||
"""{"v":"alarm"}""" => asAlarm,
|
||||
"""{"v":"removed"}""" => removed,
|
||||
_ => asValue,
|
||||
}, factory));
|
||||
|
||||
await drv.InitializeAsync("""{"v":"value"}""", CancellationToken.None);
|
||||
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.InitializeAsync("""{"v":"alarm"}""", CancellationToken.None);
|
||||
drv.AlarmTags.Keys.ShouldBe([path]);
|
||||
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||
|
||||
await drv.InitializeAsync("""{"v":"removed"}""", CancellationToken.None);
|
||||
drv.AlarmTags.ShouldBeEmpty();
|
||||
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown,
|
||||
"a tag the new config removed must not keep reading");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static int Count(List<AlarmEventArgs> events)
|
||||
{
|
||||
lock (events) return events.Count;
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(30);
|
||||
await Task.Delay(10);
|
||||
}
|
||||
pred().ShouldBeTrue("condition not reached within the timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #647 — the <c>ALARM:</c> address grammar, shared by the editor, the deploy gate and the driver.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmSelectorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ALARM:*", "ALARM:*")]
|
||||
[InlineData("alarm:other", "ALARM:OTHER")]
|
||||
[InlineData("ALARM:servo", "ALARM:Servo")]
|
||||
[InlineData("ALARM:Servo/401", "ALARM:Servo/401")]
|
||||
[InlineData("ALARM:SERVO/401-453", "ALARM:Servo/401-453")]
|
||||
[InlineData("ALARM:Servo/401-401", "ALARM:Servo/401")]
|
||||
[InlineData("ALARM:T9", "ALARM:T9")]
|
||||
[InlineData("ALARM:t9/750-758", "ALARM:T9/750-758")]
|
||||
[InlineData(" ALARM:MacroAlarm/3000 ", "ALARM:MacroAlarm/3000")]
|
||||
public void Valid_selectors_parse_to_their_canonical_form(string address, string canonical)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(address);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
parsed.Canonical.ShouldBe(canonical);
|
||||
FocasAddress.TryParse(parsed.Canonical)!.Canonical.ShouldBe(canonical, "canonical must round-trip");
|
||||
FocasAddress.DescribeAlarmAddressError(address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ALARM:")]
|
||||
[InlineData("ALARM:Bogus")]
|
||||
[InlineData("ALARM:Servo/")]
|
||||
[InlineData("ALARM:Servo/x")]
|
||||
[InlineData("ALARM:Servo/-5")]
|
||||
[InlineData("ALARM:Servo/10-5")]
|
||||
[InlineData("ALARM:Servo/1-")]
|
||||
[InlineData("ALARM:T")]
|
||||
[InlineData("ALARM:T-1")]
|
||||
[InlineData("ALARM:T99999")]
|
||||
[InlineData("ALARM:Servo /401")]
|
||||
[InlineData("ALARM:*/401")]
|
||||
public void Malformed_selectors_are_refused_with_a_reason(string address)
|
||||
{
|
||||
FocasAddress.TryParse(address).ShouldBeNull();
|
||||
FocasAddress.DescribeAlarmAddressError(address).ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>"A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC.</summary>
|
||||
[Fact]
|
||||
public void Alarm_prefix_is_not_read_as_a_PMC_A_address()
|
||||
{
|
||||
FocasAddress.TryParse("A10")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
FocasAddress.TryParse("ALARM:*")!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
FocasAddress.DescribeAlarmAddressError("A10").ShouldBeNull("not an ALARM: address at all");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type_selector_matches_on_type_and_inclusive_number_range()
|
||||
{
|
||||
var range = FocasAlarmSelector.TryParse("Servo/401-453")!;
|
||||
range.Matches(FocasAlarmType.Servo, 401).ShouldBeTrue();
|
||||
range.Matches(FocasAlarmType.Servo, 453).ShouldBeTrue();
|
||||
range.Matches(FocasAlarmType.Servo, 454).ShouldBeFalse();
|
||||
range.Matches(FocasAlarmType.Overtravel, 401).ShouldBeFalse("(type, number) is the identity");
|
||||
|
||||
var any = FocasAlarmSelector.TryParse("*")!;
|
||||
any.Matches(FocasAlarmType.Overheat, 1).ShouldBeTrue();
|
||||
|
||||
// OTHER depends on the device's other selectors, so it never matches on its own terms.
|
||||
FocasAlarmSelector.TryParse("OTHER")!.Matches(FocasAlarmType.Servo, 401).ShouldBeFalse();
|
||||
|
||||
// A raw code selects exactly that code — the series-independent form.
|
||||
FocasAlarmSelector.TryParse("T4")!.Matches(FocasAlarmType.Servo, 1).ShouldBeTrue();
|
||||
FocasAlarmSelector.TryParse("T9")!.Matches(FocasAlarmType.Servo, 1).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_named_type_resolves_through_the_one_table()
|
||||
{
|
||||
foreach (var name in FocasAlarmType.Names)
|
||||
{
|
||||
FocasAlarmType.TryResolve(name.ToUpperInvariant(), out var code).ShouldBeTrue(name);
|
||||
FocasAlarmType.NameOf(code).ShouldBe(name);
|
||||
}
|
||||
FocasAlarmType.NameOf(9).ShouldBeNull("codes outside the 16i-era table are not invented");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capability_matrix_accepts_an_alarm_address_on_every_series()
|
||||
{
|
||||
var address = FocasAddress.TryParse("ALARM:Servo/401")!;
|
||||
foreach (var series in Enum.GetValues<FocasCncSeries>())
|
||||
FocasCapabilityMatrix.Validate(series, address).ShouldBeNull(series.ToString());
|
||||
}
|
||||
|
||||
// ---- the deploy-time inspector (the gate CSV import / API authoring meets) ----
|
||||
|
||||
[Fact]
|
||||
public void Inspect_is_clean_for_an_alarm_selector_with_its_alarm_object()
|
||||
{
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/401-453","dataType":"Int32","alarm":{"severity":800}}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_advises_when_an_alarm_selector_has_no_alarm_object_or_an_alarm_object_has_no_selector()
|
||||
{
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:*","dataType":"Int32"}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("no 'alarm' object");
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"R100","dataType":"Byte","alarm":{}}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("not an ALARM: selector");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_names_the_selector_fault_for_a_malformed_alarm_address()
|
||||
{
|
||||
var warning = FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/10-5","alarm":{}}""").ShouldHaveSingleItem();
|
||||
warning.ShouldContain("reversed");
|
||||
warning.ShouldContain(FocasAddress.DescribeAlarmAddressError("ALARM:Servo/10-5")!,
|
||||
customMessage: "the gate and the editor must say the same thing about the same string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_mapper_reads_the_alarm_object_and_its_optional_integer_severity()
|
||||
{
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":650}}""", "p", out var a).ShouldBeTrue();
|
||||
a.Alarm.ShouldBe(new FocasTagAlarm(650));
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":"high"}}""", "p", out var b).ShouldBeTrue();
|
||||
b.Alarm.ShouldBe(new FocasTagAlarm(null));
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"R100"}""", "p", out var c).ShouldBeTrue();
|
||||
c.Alarm.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
|
||||
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty()
|
||||
{
|
||||
using var client = new WireFocasClient();
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => client.ReadAlarmsAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,37 @@ public sealed class FocasAddressBuilderTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gitea #647: ALARM: selectors ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("*", null, null, "ALARM:*")]
|
||||
[InlineData("*", 401, null, "ALARM:*")] // numbers apply to a type scope only
|
||||
[InlineData("OTHER", null, null, "ALARM:OTHER")]
|
||||
[InlineData("Servo", null, null, "ALARM:Servo")]
|
||||
[InlineData("Servo", 401, null, "ALARM:Servo/401")]
|
||||
[InlineData("Servo", 401, 453, "ALARM:Servo/401-453")]
|
||||
[InlineData("Servo", 401, 401, "ALARM:Servo/401")]
|
||||
[InlineData("T9", 750, 758, "ALARM:T9/750-758")]
|
||||
public void BuildAlarm_produces_a_selector_the_driver_can_parse(string scope, int? lo, int? hi, string expected)
|
||||
{
|
||||
var built = FocasAddressBuilder.BuildAlarm(scope, lo, hi);
|
||||
|
||||
built.ShouldBe(expected);
|
||||
FocasAddress.TryParse(built)!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Servo", 453, 401)] // reversed range
|
||||
[InlineData("Bogus", null, null)] // not a type
|
||||
[InlineData("", null, null)]
|
||||
public void BuildAlarm_returns_empty_rather_than_an_unusable_selector(string scope, int? lo, int? hi)
|
||||
=> FocasAddressBuilder.BuildAlarm(scope, lo, hi).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Every_offered_alarm_scope_composes_a_parseable_selector()
|
||||
{
|
||||
foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||
FocasAddress.TryParse(FocasAddressBuilder.BuildAlarm(scope, 1, 2)).ShouldNotBeNull(scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModelTests
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
[InlineData("ALARM:*")]
|
||||
[InlineData("ALARM:OTHER")]
|
||||
[InlineData("ALARM:Servo/401-453")]
|
||||
[InlineData("ALARM:T9")]
|
||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||
|
||||
@@ -104,6 +108,25 @@ public sealed class FocasTagConfigModelTests
|
||||
// The operator has to be able to act on it, so the message names the legal forms.
|
||||
error.ShouldContain("PARAM:");
|
||||
error.ShouldContain("FIXED:");
|
||||
error.ShouldContain("ALARM:");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 — a malformed ALARM: selector gets the selector's own reason, the same text the deploy-time
|
||||
/// inspector gives for the same string (one authority: <c>FocasAddress.DescribeAlarmAddressError</c>).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ALARM:Servo/10-5")]
|
||||
[InlineData("ALARM:Bogus")]
|
||||
[InlineData("ALARM:")]
|
||||
public void Validate_rejects_a_malformed_alarm_selector_with_the_gate_s_reason(string address)
|
||||
{
|
||||
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain(FocasAddress.DescribeAlarmAddressError(address)!);
|
||||
FocasTagDefinitionFactory.Inspect("{\"address\":\"" + address + "\",\"alarm\":{}}")
|
||||
.ShouldContain(w => w.Contains(FocasAddress.DescribeAlarmAddressError(address)!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS <c>"{host}#{type}:{number}"</c> before #647) has
|
||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||
[Fact]
|
||||
public void A_decorated_condition_id_is_dropped()
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 — a FOCAS CNC alarm reaches its Part 9 condition END TO END: the REAL <see cref="FocasDriver"/>
|
||||
/// (over a fake CNC client), fed the raw tags exactly as the deployment artifact delivers them, raises into the
|
||||
/// REAL <see cref="DriverHostActor"/>, whose <c>ForwardNativeAlarm</c> + <c>NativeAlarmProjector</c> must turn it
|
||||
/// into an Active and then an inactive condition at the tag's RawPath.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The lesson this exists for: OpcUaClient and AbCip native alarms were "routed" — events raised, unit tests
|
||||
/// green — but never reached a condition, because nothing checked the event against the key the host actually
|
||||
/// routes on (ConditionId == RawPath) or the field its projector actually reads (Kind). A test that stops at the
|
||||
/// driver's event cannot see either.
|
||||
/// </remarks>
|
||||
public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("focas-alarm-e2e");
|
||||
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('f', 64));
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
private const string DriverId = "drv-focas";
|
||||
private const string ExpectedRawPath = "Plant/Cnc/m1/servo_alarms";
|
||||
|
||||
[Fact]
|
||||
public async Task A_CNC_servo_alarm_activates_then_clears_the_condition_at_the_RawPath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildArtifact();
|
||||
var deploymentId = Seed(db, blob);
|
||||
var (host, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// The raw tags exactly as the driver receives them: the artifact's merged DriverConfig RawTags array.
|
||||
var rawTags = RawTagsFor(blob, DriverId);
|
||||
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
|
||||
|
||||
var cnc = new AlarmOnlyFocasClient();
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||
RawTags = rawTags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||
}, DriverId, new SingleClientFactory(cnc));
|
||||
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
// The host's alarm subscription set is the alarm-bearing raw tags' RawPaths. Called directly, as the
|
||||
// DriverInstanceActor would through its invoker: the routing is under test, not resilience wrapping.
|
||||
#pragma warning disable OTOPCUA0001
|
||||
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
try
|
||||
{
|
||||
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
|
||||
var active = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
active.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||
active.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
active.State.Active.ShouldBeTrue("the Raise must project to an ACTIVE condition, not 'keep prior state'");
|
||||
active.State.Acknowledged.ShouldBeFalse();
|
||||
active.State.Severity.ShouldBe((ushort)800, "the authored alarm.severity is the condition's severity");
|
||||
active.State.Message.ShouldStartWith("401 SERVO V-READY OFF");
|
||||
|
||||
cnc.SetAlarms();
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The seeded artifact's FOCAS driver raw tags, decoded through the production artifact reader.</summary>
|
||||
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
|
||||
{
|
||||
using var artifact = DeploymentArtifact.Open(blob);
|
||||
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
|
||||
using var doc = JsonDocument.Parse(spec.DriverConfig);
|
||||
return JsonSerializer.Deserialize<List<RawTagEntry>>(
|
||||
doc.RootElement.GetProperty("RawTags").GetRawText(),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
|
||||
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Cnc", DriverType = "FOCAS", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { HostAddress = Host }) },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-servo",
|
||||
DeviceId = "dev-m1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = "servo_alarms",
|
||||
DataType = "Int32",
|
||||
AccessLevel = 0,
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
address = "ALARM:Servo",
|
||||
dataType = "Int32",
|
||||
alarm = new { alarmType = "AlarmCondition", severity = 800 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
|
||||
{
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = Rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = blob,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
dependencyMux: CreateTestProbe().Ref,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: CreateTestProbe().Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||
return (actor, publish);
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory(IFocasClient client) : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => client;
|
||||
|
||||
public void EnsureUsable() { }
|
||||
}
|
||||
|
||||
/// <summary>A CNC that only answers the alarm list — everything else this test never reaches.</summary>
|
||||
private sealed class AlarmOnlyFocasClient : IFocasClient
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private FocasActiveAlarm[] _alarms = [];
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||
{
|
||||
lock (_gate) _alarms = alarms;
|
||||
}
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. _alarms]);
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken) => Task.FromResult(true);
|
||||
|
||||
public void Dispose() => IsConnected = false;
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
|
||||
private static NotSupportedException Unused() => new("not reached by the alarm end-to-end test");
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@
|
||||
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
||||
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
|
||||
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user