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:
Joseph Doherty
2026-10-08 19:46:48 -04:00
parent 8801631739
commit 482bafdfac
27 changed files with 2110 additions and 515 deletions
+7 -1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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>&lt;TYPE&gt;</c></term><description>any alarm of that type</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;n&gt;</c></term><description>that type and alarm number</description></item>
/// <item><term><c>&lt;TYPE&gt;/&lt;lo&gt;-&lt;hi&gt;</c></term><description>that type, number in the inclusive range</description></item>
/// </list>
/// <para>
/// <c>&lt;TYPE&gt;</c> is a name from <see cref="FocasAlarmType.Names"/> (<c>Servo</c>, <c>Overtravel</c>…)
/// or a raw type code <c>T&lt;code&gt;</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&lt;code&gt;</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>&lt;400</c> Low, <c>&lt;600</c> Medium, <c>&lt;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&lt;code&gt;</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)
@@ -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>
@@ -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);
@@ -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&lt;code&gt;</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.";
}
@@ -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]
@@ -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()
@@ -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>