Merge b626-626 (batch #626+): MTConnect CONDITION data items as native Part 9 alarms (opt-in TagConfig.alarm)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -12,6 +12,7 @@ historical reference.
|
|||||||
| Source | Driver capability | Path |
|
| Source | Driver capability | Path |
|
||||||
|----------------------------------|--------------------------|------|
|
|----------------------------------|--------------------------|------|
|
||||||
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
|
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
|
||||||
|
| **MTConnect CONDITION (driver-native, #626)** | `MTConnectDriver : IAlarmSource` | Agent `/sample` pump → `MTConnectConditionTracker` → `MTConnectConditionAlarmMapper` → driver `OnAlarmEvent` (ConditionId = RawPath) → `DriverHostActor.ForwardNativeAlarm` |
|
||||||
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
|
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
|
||||||
| **Scripted alarms** | `Phase7Composer` | server-side script evaluator → `ScriptedAlarmActor` transitions → `HistorianAdapterActor` → `IAlarmHistorianSink` |
|
| **Scripted alarms** | `Phase7Composer` | server-side script evaluator → `ScriptedAlarmActor` transitions → `HistorianAdapterActor` → `IAlarmHistorianSink` |
|
||||||
|
|
||||||
@@ -297,6 +298,23 @@ the optional properties added in PR E.7 (`OperatorComment`,
|
|||||||
need them are unaffected; consumers that do (Client.UI, Client.CLI
|
need them are unaffected; consumers that do (Client.UI, Client.CLI
|
||||||
verbose mode) read the new fields when present.
|
verbose mode) read the new fields when present.
|
||||||
|
|
||||||
|
## MTConnect CONDITION path (driver-native, #626)
|
||||||
|
|
||||||
|
An MTConnect raw tag bound to a `CONDITION` DataItem becomes a native condition when its `TagConfig`
|
||||||
|
carries the standard `alarm` object (opt-in; without it the tag stays the String state word). Normal →
|
||||||
|
inactive, Warning → active 500, Fault → active 800, Unavailable → state unchanged (an active condition stays
|
||||||
|
active and acked — it is comms loss one hop out, the #477/#481 posture) with `BadNoCommunication`
|
||||||
|
quality. Two optional `AlarmEventArgs` fields added for it are generic: **`OpcUaSeverity`** (an exact
|
||||||
|
1..1000 severity that supersedes the four-bucket mapping in `NativeAlarmProjector`) and
|
||||||
|
**`QualityStatusCode`** (projected onto the condition's `Quality` on a transition; with an `Unspecified`
|
||||||
|
kind it is a **quality-only annotation** — `DriverHostActor` routes it through the same
|
||||||
|
`AlarmQualityUpdate` → `WriteAlarmQuality` path as the connectivity annotation, which touches Quality and
|
||||||
|
nothing else, and publishes no `/alerts` row).
|
||||||
|
Acknowledge is server-side only (MTConnect has no ack; the driver's `AcknowledgeAsync` is a no-op).
|
||||||
|
The driver diffs against what the server was last told, so a reconnect's replayed `/current` never
|
||||||
|
re-raises (and so un-acknowledges) an unchanged condition. Full mapping:
|
||||||
|
[`drivers/MTConnect.md` §CONDITION alarms](drivers/MTConnect.md#condition-alarms-626).
|
||||||
|
|
||||||
## Galaxy sub-attribute fallback
|
## Galaxy sub-attribute fallback
|
||||||
|
|
||||||
For Galaxy templates without `$Alarm*` extensions, the value-driven
|
For Galaxy templates without `$Alarm*` extensions, the value-driven
|
||||||
|
|||||||
+108
-11
@@ -92,6 +92,7 @@ request/response handshake — is a different mechanism and remains deferred; se
|
|||||||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||||||
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||||
|
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
|
||||||
|
|
||||||
**`IReadable.ReadAsync` is never called by the running server.** The
|
**`IReadable.ReadAsync` is never called by the running server.** The
|
||||||
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
|
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
|
||||||
@@ -381,14 +382,96 @@ throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
|
|||||||
committed directly as `TagConfig.FullName` on pick. See
|
committed directly as `TagConfig.FullName` on pick. See
|
||||||
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
|
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
|
||||||
|
|
||||||
## CONDITION modelling (v1: plain String)
|
## CONDITION alarms (#626)
|
||||||
|
|
||||||
Each `CONDITION` DataItem materializes as a `String` variable whose value is
|
A `CONDITION` DataItem can be served two ways, and the choice is **per raw tag**:
|
||||||
the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`.
|
|
||||||
There is no native OPC UA Part 9 alarm plumbing in this build;
|
- **As a String value** (the default — every existing CONDITION tag, and every browse-committed
|
||||||
`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so
|
one). The value is the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`
|
||||||
the browse side-panel flags it and a future upgrade to native alarms doesn't
|
(`UNAVAILABLE` → `BadNoCommunication`). Unchanged by #626.
|
||||||
need re-authoring. See [Deferred](#deferred-not-in-this-build).
|
- **As a native OPC UA Part 9 alarm**, by adding the cross-driver `alarm` object to the raw tag's
|
||||||
|
`TagConfig` — exactly how a Galaxy alarm tag is made alarm-bearing (the `/raw` tag modal's native
|
||||||
|
alarm section, or JSON):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "address": "dev1_system_cond", "alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The server then materialises **one Part 9 condition at the tag's RawPath** (ConditionId = RawPath),
|
||||||
|
fanned to the raw device folder and every referencing equipment folder, and the driver feeds it
|
||||||
|
through `IAlarmSource`. An alarm-bearing tag is a condition, not a value variable (the host excludes
|
||||||
|
alarm tags from the value subscription), so to keep the String as well, author a second raw tag on
|
||||||
|
the same DataItem without the `alarm` object — one DataItem may back several raw tags.
|
||||||
|
|
||||||
|
It is deliberately **not automatic** for `category=CONDITION`: that would silently turn every existing
|
||||||
|
CONDITION String tag into a condition node on upgrade.
|
||||||
|
|
||||||
|
**State mapping.**
|
||||||
|
|
||||||
|
| Agent level | Part 9 | Severity | Quality |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Normal` | inactive (return to normal) | — | Good |
|
||||||
|
| `Warning` | active, unacknowledged | 500 | Good |
|
||||||
|
| `Fault` | active, unacknowledged | 800 | Good |
|
||||||
|
| `Unavailable` | **unchanged** (an active condition stays active and acknowledged) | — | `BadNoCommunication` |
|
||||||
|
|
||||||
|
**`Unavailable` is comms loss one hop further out, not a return to normal.** A cppagent marks every
|
||||||
|
DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
|
||||||
|
host's own transport loss (#477/#481): the condition keeps its Active/Acked state and only its `Quality`
|
||||||
|
goes `BadNoCommunication`, as a **quality-only annotation** (no `/alerts` row, written through the host's
|
||||||
|
Quality-only node path — it can never rewrite message/severity or acknowledge anything). When data
|
||||||
|
returns: the **same** statement ⇒ quality-only Good (an ack survives an adapter blip); `Normal` ⇒ Clear;
|
||||||
|
a **different** active statement ⇒ Retrigger. An inactive condition going Unavailable is likewise a
|
||||||
|
quality-only Bad. Severity is the exact Part 9 value (`AlarmEventArgs.OpcUaSeverity`, #626), not one of
|
||||||
|
the four buckets. A clear keeps the severity of the alarm it clears.
|
||||||
|
|
||||||
|
**Message** = the condition's text content; without text, `"{type} {level}"` (e.g. `SYSTEM Fault`).
|
||||||
|
Whichever of `nativeCode` / `nativeSeverity` / `qualifier` the Agent supplied is appended —
|
||||||
|
`Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]` — because the Part 9
|
||||||
|
event model has no slot for them. `AlarmType` is the condition's `type`; `SourceNodeId` the DataItem id.
|
||||||
|
|
||||||
|
**Several simultaneous conditions.** MTConnect can hold several active conditions on one DataItem
|
||||||
|
(distinct `nativeCode`s). The OPC UA condition shows **the most severe active one**; equal levels tie-break deterministically
|
||||||
|
(ordinal `nativeCode`, then `type`), never by Agent document order, so a re-baseline that lists them
|
||||||
|
differently cannot re-raise.
|
||||||
|
The driver tracks them with MTConnect's own rules (`MTConnectConditionTracker`): `Warning`/`Fault`
|
||||||
|
activates its `nativeCode`; `Normal` with a `nativeCode` clears only that code, `Normal` without one
|
||||||
|
clears all; `Unavailable` clears all; a `/current` re-baseline replaces the DataItem's set. So a
|
||||||
|
`/sample` chunk clearing code A while code B is active is **not** a return to normal. An escalation
|
||||||
|
(Warning → Fault) or a different winning code is a **Retrigger** — active and unacknowledged again with
|
||||||
|
the new severity/message. By design that includes **clearing the worst of several codes**: the next one
|
||||||
|
down is re-raised un-acked, because it is a statement the operator has not seen. A **text-only change**
|
||||||
|
on the same level + `nativeCode` (an adapter embedding a live value in the text) updates nothing — no
|
||||||
|
retrigger, no un-ack — and the message shown stays the statement's first text. (The String value surface still reconciles per
|
||||||
|
document, so on a delta it shows the state word of that chunk; the alarm is the authoritative view.)
|
||||||
|
|
||||||
|
**Acknowledge.** MTConnect has no acknowledgement upstream. Native conditions support Acknowledge and
|
||||||
|
AddComment only; the ack is applied server-side and the driver's `AcknowledgeAsync` is a no-op success
|
||||||
|
(nothing is sent to the Agent). Off-Primary acks are refused by `NativeAlarmAckPrimaryGate` as for every
|
||||||
|
native source.
|
||||||
|
|
||||||
|
**Restart / reconnect / comms loss.** The driver diffs each condition against what the server was last
|
||||||
|
**told** — dedup on level + `nativeCode` + `nativeSeverity` + `qualifier` (not text, never the value) —
|
||||||
|
and records a state only when an alarm handler was attached to receive it. Consequences:
|
||||||
|
|
||||||
|
- A replayed `/current` (reconnect, re-initialize, Agent restart, ring-buffer re-baseline) re-raises
|
||||||
|
nothing that is unchanged — re-raising would un-acknowledge an operator's ack.
|
||||||
|
- A transition that happened while the host was detached (it detaches while it reconnects) is delivered
|
||||||
|
on the re-subscribe that follows; a fault cleared during an outage is cleared on recovery.
|
||||||
|
- Comms loss emits nothing from the driver, matching every native source: the host annotates every
|
||||||
|
condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active
|
||||||
|
state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable`
|
||||||
|
condition's Bad quality on its re-subscribe.
|
||||||
|
- A re-subscribe with a **changed** alarm set clears the whole told record: only an alarm-bearing raw-tag
|
||||||
|
delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every
|
||||||
|
condition node inactive — so every active condition is re-asserted onto its new node. The **same** set
|
||||||
|
(a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no
|
||||||
|
re-subscribe; that is a host-wide gap shared with every native source.)
|
||||||
|
- An alarm-only driver (no value tags) still runs the `/sample` pump.
|
||||||
|
|
||||||
|
**EVENT controlled vocabularies as OPC UA enumerations — won't fix.** EVENT values (`Execution`,
|
||||||
|
`ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type
|
||||||
|
vocabulary table that vendors extend freely.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -464,7 +547,8 @@ for anything beyond values-and-conditions.
|
|||||||
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
|
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
|
||||||
declare a `sampleCount`, is unrealistic on this point and exists only to
|
declare a `sampleCount`, is unrealistic on this point and exists only to
|
||||||
pin the parsing rule itself.
|
pin the parsing rule itself.
|
||||||
5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above).
|
5. **A CONDITION alarm shows one condition per DataItem** — the most severe active `nativeCode`, not one
|
||||||
|
Part 9 condition per code (see [CONDITION alarms](#condition-alarms-626)).
|
||||||
6. **Structured `DATA_SET`/`TABLE` observations surface as
|
6. **Structured `DATA_SET`/`TABLE` observations surface as
|
||||||
`BadNotSupported`**, not an approximation — see the Quality mapping table.
|
`BadNotSupported`**, not an approximation — see the Quality mapping table.
|
||||||
`TIME_SERIES` is served as `double[]` (#627, see
|
`TIME_SERIES` is served as `double[]` (#627, see
|
||||||
@@ -485,9 +569,9 @@ for anything beyond values-and-conditions.
|
|||||||
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
||||||
§2.1.
|
§2.1.
|
||||||
- **P1.5 fast-follow** (design
|
- **P1.5 fast-follow** (design
|
||||||
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
|
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9
|
||||||
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
|
alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT
|
||||||
native-alarm pattern); EVENT controlled-vocabulary values → OPC UA enumerations.
|
controlled-vocabulary values → OPC UA enumerations is **won't-fix**.
|
||||||
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
|
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
|
||||||
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
|
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
|
||||||
auto-discovery (no device model without an Agent in front, so
|
auto-discovery (no device model without an Agent in front, so
|
||||||
@@ -527,6 +611,19 @@ for anything beyond values-and-conditions.
|
|||||||
(PowerShell helper, Windows-only) — or, directly on the docker host,
|
(PowerShell helper, Windows-only) — or, directly on the docker host,
|
||||||
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
|
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
|
||||||
`docker compose up -d --wait`.
|
`docker compose up -d --wait`.
|
||||||
|
- **CONDITION alarms (#626)** — `MTConnectConditionAlarmMappingTests` (tracker semantics + state
|
||||||
|
mapping/sequencing), `MTConnectAlarmSourceTests` (driver wiring: Normal→Fault→Normal, escalation,
|
||||||
|
several `nativeCode`s, Unavailable, replay after re-subscribe/re-initialize/Agent restart with no
|
||||||
|
duplicate, the missed-while-detached delivery, comms loss), `MTConnectConditionAlarmWireTests` in
|
||||||
|
`OpcUaServer.IntegrationTests` (a scripted Agent's Fault/Normal reaching a client's event subscription on
|
||||||
|
the raw device folder). **Live check:** the fixture's adapter only ever writes `normal` for its two
|
||||||
|
CONDITION items (`fixture_x_travel` / SHDR key `Xtravel`, `fixture_logic` / `Plogic`, see
|
||||||
|
`Docker/Devices.xml`). To see a transition, make `send_conditions()` in `Docker/adapter.py` alternate
|
||||||
|
`Xtravel|fault|1001|2|HIGH|X overtravel` with `Xtravel|normal||||` every few ticks, sync + restart the
|
||||||
|
adapter, author a raw tag `{"address":"fixture_x_travel","alarm":{"alarmType":"AlarmCondition"}}` on an
|
||||||
|
MTConnect driver, deploy, and subscribe events on its raw device folder (Client CLI `alarms`): expect a
|
||||||
|
Fault event (severity 800, message `X overtravel [nativeCode=1001, nativeSeverity=2, qualifier=HIGH]`),
|
||||||
|
then a return-to-normal event, repeating.
|
||||||
|
|
||||||
## Further reading
|
## Further reading
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
||||||
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
||||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server).
|
/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
|
||||||
|
/// (CONDITION data items, Gitea #626).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAlarmSource
|
public interface IAlarmSource
|
||||||
{
|
{
|
||||||
@@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest(
|
|||||||
/// other fields. <see cref="AlarmTransitionKind.Unspecified"/> when the upstream
|
/// other fields. <see cref="AlarmTransitionKind.Unspecified"/> when the upstream
|
||||||
/// path doesn't surface a distinct kind.
|
/// path doesn't surface a distinct kind.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="OpcUaSeverity">
|
||||||
|
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
|
||||||
|
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
|
||||||
|
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
|
||||||
|
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
||||||
|
/// bucket for consumers that only read the bucket.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="QualityStatusCode">
|
||||||
|
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
|
||||||
|
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
|
||||||
|
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
|
||||||
|
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
|
||||||
|
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
|
||||||
|
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
|
||||||
|
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
||||||
|
/// </param>
|
||||||
public sealed record AlarmEventArgs(
|
public sealed record AlarmEventArgs(
|
||||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||||
string SourceNodeId,
|
string SourceNodeId,
|
||||||
@@ -96,7 +113,17 @@ public sealed record AlarmEventArgs(
|
|||||||
string? OperatorComment = null,
|
string? OperatorComment = null,
|
||||||
DateTime? OriginalRaiseTimestampUtc = null,
|
DateTime? OriginalRaiseTimestampUtc = null,
|
||||||
string? AlarmCategory = null,
|
string? AlarmCategory = null,
|
||||||
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
|
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
|
||||||
|
int? OpcUaSeverity = null,
|
||||||
|
uint? QualityStatusCode = null)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True when this event only re-annotates the condition's source-data quality — a
|
||||||
|
/// <see cref="QualityStatusCode"/> with an <see cref="AlarmTransitionKind.Unspecified"/> kind. No
|
||||||
|
/// alarm-state change happened, so the host must not publish it as an alarm transition.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
|
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
|
||||||
public enum AlarmSeverity { Low, Medium, High, Critical }
|
public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the server was last TOLD about one condition (#626): the last alarm statement it was given
|
||||||
|
/// (active or Normal — never Unavailable) and whether its Quality is currently annotated Bad.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Statement">The last statement delivered; <c>null</c> when only an Unavailable was ever seen.</param>
|
||||||
|
/// <param name="QualityBad">Whether the condition's Quality was last annotated <c>BadNoCommunication</c>.</param>
|
||||||
|
internal sealed record MTConnectToldCondition(MTConnectConditionState? Statement, bool QualityBad);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MTConnect CONDITION → OPC UA Part 9 mapping (#626), as one pure function: given what the server was
|
||||||
|
/// last told about a condition and what the Agent says now, the single <see cref="AlarmEventArgs"/> to
|
||||||
|
/// raise — or none — and what the server has then been told.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <listheader><term>Agent level</term><description>Part 9</description></listheader>
|
||||||
|
/// <item><term>Normal</term><description>inactive (return to normal), quality Good</description></item>
|
||||||
|
/// <item><term>Warning</term><description>active, severity 500 (<see cref="AlarmSeverity.Medium"/>)</description></item>
|
||||||
|
/// <item><term>Fault</term><description>active, severity 800 (<see cref="AlarmSeverity.High"/> bucket)</description></item>
|
||||||
|
/// <item><term>Unavailable</term><description>state UNCHANGED, quality <c>BadNoCommunication</c></description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Unavailable is comms loss one hop further out, not a return to normal.</b> A cppagent marks every
|
||||||
|
/// DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
|
||||||
|
/// host's own transport loss (#477/#481): an active condition STAYS active (and keeps its ack), only its
|
||||||
|
/// Quality goes Bad, as a quality-only annotation (<see cref="AlarmEventArgs.IsQualityOnly"/>, no
|
||||||
|
/// <c>/alerts</c> row). When data returns: the same statement ⇒ quality-only Good; Normal ⇒ Clear; a
|
||||||
|
/// different active statement ⇒ Retrigger.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Otherwise: inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active
|
||||||
|
/// statement (Warning → Fault, a new winning <c>nativeCode</c>) is a
|
||||||
|
/// <see cref="AlarmTransitionKind.Retrigger"/> — active and unacknowledged again with the new severity
|
||||||
|
/// and message (so clearing the worst of several codes re-raises the next one down, un-acked, by
|
||||||
|
/// design: it is a statement the operator has not seen); active → Normal is a
|
||||||
|
/// <see cref="AlarmTransitionKind.Clear"/>. An unchanged statement
|
||||||
|
/// (<see cref="MTConnectConditionState.SameAlarmAs"/> — text excluded) emits nothing, which keeps a
|
||||||
|
/// reconnect's replayed <c>/current</c> from re-raising, and so un-acknowledging, an acked alarm; a
|
||||||
|
/// text-only change likewise updates nothing, so the message shown stays the statement's first text.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Resync.</b> On a (re)subscribe an unchanged Bad quality is re-asserted, because the host
|
||||||
|
/// re-annotates every condition Good when the driver reconnects (#477) and nothing else would put the
|
||||||
|
/// per-condition Bad back.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class MTConnectConditionAlarmMapper
|
||||||
|
{
|
||||||
|
/// <summary>OPC UA severity of an active <c>Warning</c>.</summary>
|
||||||
|
internal const int WarningSeverity = 500;
|
||||||
|
|
||||||
|
/// <summary>OPC UA severity of an active <c>Fault</c>.</summary>
|
||||||
|
internal const int FaultSeverity = 800;
|
||||||
|
|
||||||
|
/// <summary>Canonical <c>Opc.Ua.StatusCodes.Good</c>.</summary>
|
||||||
|
private const uint Good = 0x00000000u;
|
||||||
|
|
||||||
|
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c> — the Agent has no state for the item.</summary>
|
||||||
|
private const uint BadNoCommunication = 0x80310000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The event moving the server from <paramref name="told"/> to <paramref name="current"/> (or <c>null</c>
|
||||||
|
/// when nothing needs to be said), and the new told record.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="told">What the server was last told for this condition; <c>null</c> when nothing yet.</param>
|
||||||
|
/// <param name="current">The Agent's current statement.</param>
|
||||||
|
/// <param name="conditionId">The condition's ConditionId — the alarm-bearing raw tag's RawPath.</param>
|
||||||
|
/// <param name="dataItemId">The Agent DataItem id (carried as <see cref="AlarmEventArgs.SourceNodeId"/>).</param>
|
||||||
|
/// <param name="handle">The alarm subscription the event belongs to.</param>
|
||||||
|
/// <param name="resync">True on a (re)subscribe: re-assert an unchanged Bad quality.</param>
|
||||||
|
/// <returns>The event to raise (or <c>null</c>) and what the server has been told afterwards.</returns>
|
||||||
|
public static (AlarmEventArgs? Event, MTConnectToldCondition Told) Transition(
|
||||||
|
MTConnectToldCondition? told,
|
||||||
|
MTConnectConditionState current,
|
||||||
|
string conditionId,
|
||||||
|
string dataItemId,
|
||||||
|
IAlarmSubscriptionHandle handle,
|
||||||
|
bool resync)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(current);
|
||||||
|
|
||||||
|
var statement = told?.Statement;
|
||||||
|
var bad = told?.QualityBad ?? false;
|
||||||
|
|
||||||
|
if (current.Level == MTConnectConditionLevel.Unavailable)
|
||||||
|
{
|
||||||
|
// State untouched; only the quality moves (or is re-asserted on a resync).
|
||||||
|
var next = new MTConnectToldCondition(statement, QualityBad: true);
|
||||||
|
return !bad || resync
|
||||||
|
? (QualityOnly(statement ?? current, current, conditionId, dataItemId, handle, BadNoCommunication), next)
|
||||||
|
: (null, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
var told2 = new MTConnectToldCondition(current, QualityBad: false);
|
||||||
|
|
||||||
|
if (statement is not null && statement.SameAlarmAs(current))
|
||||||
|
{
|
||||||
|
// Unchanged statement: keep the FIRST text the server was shown. Only a Bad quality is lifted.
|
||||||
|
var kept = told2 with { Statement = statement };
|
||||||
|
return bad
|
||||||
|
? (QualityOnly(statement, current, conditionId, dataItemId, handle, Good), kept)
|
||||||
|
: (null, kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasActive = statement?.IsActive == true;
|
||||||
|
|
||||||
|
if (current.IsActive)
|
||||||
|
{
|
||||||
|
return (Build(current, statement, conditionId, dataItemId, handle,
|
||||||
|
wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise), told2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasActive)
|
||||||
|
{
|
||||||
|
return (Build(current, statement, conditionId, dataItemId, handle, AlarmTransitionKind.Clear), told2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal after nothing / after Normal: nothing to say about the state — the host's connectivity
|
||||||
|
// annotation confirms a materialised condition Good — except lifting a Bad this driver put there.
|
||||||
|
return bad
|
||||||
|
? (QualityOnly(current, current, conditionId, dataItemId, handle, Good), told2)
|
||||||
|
: (null, told2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Part 9 severity of an active level.</summary>
|
||||||
|
/// <param name="level">An active level.</param>
|
||||||
|
internal static int SeverityOf(MTConnectConditionLevel level) =>
|
||||||
|
level == MTConnectConditionLevel.Fault ? FaultSeverity : WarningSeverity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The condition's message: its text content, else <c>"{type} {level}"</c>; followed by whichever of
|
||||||
|
/// <c>nativeCode</c> / <c>nativeSeverity</c> / <c>qualifier</c> the Agent supplied — the Part 9 event
|
||||||
|
/// model has no slot for them, and an operator needs the native code to look the alarm up.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The state to describe.</param>
|
||||||
|
internal static string MessageOf(MTConnectConditionState state)
|
||||||
|
{
|
||||||
|
var detail = state.Detail;
|
||||||
|
var level = state.Level == MTConnectConditionLevel.Unavailable ? "UNAVAILABLE" : state.Level.ToString();
|
||||||
|
var builder = new StringBuilder(detail.Text ?? $"{detail.ConditionType ?? "CONDITION"} {level}");
|
||||||
|
|
||||||
|
var extras = new List<string>(3);
|
||||||
|
if (detail.NativeCode is not null) extras.Add($"nativeCode={detail.NativeCode}");
|
||||||
|
if (detail.NativeSeverity is not null) extras.Add($"nativeSeverity={detail.NativeSeverity}");
|
||||||
|
if (detail.Qualifier is not null) extras.Add($"qualifier={detail.Qualifier}");
|
||||||
|
|
||||||
|
if (extras.Count > 0) builder.Append(" [").AppendJoin(", ", extras).Append(']');
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A quality-only annotation. The host writes ONLY the condition's Quality for it (never message,
|
||||||
|
/// severity or ack state), so the message/severity carried are informational: those of the statement the
|
||||||
|
/// server holds.
|
||||||
|
/// </summary>
|
||||||
|
private static AlarmEventArgs QualityOnly(
|
||||||
|
MTConnectConditionState shown,
|
||||||
|
MTConnectConditionState current,
|
||||||
|
string conditionId,
|
||||||
|
string dataItemId,
|
||||||
|
IAlarmSubscriptionHandle handle,
|
||||||
|
uint quality)
|
||||||
|
{
|
||||||
|
var severity = shown.IsActive ? SeverityOf(shown.Level) : WarningSeverity;
|
||||||
|
|
||||||
|
return new AlarmEventArgs(
|
||||||
|
SubscriptionHandle: handle,
|
||||||
|
SourceNodeId: dataItemId,
|
||||||
|
ConditionId: conditionId,
|
||||||
|
AlarmType: shown.Detail.ConditionType ?? "CONDITION",
|
||||||
|
Message: MessageOf(shown),
|
||||||
|
Severity: severity >= FaultSeverity ? AlarmSeverity.High : AlarmSeverity.Medium,
|
||||||
|
SourceTimestampUtc: current.TimestampUtc,
|
||||||
|
Kind: AlarmTransitionKind.Unspecified,
|
||||||
|
OpcUaSeverity: severity,
|
||||||
|
QualityStatusCode: quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmEventArgs Build(
|
||||||
|
MTConnectConditionState current,
|
||||||
|
MTConnectConditionState? previous,
|
||||||
|
string conditionId,
|
||||||
|
string dataItemId,
|
||||||
|
IAlarmSubscriptionHandle handle,
|
||||||
|
AlarmTransitionKind kind)
|
||||||
|
{
|
||||||
|
// A clear keeps the severity of the alarm it clears, so it does not visibly re-grade the condition.
|
||||||
|
var severity = current.IsActive
|
||||||
|
? SeverityOf(current.Level)
|
||||||
|
: previous?.IsActive == true ? SeverityOf(previous.Level) : WarningSeverity;
|
||||||
|
|
||||||
|
return new AlarmEventArgs(
|
||||||
|
SubscriptionHandle: handle,
|
||||||
|
SourceNodeId: dataItemId,
|
||||||
|
ConditionId: conditionId,
|
||||||
|
AlarmType: current.Detail.ConditionType ?? "CONDITION",
|
||||||
|
Message: MessageOf(current),
|
||||||
|
Severity: severity >= FaultSeverity ? AlarmSeverity.High : AlarmSeverity.Medium,
|
||||||
|
SourceTimestampUtc: current.TimestampUtc,
|
||||||
|
Kind: kind,
|
||||||
|
OpcUaSeverity: severity,
|
||||||
|
// A real transition only ever happens on live data, so it also lifts any Bad annotation.
|
||||||
|
QualityStatusCode: Good);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>The four MTConnect CONDITION levels the alarm path distinguishes (#626).</summary>
|
||||||
|
internal enum MTConnectConditionLevel
|
||||||
|
{
|
||||||
|
/// <summary><c><Normal/></c> — nothing is active.</summary>
|
||||||
|
Normal = 0,
|
||||||
|
|
||||||
|
/// <summary><c><Warning/></c> — active, Part 9 severity 500.</summary>
|
||||||
|
Warning = 1,
|
||||||
|
|
||||||
|
/// <summary><c><Fault/></c> — active, Part 9 severity 800.</summary>
|
||||||
|
Fault = 2,
|
||||||
|
|
||||||
|
/// <summary><c><Unavailable/></c> — the Agent has no state for the item (inactive, Bad quality).</summary>
|
||||||
|
Unavailable = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The derived state of ONE CONDITION DataItem as the alarm path sees it (#626): the most severe active
|
||||||
|
/// condition when any is active, else Normal or Unavailable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Level">The derived level.</param>
|
||||||
|
/// <param name="Detail">
|
||||||
|
/// The detail of the observation the level comes from — the winning active condition, or the latest
|
||||||
|
/// Normal/Unavailable observation. Never null.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TimestampUtc">That observation's Agent timestamp.</param>
|
||||||
|
/// <param name="ActiveCount">How many distinct conditions (by <c>nativeCode</c>) are active at once.</param>
|
||||||
|
internal sealed record MTConnectConditionState(
|
||||||
|
MTConnectConditionLevel Level,
|
||||||
|
MTConnectConditionDetail Detail,
|
||||||
|
DateTime TimestampUtc,
|
||||||
|
int ActiveCount)
|
||||||
|
{
|
||||||
|
/// <summary>Whether the derived condition is active (Warning or Fault).</summary>
|
||||||
|
public bool IsActive => Level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active
|
||||||
|
/// states match when level, <c>nativeCode</c>, <c>nativeSeverity</c> and <c>qualifier</c> agree (a new
|
||||||
|
/// code or an escalation is a new statement); two inactive states match on the level alone, so a
|
||||||
|
/// re-reported Normal never re-clears. NOT part of it: the timestamp and the active count (a replayed
|
||||||
|
/// <c>/current</c> after a reconnect carries the same condition under a new sequence, and re-raising it
|
||||||
|
/// would un-acknowledge an operator's ack) and the message TEXT (an adapter that embeds a live value in
|
||||||
|
/// the text, e.g. "Spindle temp 82C", would otherwise re-raise and un-ack on every tick — the message
|
||||||
|
/// shown stays the statement's first text).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="other">The state to compare against.</param>
|
||||||
|
/// <returns>True when the alarm path must emit nothing to move from one to the other.</returns>
|
||||||
|
public bool SameAlarmAs(MTConnectConditionState other)
|
||||||
|
{
|
||||||
|
if (Level != other.Level) return false;
|
||||||
|
if (!IsActive) return true;
|
||||||
|
|
||||||
|
return string.Equals(Detail.NativeCode, other.Detail.NativeCode, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(Detail.NativeSeverity, other.Detail.NativeSeverity, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(Detail.Qualifier, other.Detail.Qualifier, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds MTConnect CONDITION observations into per-DataItem alarm state with the protocol's own
|
||||||
|
/// multi-condition semantics (#626) — the alarm path's analogue of <see cref="MTConnectObservationIndex"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why the index is not enough.</b> The index reconciles several states reported for one DataItem
|
||||||
|
/// only <i>within one document</i> and otherwise treats each document as the new statement of the
|
||||||
|
/// value. That is right for a <c>/current</c> snapshot but wrong for a <c>/sample</c> delta: an Agent
|
||||||
|
/// streams each <c>nativeCode</c>'s activation and clearing separately, so a chunk carrying
|
||||||
|
/// <c><Normal nativeCode="A"/></c> clears code A only — code B, raised in an earlier chunk,
|
||||||
|
/// is still active. The alarm path must not report "return to normal" there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Rules, per DataItem, applied in document order.</b> <c>Warning</c>/<c>Fault</c> activates (or
|
||||||
|
/// replaces) the entry keyed by its <c>nativeCode</c> (blank = one unnamed entry). <c>Normal</c> with a
|
||||||
|
/// <c>nativeCode</c> clears that code; <c>Normal</c> without one clears every code. <c>Unavailable</c>
|
||||||
|
/// clears every code and marks the item unavailable until any other level arrives (the alarm path does
|
||||||
|
/// NOT treat that as a clear — see <see cref="MTConnectConditionAlarmMapper"/>). A <b>baseline</b>
|
||||||
|
/// (<c>/current</c>, which lists every currently-active condition) first resets each DataItem it
|
||||||
|
/// mentions, so an entry cleared while the driver was not listening cannot survive a re-baseline. An
|
||||||
|
/// unrecognised (vendor) state word is treated as an active <c>Warning</c> — surfacing an unknown
|
||||||
|
/// condition beats hiding it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Thread safety.</b> One internal lock; every member is atomic. The pump is the only writer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class MTConnectConditionTracker
|
||||||
|
{
|
||||||
|
/// <summary>The parser's normalised spelling of <c><Unavailable/></c>.</summary>
|
||||||
|
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||||
|
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private readonly Dictionary<string, ItemState> _items = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds every CONDITION observation of <paramref name="document"/> into the tracked state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="document">A <c>/current</c> snapshot or one <c>/sample</c> chunk.</param>
|
||||||
|
/// <param name="baseline">
|
||||||
|
/// True for a <c>/current</c> answer — each DataItem it mentions is reset before its observations are
|
||||||
|
/// applied; false for a <c>/sample</c> delta.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The DataItem ids whose condition state the document touched (empty when none).</returns>
|
||||||
|
public IReadOnlySet<string> Apply(MTConnectStreamsResult document, bool baseline)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(document);
|
||||||
|
|
||||||
|
var touched = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (document.Observations is not { Count: > 0 }) return touched;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var observation in document.Observations)
|
||||||
|
{
|
||||||
|
if (observation?.Condition is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
|
||||||
|
|
||||||
|
if (!_items.TryGetValue(observation.DataItemId, out var item))
|
||||||
|
{
|
||||||
|
_items[observation.DataItemId] = item = new ItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched.Add(observation.DataItemId) && baseline)
|
||||||
|
{
|
||||||
|
item.Active.Clear();
|
||||||
|
item.Unavailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyOne(item, observation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The derived state of one DataItem, or <c>null</c> when it has never been observed as a condition.</summary>
|
||||||
|
/// <param name="dataItemId">The DataItem id.</param>
|
||||||
|
public MTConnectConditionState? Get(string dataItemId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dataItemId) || !_items.TryGetValue(dataItemId, out var item)) return null;
|
||||||
|
|
||||||
|
if (item.Active.Count > 0)
|
||||||
|
{
|
||||||
|
// Most severe wins; among equals, a DETERMINISTIC tie-break (ordinal nativeCode, then type) —
|
||||||
|
// never document order, which an Agent may present differently on a re-baseline and which would
|
||||||
|
// then re-raise (and un-ack) an unchanged condition on every reconnect.
|
||||||
|
var winner = item.Active.Values
|
||||||
|
.OrderByDescending(e => e.Level)
|
||||||
|
.ThenBy(e => e.Detail.NativeCode ?? string.Empty, StringComparer.Ordinal)
|
||||||
|
.ThenBy(e => e.Detail.ConditionType ?? string.Empty, StringComparer.Ordinal)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return new MTConnectConditionState(winner.Level, winner.Detail, winner.TimestampUtc, item.Active.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = item.LastInactive;
|
||||||
|
return last is null
|
||||||
|
? null
|
||||||
|
: new MTConnectConditionState(
|
||||||
|
item.Unavailable ? MTConnectConditionLevel.Unavailable : MTConnectConditionLevel.Normal,
|
||||||
|
last.Detail,
|
||||||
|
last.TimestampUtc,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops every tracked state — the Agent-restart re-baseline, mirroring <see cref="MTConnectObservationIndex.Clear"/>.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_lock) _items.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a parsed condition state word onto a level (unknown vendor words ⇒ Warning).</summary>
|
||||||
|
/// <param name="value">The observation's state word.</param>
|
||||||
|
internal static MTConnectConditionLevel LevelOf(string? value) => value switch
|
||||||
|
{
|
||||||
|
UnavailableSentinel => MTConnectConditionLevel.Unavailable,
|
||||||
|
_ when string.Equals(value, "Normal", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Normal,
|
||||||
|
_ when string.Equals(value, "Fault", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Fault,
|
||||||
|
_ when string.Equals(value, "Unavailable", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Unavailable,
|
||||||
|
_ => MTConnectConditionLevel.Warning,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ApplyOne(ItemState item, MTConnectObservation observation)
|
||||||
|
{
|
||||||
|
var detail = observation.Condition!;
|
||||||
|
var entry = new Entry(LevelOf(observation.Value), detail, observation.TimestampUtc);
|
||||||
|
|
||||||
|
switch (entry.Level)
|
||||||
|
{
|
||||||
|
case MTConnectConditionLevel.Normal:
|
||||||
|
if (detail.NativeCode is null) item.Active.Clear();
|
||||||
|
else item.Active.Remove(detail.NativeCode);
|
||||||
|
item.Unavailable = false;
|
||||||
|
item.LastInactive = entry;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MTConnectConditionLevel.Unavailable:
|
||||||
|
item.Active.Clear();
|
||||||
|
item.Unavailable = true;
|
||||||
|
item.LastInactive = entry;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
item.Active[detail.NativeCode ?? string.Empty] = entry;
|
||||||
|
item.Unavailable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Entry(MTConnectConditionLevel Level, MTConnectConditionDetail Detail, DateTime TimestampUtc);
|
||||||
|
|
||||||
|
private sealed class ItemState
|
||||||
|
{
|
||||||
|
/// <summary>Active conditions keyed by <c>nativeCode</c> ("" for an unnamed one).</summary>
|
||||||
|
public Dictionary<string, Entry> Active { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Whether the latest inactive statement was Unavailable.</summary>
|
||||||
|
public bool Unavailable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The latest Normal/Unavailable observation (its text and type feed the clear message).</summary>
|
||||||
|
public Entry? LastInactive { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="IAlarmSource"/> half of <see cref="MTConnectDriver"/> (#626): CONDITION data items as
|
||||||
|
/// native OPC UA Part 9 alarms.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Which tags are alarms.</b> The server decides, not this driver: a raw tag whose <c>TagConfig</c>
|
||||||
|
/// carries the cross-driver <c>alarm</c> object is materialised as a Part 9 condition at its RawPath, and
|
||||||
|
/// its RawPath arrives here through <see cref="SubscribeAlarmsAsync"/>. Every event this driver raises
|
||||||
|
/// uses that RawPath as <see cref="AlarmEventArgs.ConditionId"/> — the key the host routes on. A CONDITION
|
||||||
|
/// tag WITHOUT an <c>alarm</c> object is unchanged: it stays the plain String value it always was.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One condition per DataItem.</b> MTConnect can hold several simultaneous conditions on one
|
||||||
|
/// DataItem (distinct <c>nativeCode</c>s); the Part 9 condition shows the most severe active one
|
||||||
|
/// (<see cref="MTConnectConditionTracker"/>), and an escalation or a new code re-raises it
|
||||||
|
/// (<see cref="MTConnectConditionAlarmMapper"/>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Delivery is diffed against what the server was actually TOLD.</b> <see cref="_deliveredConditions"/>
|
||||||
|
/// records a state only when an <see cref="OnAlarmEvent"/> handler was attached to receive it. The host's
|
||||||
|
/// <c>DriverInstanceActor</c> detaches its handler while it reconnects and re-subscribes on every
|
||||||
|
/// Connected entry, so a transition that happened while nobody listened stays pending and is delivered on
|
||||||
|
/// the re-subscribe — and an unchanged condition is NOT re-raised there, which would un-acknowledge it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Raised under <see cref="_alarmLock"/>.</b> Two threads emit — the <c>/sample</c> pump and a
|
||||||
|
/// subscribing caller — and the diff + record + raise must be one step or a Raise and its Clear could
|
||||||
|
/// reach the server reversed. The only production handler is <c>DriverInstanceActor</c>'s, a
|
||||||
|
/// non-blocking <c>Tell</c>. <see cref="_alarmLock"/> is never taken while holding
|
||||||
|
/// <see cref="_subscriptionLock"/> or the lifecycle semaphore, and never takes either.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Comms loss emits nothing here</b>, matching every other native source: a driver that cannot reach
|
||||||
|
/// its Agent has nothing new to say, and the host annotates every condition it owns
|
||||||
|
/// <c>BadNoCommunication</c> on the connectivity edge (#477), keeping each one's Active state. On recovery
|
||||||
|
/// the re-baselined <c>/current</c> is diffed like any other document.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class MTConnectDriver : IAlarmSource
|
||||||
|
{
|
||||||
|
/// <summary>Serializes condition-alarm diffing and delivery. See the class remarks.</summary>
|
||||||
|
private readonly Lock _alarmLock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
|
||||||
|
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
|
||||||
|
/// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
|
||||||
|
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
|
||||||
|
/// and a rebuild recreates every condition node inactive — so everything must be told again.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
|
||||||
|
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
|
||||||
|
/// </summary>
|
||||||
|
private HashSet<string>? _lastAlarmReferences;
|
||||||
|
|
||||||
|
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
|
||||||
|
private MTConnectConditionTracker _conditions = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one live alarm subscription, or <c>null</c>. Written under <see cref="_subscriptionLock"/> (it
|
||||||
|
/// keeps the shared <c>/sample</c> pump alive); read with <see cref="Volatile.Read{T}"/> elsewhere.
|
||||||
|
/// </summary>
|
||||||
|
private AlarmSubscriptionState? _alarmSubscription;
|
||||||
|
|
||||||
|
private long _nextAlarmSubscriptionId;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||||
|
|
||||||
|
/// <summary>The condition tracker the pump folds CONDITION observations into. Exposed for tests.</summary>
|
||||||
|
internal MTConnectConditionTracker ConditionTracker => Volatile.Read(ref _conditions);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Collapses to ONE live subscription (the latest), like the Galaxy and FOCAS sources: the owning
|
||||||
|
/// <c>DriverInstanceActor</c> is the only caller and re-subscribes on every Connected entry. Starts the
|
||||||
|
/// shared <c>/sample</c> pump when nothing else had (a driver whose only authored tags are alarms), and
|
||||||
|
/// immediately delivers the current state of every subscribed condition that differs from what the server
|
||||||
|
/// was last told — the re-establish step after a restart or a reconnect.
|
||||||
|
/// </remarks>
|
||||||
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||||
|
|
||||||
|
var references = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var reference in sourceNodeIds)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(reference)) references.Add(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = new MTConnectAlarmSubscriptionHandle(Interlocked.Increment(ref _nextAlarmSubscriptionId));
|
||||||
|
|
||||||
|
lock (_subscriptionLock)
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _alarmSubscription, new AlarmSubscriptionState(handle, references));
|
||||||
|
StartSampleStreamCore(republishOnStart: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_alarmLock)
|
||||||
|
{
|
||||||
|
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
|
||||||
|
// condition node — including ones still in the set — was recreated inactive and has been told
|
||||||
|
// nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
|
||||||
|
// alarm would be re-raised and lose its acknowledgement.
|
||||||
|
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
|
||||||
|
{
|
||||||
|
_deliveredConditions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastAlarmReferences = references;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmitConditionTransitions(touchedDataItemIds: null, resync: true);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"MTConnect driver {DriverInstanceId} opened alarm subscription {SubscriptionId} over {ConditionCount} condition(s).",
|
||||||
|
_driverInstanceId, handle.DiagnosticId, references.Count);
|
||||||
|
|
||||||
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>An unknown or superseded handle is a no-op, matching <see cref="UnsubscribeAsync"/>.</remarks>
|
||||||
|
public async Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(handle);
|
||||||
|
|
||||||
|
bool stopStream;
|
||||||
|
lock (_subscriptionLock)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _alarmSubscription) is not { } live || !ReferenceEquals(live.Handle, handle))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Volatile.Write(ref _alarmSubscription, null);
|
||||||
|
stopStream = !HasSubscribedReferencesCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopStream)
|
||||||
|
{
|
||||||
|
// Outside the lock — see UnsubscribeAsync.
|
||||||
|
await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// MTConnect has no acknowledgement — an Agent's conditions clear when the machine says so. The Part 9
|
||||||
|
/// Acknowledge (and AddComment) is applied server-side; this is the no-op success the host routes it to,
|
||||||
|
/// so nothing is sent to the Agent and capability negotiation still succeeds. Same posture as FOCAS.
|
||||||
|
/// </remarks>
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the alarm subscription names any condition. <b>Caller must hold <see cref="_subscriptionLock"/>.</b></summary>
|
||||||
|
private bool HasAlarmReferencesCore() => Volatile.Read(ref _alarmSubscription) is { References.Count: > 0 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diffs the subscribed conditions against what the server was last told and raises one event per
|
||||||
|
/// condition that moved. Never throws: a faulting handler is logged and the next condition still goes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="touchedDataItemIds">
|
||||||
|
/// Only conditions bound to these DataItems are evaluated; <c>null</c> evaluates every subscribed one.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="resync">Whether this is a (re)subscribe — see <see cref="MTConnectConditionAlarmMapper"/>.</param>
|
||||||
|
private void EmitConditionTransitions(IReadOnlySet<string>? touchedDataItemIds, bool resync)
|
||||||
|
{
|
||||||
|
if (touchedDataItemIds is { Count: 0 }) return;
|
||||||
|
|
||||||
|
var subscription = Volatile.Read(ref _alarmSubscription);
|
||||||
|
if (subscription is not { References.Count: > 0 }) return;
|
||||||
|
|
||||||
|
lock (_alarmLock)
|
||||||
|
{
|
||||||
|
// 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 = OnAlarmEvent;
|
||||||
|
if (handlers is null) return;
|
||||||
|
|
||||||
|
var binding = Volatile.Read(ref _binding);
|
||||||
|
var tracker = Volatile.Read(ref _conditions);
|
||||||
|
|
||||||
|
foreach (var conditionId in subscription.References)
|
||||||
|
{
|
||||||
|
var dataItemId = binding.ToDataItemId(conditionId);
|
||||||
|
if (touchedDataItemIds is not null && !touchedDataItemIds.Contains(dataItemId)) continue;
|
||||||
|
|
||||||
|
var current = tracker.Get(dataItemId);
|
||||||
|
if (current is null) continue;
|
||||||
|
|
||||||
|
_deliveredConditions.TryGetValue(conditionId, out var told);
|
||||||
|
var (evt, nowTold) = MTConnectConditionAlarmMapper.Transition(
|
||||||
|
told, current, conditionId, dataItemId, subscription.Handle, resync);
|
||||||
|
_deliveredConditions[conditionId] = nowTold;
|
||||||
|
|
||||||
|
if (evt is null) continue;
|
||||||
|
|
||||||
|
foreach (var registration in handlers.GetInvocationList())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
((EventHandler<AlarmEventArgs>)registration).Invoke(this, evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"MTConnect driver {DriverInstanceId} caught a fault from an alarm subscriber for {ConditionId}; delivery continues.",
|
||||||
|
_driverInstanceId, conditionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
|
||||||
|
private sealed record AlarmSubscriptionState(MTConnectAlarmSubscriptionHandle Handle, HashSet<string> References);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handle returned by <see cref="MTConnectDriver.SubscribeAlarmsAsync"/>.</summary>
|
||||||
|
/// <param name="Id">The driver-local subscription number.</param>
|
||||||
|
public sealed record MTConnectAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DiagnosticId => $"mtconnect-alarm-sub-{Id}";
|
||||||
|
}
|
||||||
@@ -47,7 +47,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
|
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
|
||||||
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
|
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
|
||||||
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
|
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
|
||||||
/// model).
|
/// model) — plus <see cref="IAlarmSource"/> for CONDITION data items (#626, in
|
||||||
|
/// <c>MTConnectDriver.Alarms.cs</c>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
|
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
|
||||||
@@ -71,7 +72,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
|
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MTConnectDriver
|
public sealed partial class MTConnectDriver
|
||||||
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable,
|
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable,
|
||||||
ISkippedTagReporter
|
ISkippedTagReporter
|
||||||
{
|
{
|
||||||
@@ -1981,6 +1982,10 @@ public sealed class MTConnectDriver
|
|||||||
if (republishOnStart)
|
if (republishOnStart)
|
||||||
{
|
{
|
||||||
FanOut(SubscribedReferences(), ObservationIndex);
|
FanOut(SubscribedReferences(), ObservationIndex);
|
||||||
|
|
||||||
|
// #626: and the conditions, diffed against what the server was last told — so a restart
|
||||||
|
// delivers what changed while the driver was down and re-raises nothing that did not.
|
||||||
|
EmitConditionTransitions(touchedDataItemIds: null, resync: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@@ -2140,6 +2145,7 @@ public sealed class MTConnectDriver
|
|||||||
// then fails, holding stale values from a dead device model would be worse than
|
// then fails, holding stale values from a dead device model would be worse than
|
||||||
// holding none.
|
// holding none.
|
||||||
ObservationIndex.Clear();
|
ObservationIndex.Clear();
|
||||||
|
ConditionTracker.Clear();
|
||||||
FanOut(SubscribedReferences(), ObservationIndex);
|
FanOut(SubscribedReferences(), ObservationIndex);
|
||||||
|
|
||||||
// The driver's own state is consistent by this point, so it is safe to hand the
|
// The driver's own state is consistent by this point, so it is safe to hand the
|
||||||
@@ -2245,7 +2251,7 @@ public sealed class MTConnectDriver
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
PublishAgentCursor(current.InstanceId, current.NextSequence);
|
PublishAgentCursor(current.InstanceId, current.NextSequence);
|
||||||
ApplyAndFanOut(current);
|
ApplyAndFanOut(current, baseline: true);
|
||||||
|
|
||||||
_streamDegraded = false;
|
_streamDegraded = false;
|
||||||
PublishHealthy(DateTime.UtcNow);
|
PublishHealthy(DateTime.UtcNow);
|
||||||
@@ -2328,11 +2334,16 @@ public sealed class MTConnectDriver
|
|||||||
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the shared index and
|
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the shared index and
|
||||||
/// publishes every reference it touched to the handles that subscribe it.
|
/// publishes every reference it touched to the handles that subscribe it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyAndFanOut(MTConnectStreamsResult document)
|
private void ApplyAndFanOut(MTConnectStreamsResult document, bool baseline = false)
|
||||||
{
|
{
|
||||||
var index = ObservationIndex;
|
var index = ObservationIndex;
|
||||||
index.Apply(document);
|
index.Apply(document);
|
||||||
|
|
||||||
|
// #626: CONDITION observations also feed the alarm path, which keeps its own multi-condition state
|
||||||
|
// (a /sample Normal for one nativeCode is not a return to normal while another code is active).
|
||||||
|
var touchedConditions = ConditionTracker.Apply(document, baseline);
|
||||||
|
EmitConditionTransitions(touchedConditions, resync: false);
|
||||||
|
|
||||||
if (document.Observations is not { Count: > 0 })
|
if (document.Observations is not { Count: > 0 })
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2509,6 +2520,13 @@ public sealed class MTConnectDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private bool HasSubscribedReferencesCore()
|
private bool HasSubscribedReferencesCore()
|
||||||
{
|
{
|
||||||
|
// #626: a CONDITION alarm subscription needs the same stream — a driver whose only authored tags are
|
||||||
|
// alarms would otherwise never hear a condition change.
|
||||||
|
if (HasAlarmReferencesCore())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var state in _subscriptions.Values)
|
foreach (var state in _subscriptions.Values)
|
||||||
{
|
{
|
||||||
if (state.References.Count > 0)
|
if (state.References.Count > 0)
|
||||||
@@ -2786,6 +2804,12 @@ public sealed class MTConnectDriver
|
|||||||
index.Apply(current);
|
index.Apply(current);
|
||||||
Volatile.Write(ref _index, index);
|
Volatile.Write(ref _index, index);
|
||||||
|
|
||||||
|
// #626: the CONDITION tracker is per-session state like the index. Nothing is RAISED here — the
|
||||||
|
// lifecycle semaphore is held; the restarted pump delivers the diff as its first act.
|
||||||
|
var conditions = new MTConnectConditionTracker();
|
||||||
|
conditions.Apply(current, baseline: true);
|
||||||
|
Volatile.Write(ref _conditions, conditions);
|
||||||
|
|
||||||
// A fresh session has no failures behind it — including the /sample verdict, because a
|
// A fresh session has no failures behind it — including the /sample verdict, because a
|
||||||
// re-initialize is precisely the event that means an operator changed something.
|
// re-initialize is precisely the event that means an operator changed something.
|
||||||
_streamDegraded = false;
|
_streamDegraded = false;
|
||||||
@@ -2867,6 +2891,7 @@ public sealed class MTConnectDriver
|
|||||||
// against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather
|
// against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather
|
||||||
// than BadNodeIdUnknown ("no such tag").
|
// than BadNodeIdUnknown ("no such tag").
|
||||||
Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags));
|
Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags));
|
||||||
|
Volatile.Write(ref _conditions, new MTConnectConditionTracker());
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,13 +167,35 @@ public sealed record MTConnectStreamsResult(
|
|||||||
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
||||||
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Condition">
|
||||||
|
/// The CONDITION-only detail (#626) — set exactly when the observation came from a
|
||||||
|
/// <c><Condition></c> container, <c>null</c> for every Sample/Event. Its presence is what marks an
|
||||||
|
/// observation as a condition for the alarm path; <see cref="Value"/> still carries the state word.
|
||||||
|
/// </param>
|
||||||
public sealed record MTConnectObservation(
|
public sealed record MTConnectObservation(
|
||||||
string DataItemId,
|
string DataItemId,
|
||||||
string Value,
|
string Value,
|
||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
bool IsStructured = false,
|
bool IsStructured = false,
|
||||||
int? SampleCount = null,
|
int? SampleCount = null,
|
||||||
double? SampleRate = null);
|
double? SampleRate = null,
|
||||||
|
MTConnectConditionDetail? Condition = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an MTConnect CONDITION observation says beyond its state word (#626). Every field is the
|
||||||
|
/// Agent's own text, trimmed, or <c>null</c> when absent or blank — none is interpreted at this layer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Text">The element's text content: the operator-facing condition message.</param>
|
||||||
|
/// <param name="NativeCode">The <c>nativeCode</c> attribute — the controller's own alarm number/code.</param>
|
||||||
|
/// <param name="NativeSeverity">The <c>nativeSeverity</c> attribute — the controller's own severity, verbatim.</param>
|
||||||
|
/// <param name="Qualifier">The <c>qualifier</c> attribute (e.g. <c>HIGH</c>, <c>LOW</c>).</param>
|
||||||
|
/// <param name="ConditionType">The element's <c>type</c> attribute (e.g. <c>SYSTEM</c>, <c>TEMPERATURE</c>).</param>
|
||||||
|
public sealed record MTConnectConditionDetail(
|
||||||
|
string? Text,
|
||||||
|
string? NativeCode,
|
||||||
|
string? NativeSeverity,
|
||||||
|
string? Qualifier,
|
||||||
|
string? ConditionType);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ internal static class MTConnectStreamsParser
|
|||||||
string? conditionState = null;
|
string? conditionState = null;
|
||||||
int? sampleCount = null;
|
int? sampleCount = null;
|
||||||
double? sampleRate = null;
|
double? sampleRate = null;
|
||||||
|
(string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default;
|
||||||
|
|
||||||
if (isObservation)
|
if (isObservation)
|
||||||
{
|
{
|
||||||
@@ -387,6 +388,17 @@ internal static class MTConnectStreamsParser
|
|||||||
timestampUtc = ReadTimestampUtc(reader, dataItemId);
|
timestampUtc = ReadTimestampUtc(reader, dataItemId);
|
||||||
conditionState = isCondition ? ConditionState(name) : null;
|
conditionState = isCondition ? ConditionState(name) : null;
|
||||||
|
|
||||||
|
// #626: a condition's alarm identity rides the element's attributes — read here, before the
|
||||||
|
// subtree walk moves the reader off them. Verbatim (trimmed), never interpreted.
|
||||||
|
if (isCondition)
|
||||||
|
{
|
||||||
|
conditionAttributes = (
|
||||||
|
MTConnectXml.OptionalAttribute(reader, "nativeCode")?.Trim(),
|
||||||
|
MTConnectXml.OptionalAttribute(reader, "nativeSeverity")?.Trim(),
|
||||||
|
MTConnectXml.OptionalAttribute(reader, "qualifier")?.Trim(),
|
||||||
|
MTConnectXml.OptionalAttribute(reader, "type")?.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
// TIME_SERIES metadata rides the observation element itself. Read here, before the subtree
|
// TIME_SERIES metadata rides the observation element itself. Read here, before the subtree
|
||||||
// walk moves the reader off this element's attributes. Lenient: a vendor's unparseable
|
// walk moves the reader off this element's attributes. Lenient: a vendor's unparseable
|
||||||
// attribute is dropped rather than failing the whole document (the vector is checked
|
// attribute is dropped rather than failing the whole document (the vector is checked
|
||||||
@@ -413,9 +425,11 @@ internal static class MTConnectStreamsParser
|
|||||||
observations.Add(null!);
|
observations.Add(null!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A CONDITION's value is its element NAME, so nothing below it is collected for it — but an
|
// A CONDITION's VALUE is its element NAME, so its text never becomes the value — but since #626
|
||||||
// enclosing observation's text still is, hence the ancestor sink rather than none at all.
|
// the text is still collected, into a sink of its own, because it is the condition's
|
||||||
var sink = isObservation && !isCondition ? new ValueSink() : textSink;
|
// operator-facing MESSAGE (the alarm path's Message). It is deliberately not appended to an
|
||||||
|
// enclosing observation's sink: a condition is never nested inside a text-valued observation.
|
||||||
|
var sink = isObservation ? new ValueSink() : textSink;
|
||||||
|
|
||||||
var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink);
|
var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink);
|
||||||
|
|
||||||
@@ -425,9 +439,17 @@ internal static class MTConnectStreamsParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
string value;
|
string value;
|
||||||
|
MTConnectConditionDetail? condition = null;
|
||||||
if (isCondition)
|
if (isCondition)
|
||||||
{
|
{
|
||||||
value = conditionState!;
|
value = conditionState!;
|
||||||
|
var text = sink!.Value.Trim();
|
||||||
|
condition = new MTConnectConditionDetail(
|
||||||
|
text.Length == 0 ? null : text,
|
||||||
|
conditionAttributes.NativeCode,
|
||||||
|
conditionAttributes.NativeSeverity,
|
||||||
|
conditionAttributes.Qualifier,
|
||||||
|
conditionAttributes.Type);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -452,7 +474,8 @@ internal static class MTConnectStreamsParser
|
|||||||
// away a perfectly well-determined state.
|
// away a perfectly well-determined state.
|
||||||
IsStructured: !isCondition && hasElements,
|
IsStructured: !isCondition && hasElements,
|
||||||
SampleCount: sampleCount,
|
SampleCount: sampleCount,
|
||||||
SampleRate: sampleRate);
|
SampleRate: sampleRate,
|
||||||
|
Condition: condition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1789,6 +1789,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
|
||||||
|
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
|
||||||
|
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
|
||||||
|
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
|
||||||
|
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
|
||||||
|
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
|
||||||
|
// warm, as for connectivity) and no alerts row.
|
||||||
|
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
|
||||||
|
{
|
||||||
|
foreach (var n in nodeIds)
|
||||||
|
{
|
||||||
|
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
|
||||||
|
n.NodeId, quality, msg.Args.SourceTimestampUtc, n.Realm));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// PRIMARY GATE (archreview 03/S4) — decided ONCE for this transition: the OPC UA condition write
|
// PRIMARY GATE (archreview 03/S4) — decided ONCE for this transition: the OPC UA condition write
|
||||||
// below stays UNGATED (a Secondary keeps its address space warm for failover), but only the Primary
|
// below stays UNGATED (a Secondary keeps its address space warm for failover), but only the Primary
|
||||||
// publishes the single cluster-wide alerts copy. Denied on a Secondary/Detached, OR on a multi-driver
|
// publishes the single cluster-wide alerts copy. Denied on a Secondary/Detached, OR on a multi-driver
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public sealed class NativeAlarmProjector
|
|||||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
|
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
|
||||||
{
|
{
|
||||||
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
|
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
|
||||||
var sev = MapSeverity(e.Severity);
|
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||||
|
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||||
var (active, acked) = e.Kind switch
|
var (active, acked) = e.Kind switch
|
||||||
{
|
{
|
||||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||||
@@ -32,7 +33,11 @@ public sealed class NativeAlarmProjector
|
|||||||
_prior[nodeId] = (active, acked);
|
_prior[nodeId] = (active, acked);
|
||||||
return new AlarmConditionSnapshot(
|
return new AlarmConditionSnapshot(
|
||||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
|
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||||
|
// #626: a per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
|
||||||
|
// (every pre-#626 source) ⇒ Good, as before — driver-wide comms loss stays the host's
|
||||||
|
// out-of-band WriteAlarmQuality annotation (#477), not something the projector infers.
|
||||||
|
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #626 — <see cref="MTConnectDriver"/>'s <see cref="IAlarmSource"/> half against
|
||||||
|
/// <see cref="CannedAgentClient"/>: CONDITION transitions off the <c>/sample</c> stream become Part 9 alarm
|
||||||
|
/// events keyed by the alarm tag's RawPath, an unchanged state is never re-raised on a re-subscribe or a
|
||||||
|
/// re-baseline, and a transition nobody was listening for is delivered on the next subscribe.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every "nothing was raised" assertion is followed by a transition that MUST be raised, and asserts that
|
||||||
|
/// one is the first event — an absence proven by order, never by a delay.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectAlarmSourceTests
|
||||||
|
{
|
||||||
|
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
|
||||||
|
private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
|
||||||
|
private const string ConditionId = "dev1_system_cond";
|
||||||
|
private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond";
|
||||||
|
private const long FixtureInstanceId = 1655000000L;
|
||||||
|
private const long RestartedInstanceId = 1655999999L;
|
||||||
|
|
||||||
|
private static readonly DateTime ObservedAt = new(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private static MTConnectDriverOptions Opts() =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
AgentUri = "http://fixture-agent:5000",
|
||||||
|
RequestTimeoutMs = 5000,
|
||||||
|
RawTags =
|
||||||
|
[
|
||||||
|
// The alarm-bearing raw tag and an ordinary String tag on the SAME DataItem: the alarm path
|
||||||
|
// must not disturb the existing value surface.
|
||||||
|
new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
||||||
|
new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
||||||
|
new RawTagEntry(OtherConditionRawPath, """{"address":"dev1_other_cond"}""", false, "dev1"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue<AlarmEventArgs> Events)>
|
||||||
|
AlarmDriverAsync(CannedAgentClient? agent = null)
|
||||||
|
{
|
||||||
|
var client = agent ?? CannedAgentClient.FromFixtures();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
|
||||||
|
await driver.InitializeAsync("{}", Ct);
|
||||||
|
|
||||||
|
var events = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
driver.OnAlarmEvent += (_, e) => events.Enqueue(e);
|
||||||
|
|
||||||
|
return (driver, client, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MTConnectStreamsResult Chunk(long first, long next, params MTConnectObservation[] observations) =>
|
||||||
|
new(FixtureInstanceId, next, first, observations);
|
||||||
|
|
||||||
|
private static MTConnectObservation Cond(string state, string? nativeCode = null, string? text = null) =>
|
||||||
|
new(ConditionId, state, ObservedAt,
|
||||||
|
Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Normal_then_fault_then_normal_raises_then_clears_keyed_by_the_RawPath()
|
||||||
|
{
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
events.ShouldBeEmpty("the primed /current says Normal, and a first-seen Normal has nothing to say");
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "Spindle overtemperature"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
var seen = events.ToArray();
|
||||||
|
seen.Length.ShouldBe(2);
|
||||||
|
seen[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
seen[0].ConditionId.ShouldBe(ConditionRawPath);
|
||||||
|
seen[0].OpcUaSeverity.ShouldBe(800);
|
||||||
|
seen[0].Message.ShouldBe("Spindle overtemperature [nativeCode=E17]");
|
||||||
|
seen[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||||
|
seen[1].ConditionId.ShouldBe(ConditionRawPath);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Several_native_codes_show_the_most_severe_and_clear_only_when_the_last_goes()
|
||||||
|
{
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1", "coolant low"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Fault", "F1", "spindle"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(110, 111, Cond("Normal", "F1"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(111, 112, Cond("Normal", "W1"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
var seen = events.ToArray();
|
||||||
|
seen.Select(e => e.Kind).ShouldBe(
|
||||||
|
[
|
||||||
|
AlarmTransitionKind.Raise, // Warning W1
|
||||||
|
AlarmTransitionKind.Retrigger, // escalation to Fault F1
|
||||||
|
AlarmTransitionKind.Retrigger, // F1 cleared — W1 is still active, NOT a return to normal
|
||||||
|
AlarmTransitionKind.Clear, // the last code cleared
|
||||||
|
]);
|
||||||
|
seen.Select(e => e.OpcUaSeverity).ShouldBe([500, 800, 500, 500]);
|
||||||
|
seen[2].Message.ShouldStartWith("coolant low");
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unavailable_condition_reaches_the_server_as_a_bad_quality_annotation()
|
||||||
|
{
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
var evt = events.ShouldHaveSingleItem();
|
||||||
|
evt.IsQualityOnly.ShouldBeTrue();
|
||||||
|
evt.QualityStatusCode.ShouldBe(0x80310000u);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_ack_survives_an_adapter_unavailable_blip()
|
||||||
|
{
|
||||||
|
// A cppagent marks an adapter's items UNAVAILABLE when the adapter socket drops. The Fault must stay
|
||||||
|
// active (no Clear/Raise pair, which would reset the operator's ack); only its quality moves.
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.PumpAsync(Chunk(110, 111, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
var seen = events.ToArray();
|
||||||
|
seen.Select(e => e.Kind).ShouldBe(
|
||||||
|
[AlarmTransitionKind.Raise, AlarmTransitionKind.Unspecified, AlarmTransitionKind.Unspecified]);
|
||||||
|
seen[1].IsQualityOnly.ShouldBeTrue();
|
||||||
|
seen[1].QualityStatusCode.ShouldBe(0x80310000u);
|
||||||
|
seen[2].IsQualityOnly.ShouldBeTrue();
|
||||||
|
seen[2].QualityStatusCode.ShouldBe(0x00000000u);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
|
||||||
|
{
|
||||||
|
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
|
||||||
|
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
|
||||||
|
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
|
||||||
|
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
||||||
|
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
// Positive control: the next real transition is the very next event.
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Select(e => e.Kind).ShouldBe(
|
||||||
|
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||||
|
"the same set (a reconnect) must re-raise nothing");
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
|
||||||
|
{
|
||||||
|
// The reconnect path: DriverInstanceActor re-subscribes on every Connected entry. Re-raising the
|
||||||
|
// still-active Fault would un-acknowledge an operator's ack.
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
// Positive control: the next real transition is the very next event.
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reinitialize_replaying_the_same_active_state_raises_nothing()
|
||||||
|
{
|
||||||
|
var agent = CannedAgentClient.FromFixtures();
|
||||||
|
agent.Current = Chunk(1, 108, Cond("Fault", "E17", "overtemp"));
|
||||||
|
var (driver, _, events) = await AlarmDriverAsync(agent);
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise, "the subscribe re-establishes an active condition");
|
||||||
|
|
||||||
|
// The first pump must own its /sample before the restart, or the count barrier below is off by one.
|
||||||
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
// A comms-loss recovery re-initializes against the same Agent, which replays the same Fault.
|
||||||
|
await driver.ReinitializeAsync("{}", Ct);
|
||||||
|
|
||||||
|
// The restarted pump republishes (values AND conditions) before it opens /sample, so its second
|
||||||
|
// /sample call is the barrier that the replay has been diffed.
|
||||||
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_transition_nobody_was_listening_for_is_delivered_on_the_next_subscribe()
|
||||||
|
{
|
||||||
|
// While the host reconnects it detaches its handler; the fault that clears in that window must not be
|
||||||
|
// recorded as delivered, or the condition stays active on the server forever.
|
||||||
|
var agent = CannedAgentClient.FromFixtures();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => agent);
|
||||||
|
await driver.InitializeAsync("{}", Ct);
|
||||||
|
var events = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
EventHandler<AlarmEventArgs> handler = (_, e) => events.Enqueue(e);
|
||||||
|
driver.OnAlarmEvent += handler;
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
driver.OnAlarmEvent -= handler;
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
driver.OnAlarmEvent += handler;
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_agent_restart_whose_baseline_no_longer_lists_the_fault_clears_it()
|
||||||
|
{
|
||||||
|
var agent = CannedAgentClient.FromFixtures();
|
||||||
|
var (driver, _, events) = await AlarmDriverAsync(agent);
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
// The restarted Agent's /current reports the condition Normal.
|
||||||
|
agent.CurrentAnswers.Enqueue(new MTConnectStreamsResult(RestartedInstanceId, 5, 1, [Cond("Normal")]));
|
||||||
|
await agent.PumpAsync(new MTConnectStreamsResult(RestartedInstanceId, 4, 1, [])).WaitAsync(Watchdog, Ct);
|
||||||
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_dropped_stream_raises_no_alarm_event()
|
||||||
|
{
|
||||||
|
// Comms loss is the host's to annotate (#477) — the driver says nothing on its own.
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
agent.EndStream();
|
||||||
|
await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
// Positive control: after reconnecting, the next transition is the next event.
|
||||||
|
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_alarm_only_subscription_starts_the_sample_stream()
|
||||||
|
{
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
driver.SampleStreamTask.ShouldBeNull("no value subscription exists");
|
||||||
|
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1"))).WaitAsync(Watchdog, Ct);
|
||||||
|
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_condition_string_value_surface_is_unchanged_beside_the_alarm()
|
||||||
|
{
|
||||||
|
var (driver, agent, events) = await AlarmDriverAsync();
|
||||||
|
var values = new ConcurrentQueue<DataChangeEventArgs>();
|
||||||
|
driver.OnDataChange += (_, e) => values.Enqueue(e);
|
||||||
|
|
||||||
|
await driver.SubscribeAsync([ConditionValueRawPath], TimeSpan.FromMilliseconds(50), Ct);
|
||||||
|
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||||
|
|
||||||
|
values.Where(v => v.FullReference == ConditionValueRawPath).Select(v => v.Snapshot.Value)
|
||||||
|
.ShouldBe(["Normal", "Fault"]);
|
||||||
|
values.ShouldNotContain(v => v.FullReference == ConditionRawPath,
|
||||||
|
"the alarm RawPath is not a value subscription and gets no data-change");
|
||||||
|
events.ShouldHaveSingleItem().ConditionId.ShouldBe(ConditionRawPath);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_is_a_no_op_success_that_sends_nothing_to_the_agent()
|
||||||
|
{
|
||||||
|
var (driver, agent, _) = await AlarmDriverAsync();
|
||||||
|
|
||||||
|
await driver.AcknowledgeAsync([new AlarmAcknowledgeRequest(ConditionRawPath, ConditionRawPath, "seen", "op")], Ct);
|
||||||
|
|
||||||
|
agent.Writes.ShouldBeEmpty();
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsubscribing_the_last_alarm_subscription_stops_an_alarm_only_stream()
|
||||||
|
{
|
||||||
|
var (driver, agent, _) = await AlarmDriverAsync();
|
||||||
|
var handle = await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||||
|
await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||||
|
var pump = driver.SampleStreamTask.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await driver.UnsubscribeAlarmsAsync(handle, Ct);
|
||||||
|
|
||||||
|
await pump.WaitAsync(Watchdog, Ct);
|
||||||
|
driver.SampleStreamTask.ShouldBeNull();
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(Ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #626 — the pure halves of the CONDITION → Part 9 path: <see cref="MTConnectConditionTracker"/>
|
||||||
|
/// (MTConnect's multi-condition semantics) and <see cref="MTConnectConditionAlarmMapper"/> (state mapping and
|
||||||
|
/// transition sequencing). The driver wiring is covered by <c>MTConnectAlarmSourceTests</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MTConnectConditionAlarmMappingTests
|
||||||
|
{
|
||||||
|
private const string Id = "dev1_system_cond";
|
||||||
|
private const string RawPath = "Plant/MT/dev1/SystemCond";
|
||||||
|
private const uint Good = 0x00000000u;
|
||||||
|
private const uint BadNoCommunication = 0x80310000u;
|
||||||
|
|
||||||
|
private static readonly DateTime T0 = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static readonly IAlarmSubscriptionHandle Handle = new MTConnectAlarmSubscriptionHandle(1);
|
||||||
|
|
||||||
|
// ---- tracker ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_never_observed_item_has_no_state()
|
||||||
|
{
|
||||||
|
new MTConnectConditionTracker().Get(Id).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Samples_and_events_never_reach_the_tracker()
|
||||||
|
{
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
|
||||||
|
var touched = tracker.Apply(Doc(new MTConnectObservation(Id, "Fault", T0)), baseline: false);
|
||||||
|
|
||||||
|
touched.ShouldBeEmpty("an observation without Condition detail did not come from a <Condition>");
|
||||||
|
tracker.Get(Id).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_most_severe_of_several_simultaneous_conditions_wins()
|
||||||
|
{
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("Warning", "W1", at: 0), Cond("Fault", "F1", at: 1), Cond("Warning", "W2", at: 2)), baseline: false);
|
||||||
|
|
||||||
|
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||||
|
state.Level.ShouldBe(MTConnectConditionLevel.Fault);
|
||||||
|
state.Detail.NativeCode.ShouldBe("F1");
|
||||||
|
state.ActiveCount.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_normal_for_one_native_code_in_a_later_sample_clears_only_that_code()
|
||||||
|
{
|
||||||
|
// The index's per-document reconcile would read this chunk as "Normal"; the alarm path must not.
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
|
||||||
|
tracker.Apply(Doc(Cond("Warning", "B", at: 1)), baseline: false);
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("Normal", "A", at: 2)), baseline: false);
|
||||||
|
|
||||||
|
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||||
|
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
|
||||||
|
state.Detail.NativeCode.ShouldBe("B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_normal_without_a_native_code_clears_every_code()
|
||||||
|
{
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
|
||||||
|
|
||||||
|
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unavailable_clears_every_code_until_any_other_level_arrives()
|
||||||
|
{
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("UNAVAILABLE", null, at: 1)), baseline: false);
|
||||||
|
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Unavailable);
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
|
||||||
|
tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_baseline_drops_a_code_the_current_document_no_longer_lists()
|
||||||
|
{
|
||||||
|
// A code cleared while the driver was not listening must not survive a /current re-baseline.
|
||||||
|
var tracker = new MTConnectConditionTracker();
|
||||||
|
tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
|
||||||
|
|
||||||
|
tracker.Apply(Doc(Cond("Warning", "B", at: 2)), baseline: true);
|
||||||
|
|
||||||
|
var state = tracker.Get(Id).ShouldNotBeNull();
|
||||||
|
state.Level.ShouldBe(MTConnectConditionLevel.Warning);
|
||||||
|
state.ActiveCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unrecognised_vendor_state_is_surfaced_as_a_warning()
|
||||||
|
{
|
||||||
|
MTConnectConditionTracker.LevelOf("Alarm").ShouldBe(MTConnectConditionLevel.Warning);
|
||||||
|
MTConnectConditionTracker.LevelOf("UNAVAILABLE").ShouldBe(MTConnectConditionLevel.Unavailable);
|
||||||
|
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Equal_level_conditions_tie_break_deterministically_regardless_of_document_order()
|
||||||
|
{
|
||||||
|
// Review MINOR 2: an Agent that lists the same two Warnings in a different order on a re-baseline must
|
||||||
|
// not change the winner, or every reconnect would retrigger (and un-ack) the condition.
|
||||||
|
var forward = new MTConnectConditionTracker();
|
||||||
|
forward.Apply(Doc(Cond("Warning", "B", at: 0), Cond("Warning", "A", at: 1)), baseline: true);
|
||||||
|
var reverse = new MTConnectConditionTracker();
|
||||||
|
reverse.Apply(Doc(Cond("Warning", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: true);
|
||||||
|
|
||||||
|
forward.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
|
||||||
|
reverse.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mapper: state mapping ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fault_raises_active_at_severity_800_with_the_text_as_message()
|
||||||
|
{
|
||||||
|
var evt = Map(null, State(MTConnectConditionLevel.Fault, "Spindle overtemperature", "E17", "2", "HIGH"))
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
|
||||||
|
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
evt.ConditionId.ShouldBe(RawPath, "the host routes on ConditionId == RawPath");
|
||||||
|
evt.SourceNodeId.ShouldBe(Id);
|
||||||
|
evt.OpcUaSeverity.ShouldBe(800);
|
||||||
|
evt.Severity.ShouldBe(AlarmSeverity.High);
|
||||||
|
evt.QualityStatusCode.ShouldBe(Good);
|
||||||
|
evt.Message.ShouldBe("Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]");
|
||||||
|
evt.AlarmType.ShouldBe("SYSTEM");
|
||||||
|
evt.SourceTimestampUtc.ShouldBe(T0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Warning_raises_active_at_severity_500()
|
||||||
|
{
|
||||||
|
var evt = Map(null, State(MTConnectConditionLevel.Warning)).ShouldNotBeNull();
|
||||||
|
|
||||||
|
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
evt.OpcUaSeverity.ShouldBe(500);
|
||||||
|
evt.Severity.ShouldBe(AlarmSeverity.Medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_condition_without_text_falls_back_to_its_type_level_and_native_code()
|
||||||
|
{
|
||||||
|
MTConnectConditionAlarmMapper.MessageOf(State(MTConnectConditionLevel.Fault, text: null, nativeCode: "1001"))
|
||||||
|
.ShouldBe("SYSTEM Fault [nativeCode=1001]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mapper: transition sequencing ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normal_then_fault_then_normal_is_raise_then_clear()
|
||||||
|
{
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Normal),
|
||||||
|
State(MTConnectConditionLevel.Fault, "overtemp", "E17"),
|
||||||
|
State(MTConnectConditionLevel.Normal));
|
||||||
|
|
||||||
|
events[0].ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
|
||||||
|
events[1].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
var clear = events[2].ShouldNotBeNull();
|
||||||
|
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||||
|
clear.QualityStatusCode.ShouldBe(Good);
|
||||||
|
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
|
||||||
|
{
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Warning, "coolant low", "W1"),
|
||||||
|
State(MTConnectConditionLevel.Fault, "coolant empty", "F1"));
|
||||||
|
|
||||||
|
var evt = events[1].ShouldNotBeNull();
|
||||||
|
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||||
|
evt.OpcUaSeverity.ShouldBe(800);
|
||||||
|
evt.Message.ShouldStartWith("coolant empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_different_active_native_code_at_the_same_level_is_a_retrigger()
|
||||||
|
{
|
||||||
|
// Includes the documented "clearing the worst code re-raises the next one down, un-acked" case.
|
||||||
|
Sequence(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))[1]
|
||||||
|
.ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unchanged_active_statement_emits_nothing_even_with_a_new_timestamp_or_count()
|
||||||
|
{
|
||||||
|
// The reconnect replay: /current re-reports the same Fault under a new sequence. Re-raising it would
|
||||||
|
// un-acknowledge an operator's ack.
|
||||||
|
var before = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
|
||||||
|
var replayed = before with { TimestampUtc = T0.AddMinutes(5), ActiveCount = 2 };
|
||||||
|
|
||||||
|
Map(before, replayed).ShouldBeNull();
|
||||||
|
Map(before, replayed, resync: true).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_text_only_change_updates_nothing_and_the_first_text_is_kept()
|
||||||
|
{
|
||||||
|
// Review MINOR 4 / ruling 5: an adapter embedding a live value in the text must not re-raise (un-ack).
|
||||||
|
var first = State(MTConnectConditionLevel.Fault, "Spindle temp 82C", "E17");
|
||||||
|
var (evt, told) = MTConnectConditionAlarmMapper.Transition(
|
||||||
|
new MTConnectToldCondition(first, false), State(MTConnectConditionLevel.Fault, "Spindle temp 83C", "E17"),
|
||||||
|
RawPath, Id, Handle, resync: false);
|
||||||
|
|
||||||
|
evt.ShouldBeNull();
|
||||||
|
told.Statement.ShouldNotBeNull().Detail.Text.ShouldBe("Spindle temp 82C");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_repeated_normal_with_different_text_does_not_re_clear()
|
||||||
|
{
|
||||||
|
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mapper: Unavailable (comms loss one hop out) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_active_condition_going_unavailable_stays_active_with_a_bad_quality_annotation()
|
||||||
|
{
|
||||||
|
var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
|
||||||
|
evt.IsQualityOnly.ShouldBeTrue("not a Clear: the state is unknown, not normal (#477/#481 posture)");
|
||||||
|
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||||
|
evt.Message.ShouldBe("x [nativeCode=E1]", "the annotation describes the statement the server still holds");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_ack_survives_an_unavailable_blip_when_the_same_fault_returns()
|
||||||
|
{
|
||||||
|
// Fault → UNAVAILABLE → the same Fault: two quality-only annotations, no Clear, no Raise — so the
|
||||||
|
// operator's acknowledgement is never reset.
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Fault, "x", "E1"),
|
||||||
|
State(MTConnectConditionLevel.Unavailable),
|
||||||
|
State(MTConnectConditionLevel.Fault, "x", "E1"));
|
||||||
|
|
||||||
|
events[0].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||||
|
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||||
|
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||||
|
events[2]!.QualityStatusCode.ShouldBe(Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_active_condition_returning_normal_from_unavailable_is_a_clear()
|
||||||
|
{
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Fault, "x", "E1"),
|
||||||
|
State(MTConnectConditionLevel.Unavailable),
|
||||||
|
State(MTConnectConditionLevel.Normal));
|
||||||
|
|
||||||
|
var clear = events[2].ShouldNotBeNull();
|
||||||
|
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||||
|
clear.QualityStatusCode.ShouldBe(Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_active_condition_returning_with_a_different_statement_is_a_retrigger()
|
||||||
|
{
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Warning, "w", "W1"),
|
||||||
|
State(MTConnectConditionLevel.Unavailable),
|
||||||
|
State(MTConnectConditionLevel.Fault, "f", "F1"));
|
||||||
|
|
||||||
|
var evt = events[2].ShouldNotBeNull();
|
||||||
|
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||||
|
evt.QualityStatusCode.ShouldBe(Good);
|
||||||
|
evt.OpcUaSeverity.ShouldBe(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
|
||||||
|
{
|
||||||
|
var events = Sequence(
|
||||||
|
State(MTConnectConditionLevel.Normal),
|
||||||
|
State(MTConnectConditionLevel.Unavailable),
|
||||||
|
State(MTConnectConditionLevel.Normal));
|
||||||
|
|
||||||
|
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||||
|
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||||
|
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||||
|
events[2]!.QualityStatusCode.ShouldBe(Good);
|
||||||
|
|
||||||
|
Map(null, State(MTConnectConditionLevel.Unavailable)).ShouldNotBeNull().IsQualityOnly
|
||||||
|
.ShouldBeTrue("first-seen Unavailable must reach the node");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unchanged_unavailable_is_reasserted_only_on_a_resync()
|
||||||
|
{
|
||||||
|
// The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back.
|
||||||
|
var told = new MTConnectToldCondition(State(MTConnectConditionLevel.Fault, "x", "E1"), QualityBad: true);
|
||||||
|
var unavailable = State(MTConnectConditionLevel.Unavailable);
|
||||||
|
|
||||||
|
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: false).Event.ShouldBeNull();
|
||||||
|
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: true).Event
|
||||||
|
.ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_first_seen_unavailable_recovering_straight_to_fault_raises_with_good_quality()
|
||||||
|
{
|
||||||
|
var events = Sequence(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"));
|
||||||
|
|
||||||
|
var evt = events[1].ShouldNotBeNull();
|
||||||
|
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||||
|
evt.QualityStatusCode.ShouldBe(Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
/// <summary>Maps one step from a told state of <paramref name="statement"/> (Good quality).</summary>
|
||||||
|
private static AlarmEventArgs? Map(MTConnectConditionState? statement, MTConnectConditionState current, bool resync = false) =>
|
||||||
|
MTConnectConditionAlarmMapper.Transition(
|
||||||
|
statement is null ? null : new MTConnectToldCondition(statement, false), current, RawPath, Id, Handle, resync).Event;
|
||||||
|
|
||||||
|
/// <summary>Threads the told record through a sequence of Agent statements, returning each step's event.</summary>
|
||||||
|
private static AlarmEventArgs?[] Sequence(params MTConnectConditionState[] states)
|
||||||
|
{
|
||||||
|
MTConnectToldCondition? told = null;
|
||||||
|
var events = new AlarmEventArgs?[states.Length];
|
||||||
|
for (var i = 0; i < states.Length; i++)
|
||||||
|
{
|
||||||
|
(events[i], told) = MTConnectConditionAlarmMapper.Transition(told, states[i], RawPath, Id, Handle, resync: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MTConnectConditionState State(
|
||||||
|
MTConnectConditionLevel level,
|
||||||
|
string? text = null,
|
||||||
|
string? nativeCode = null,
|
||||||
|
string? nativeSeverity = null,
|
||||||
|
string? qualifier = null) =>
|
||||||
|
new(level, new MTConnectConditionDetail(text, nativeCode, nativeSeverity, qualifier, "SYSTEM"), T0,
|
||||||
|
level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault ? 1 : 0);
|
||||||
|
|
||||||
|
private static MTConnectObservation Cond(string state, string? nativeCode, int at) =>
|
||||||
|
new(Id, state, T0.AddSeconds(at),
|
||||||
|
Condition: new MTConnectConditionDetail($"{state} {nativeCode}", nativeCode, null, null, "SYSTEM"));
|
||||||
|
|
||||||
|
private static MTConnectStreamsResult Doc(params MTConnectObservation[] observations) =>
|
||||||
|
new(1, 2, 1, observations);
|
||||||
|
}
|
||||||
@@ -131,6 +131,36 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
|
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_condition_carries_its_text_and_native_attributes_as_condition_detail()
|
||||||
|
{
|
||||||
|
// #626: the alarm path needs the message (the text) and the controller's own identity for it.
|
||||||
|
var result = MTConnectStreamsParser.Parse(StreamsDocument(
|
||||||
|
"""<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" type="TEMPERATURE" nativeCode=" E17 " nativeSeverity="2" qualifier="HIGH"> Spindle overtemperature </Fault></Condition>"""));
|
||||||
|
|
||||||
|
var detail = result.Observations.ShouldHaveSingleItem().Condition.ShouldNotBeNull();
|
||||||
|
detail.Text.ShouldBe("Spindle overtemperature");
|
||||||
|
detail.NativeCode.ShouldBe("E17");
|
||||||
|
detail.NativeSeverity.ShouldBe("2");
|
||||||
|
detail.Qualifier.ShouldBe("HIGH");
|
||||||
|
detail.ConditionType.ShouldBe("TEMPERATURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_empty_condition_has_detail_with_null_text_and_samples_have_none()
|
||||||
|
{
|
||||||
|
var current = ParseCurrent();
|
||||||
|
|
||||||
|
var condition = current["dev1_system_cond"].Condition.ShouldNotBeNull();
|
||||||
|
condition.Text.ShouldBeNull();
|
||||||
|
condition.NativeCode.ShouldBeNull();
|
||||||
|
condition.ConditionType.ShouldBe("SYSTEM");
|
||||||
|
|
||||||
|
// The detail is the alarm path's "this came from a <Condition>" marker — never on a Sample/Event.
|
||||||
|
current["dev1_pos"].Condition.ShouldBeNull();
|
||||||
|
current["dev1_execution"].Condition.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
|
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
|
||||||
{
|
{
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #626 over the wire — an MTConnect CONDITION transition, streamed by a scripted Agent through the real
|
||||||
|
/// <see cref="MTConnectDriver"/>, projected by the production <see cref="NativeAlarmProjector"/> and written
|
||||||
|
/// through the production sink, reaches a client's event subscription on the RAW DEVICE FOLDER as a Part 9
|
||||||
|
/// condition event carrying the driver's message — then its return to normal does too.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one seam not exercised is <c>DriverHostActor</c>'s routing (ConditionId → condition NodeId), which
|
||||||
|
/// <c>DriverHostActorNativeAlarmTests</c> covers; here the driver's ConditionId is asserted to BE the
|
||||||
|
/// materialised condition's RawPath, which is the whole contract that routing relies on.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectConditionAlarmWireTests
|
||||||
|
{
|
||||||
|
private const string DataItemId = "dev1_system_cond";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_condition_fault_an_unavailable_blip_and_its_clear_reach_the_raw_device_folder()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var rig = await StartAsync("MTConnectConditionWire", encrypted: false, ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rig.MaterialiseNative();
|
||||||
|
|
||||||
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||||
|
var events = new RecordedEvents();
|
||||||
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
var folderItem = RecordedEvents.AddEventItem(subscription, new NodeId(RawDeviceFolder, rawNs));
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
folderItem.Status.Error.ShouldBeNull();
|
||||||
|
|
||||||
|
var agent = new ScriptedAgent();
|
||||||
|
var driver = new MTConnectDriver(
|
||||||
|
new ZB.MOM.WW.OtOpcUa.Driver.MTConnect.MTConnectDriverOptions
|
||||||
|
{
|
||||||
|
AgentUri = "http://scripted-agent:5000",
|
||||||
|
RawTags = [new RawTagEntry(RawAlarmPath, $$"""{"address":"{{DataItemId}}"}""", false, "dev1")],
|
||||||
|
},
|
||||||
|
"mt-wire",
|
||||||
|
_ => agent);
|
||||||
|
|
||||||
|
// What DriverHostActor.ForwardNativeAlarm does for a routed transition, minus the routing: a
|
||||||
|
// quality-only event takes the Quality-only node path, anything else is a projected snapshot.
|
||||||
|
var projector = new NativeAlarmProjector();
|
||||||
|
var routed = new List<string>();
|
||||||
|
driver.OnAlarmEvent += (_, e) =>
|
||||||
|
{
|
||||||
|
lock (routed) routed.Add(e.ConditionId);
|
||||||
|
if (e is { IsQualityOnly: true, QualityStatusCode: { } quality })
|
||||||
|
{
|
||||||
|
rig.Sink.WriteAlarmQuality(e.ConditionId, quality, e.SourceTimestampUtc, AddressSpaceRealm.Raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rig.Sink.WriteAlarmCondition(e.ConditionId, projector.Project(e.ConditionId, e), e.SourceTimestampUtc, AddressSpaceRealm.Raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
await driver.SubscribeAlarmsAsync([RawAlarmPath], ct);
|
||||||
|
|
||||||
|
agent.Push(Condition("Fault", "E17", "Spindle overtemperature", 108));
|
||||||
|
const string FaultMessage = "Spindle overtemperature [nativeCode=E17]";
|
||||||
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, FaultMessage));
|
||||||
|
|
||||||
|
var fault = events.ConditionEvents(folderItem, FaultMessage).ShouldHaveSingleItem();
|
||||||
|
fault[RecordedEvents.AckedField].Value.ShouldBe(false, "a fresh fault is unacknowledged");
|
||||||
|
|
||||||
|
// An operator acknowledges it (applied server-side, as the Part 9 method does), then a deploy clears
|
||||||
|
// the host's projector prior state.
|
||||||
|
rig.Sink.WriteAlarmCondition(RawAlarmPath,
|
||||||
|
new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||||
|
AlarmShelvingKind.Unshelved, 800, FaultMessage),
|
||||||
|
DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(folderItem, FaultMessage).Count == 2);
|
||||||
|
projector = new NativeAlarmProjector();
|
||||||
|
|
||||||
|
// The adapter blips: UNAVAILABLE, then the same Fault. Each is a quality-only annotation that must
|
||||||
|
// leave the condition active, ACKNOWLEDGED, and carrying its message (review MAJOR 1 / MINOR 1).
|
||||||
|
agent.Push(Condition("UNAVAILABLE", null, null, 109));
|
||||||
|
agent.Push(Condition("Fault", "E17", "Spindle overtemperature", 110));
|
||||||
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(folderItem, FaultMessage).Count == 4);
|
||||||
|
foreach (var blip in events.ConditionEvents(folderItem, FaultMessage).Skip(2))
|
||||||
|
{
|
||||||
|
blip[RecordedEvents.AckedField].Value.ShouldBe(true, "an Unavailable blip must not reset the ack");
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.Push(Condition("Normal", null, null, 111));
|
||||||
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "SYSTEM Normal"));
|
||||||
|
|
||||||
|
lock (routed) routed.ShouldAllBe(id => id == RawAlarmPath, "ConditionId must be the alarm tag's RawPath");
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await rig.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MTConnectStreamsResult Condition(string state, string? nativeCode, string? text, long sequence) =>
|
||||||
|
new(1, sequence + 1, sequence,
|
||||||
|
[
|
||||||
|
new MTConnectObservation(DataItemId, state, DateTime.UtcNow,
|
||||||
|
Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// <summary>An Agent whose <c>/current</c> reports the condition Normal and whose <c>/sample</c> a test drives.</summary>
|
||||||
|
private sealed class ScriptedAgent : IMTConnectAgentClient
|
||||||
|
{
|
||||||
|
private readonly Channel<MTConnectStreamsResult> _chunks = Channel.CreateUnbounded<MTConnectStreamsResult>();
|
||||||
|
|
||||||
|
public void Push(MTConnectStreamsResult chunk) => _chunks.Writer.TryWrite(chunk);
|
||||||
|
|
||||||
|
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct) =>
|
||||||
|
Task.FromResult(new MTConnectProbeModel(
|
||||||
|
[
|
||||||
|
new MTConnectDevice("dev1", "dev1", [],
|
||||||
|
[new MTConnectDataItem(DataItemId, "system", "CONDITION", "SYSTEM", null, null, null, null)]),
|
||||||
|
]));
|
||||||
|
|
||||||
|
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct) =>
|
||||||
|
Task.FromResult(new MTConnectStreamsResult(1, 108, 1,
|
||||||
|
[
|
||||||
|
new MTConnectObservation(DataItemId, "Normal", DateTime.UtcNow,
|
||||||
|
Condition: new MTConnectConditionDetail(null, null, null, null, "SYSTEM")),
|
||||||
|
]));
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||||
|
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
yield return await _chunks.Reader.ReadAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||||
|
string deviceName, string dataItemId, string value, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void Dispose() => _chunks.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -271,6 +271,103 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
evt.User.ShouldBe("device");
|
evt.User.ShouldBe("device");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind
|
||||||
|
/// carrying <see cref="AlarmEventArgs.QualityStatusCode"/>, e.g. an MTConnect condition gone Unavailable while
|
||||||
|
/// already Normal) re-annotates the condition's Quality through the quality-only node path
|
||||||
|
/// (<see cref="OpcUaPublishActor.AlarmQualityUpdate"/> — NOT a projected snapshot), and publishes NO
|
||||||
|
/// <c>alerts</c> row — it is not an alarm transition. The positive control is the Raise that follows: it
|
||||||
|
/// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Quality_only_native_event_updates_the_condition_but_publishes_no_alerts_row()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
|
|
||||||
|
var alerts = CreateTestProbe();
|
||||||
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
|
new StubAlarmHandle(),
|
||||||
|
SourceNodeId: "Temp",
|
||||||
|
ConditionId: AlarmRawPath,
|
||||||
|
AlarmType: "SYSTEM",
|
||||||
|
Message: "SYSTEM UNAVAILABLE",
|
||||||
|
Severity: AlarmSeverity.Low,
|
||||||
|
SourceTimestampUtc: Ts,
|
||||||
|
Kind: AlarmTransitionKind.Unspecified,
|
||||||
|
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||||
|
|
||||||
|
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
|
annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||||
|
|
||||||
|
// Positive control: a real transition after it DOES publish, and is the first thing on the topic.
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
|
new StubAlarmHandle(),
|
||||||
|
SourceNodeId: "Temp",
|
||||||
|
ConditionId: AlarmRawPath,
|
||||||
|
AlarmType: "SYSTEM",
|
||||||
|
Message: "spindle fault",
|
||||||
|
Severity: AlarmSeverity.High,
|
||||||
|
SourceTimestampUtc: Ts,
|
||||||
|
Kind: AlarmTransitionKind.Raise,
|
||||||
|
OpcUaSeverity: 800,
|
||||||
|
QualityStatusCode: OpcUaStatus.Good)));
|
||||||
|
|
||||||
|
var raised = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
raised.State.Active.ShouldBeTrue();
|
||||||
|
raised.State.Severity.ShouldBe((ushort)800);
|
||||||
|
raised.State.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||||
|
|
||||||
|
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
|
evt.Message.ShouldBe("spindle fault", "the quality-only annotation must not have published an alerts row");
|
||||||
|
evt.TransitionKind.ShouldBe("Activated");
|
||||||
|
evt.Severity.ShouldBe(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a
|
||||||
|
/// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected
|
||||||
|
/// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and
|
||||||
|
/// severity. Only an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/> (which writes Quality alone) may be
|
||||||
|
/// sent. Proven by order: the next message after it is the positive control's state update.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
|
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||||
|
Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||||
|
|
||||||
|
// Whatever the projector's prior state is (any apply clears it), a quality-only event must never be
|
||||||
|
// projected — the routing below is the whole guard, so no deploy needs staging here.
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
|
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||||
|
Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800,
|
||||||
|
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||||
|
// Positive control.
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
|
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts,
|
||||||
|
Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||||
|
|
||||||
|
var next = publish.FishForMessage(
|
||||||
|
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||||
|
TimeSpan.FromSeconds(5));
|
||||||
|
next.ShouldBeOfType<OpcUaPublishActor.AlarmQualityUpdate>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||||
|
var control = publish.FishForMessage(
|
||||||
|
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||||
|
TimeSpan.FromSeconds(5));
|
||||||
|
control.ShouldBeOfType<OpcUaPublishActor.AlarmStateUpdate>().State.Message.ShouldBe("control",
|
||||||
|
"the quality-only event must produce no state snapshot ahead of the control");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
||||||
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
||||||
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
||||||
|
|||||||
@@ -166,6 +166,49 @@ public class NativeAlarmProjectorTests
|
|||||||
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
|
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea #626 — a source with a finer severity scale than the four buckets (MTConnect Fault = 800) sets
|
||||||
|
// OpcUaSeverity, which supersedes the bucket on the projected condition. Clamped to the Part 9 1..1000.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(800, 800)]
|
||||||
|
[InlineData(500, 500)]
|
||||||
|
[InlineData(0, 1)]
|
||||||
|
[InlineData(5000, 1000)]
|
||||||
|
public void Explicit_opcua_severity_supersedes_the_bucket(int requested, int projected)
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
|
||||||
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High) with { OpcUaSeverity = requested });
|
||||||
|
|
||||||
|
snap.Severity.ShouldBe((ushort)projected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea #626 — a per-condition source quality (MTConnect Unavailable) rides the snapshot's StatusCode, the
|
||||||
|
// same Quality child #477's connectivity annotation writes.
|
||||||
|
[Fact]
|
||||||
|
public void Supplied_quality_is_projected_onto_the_snapshot()
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
|
||||||
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
||||||
|
|
||||||
|
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea #626 — a quality-only annotation (Unspecified kind + quality) must not move Active/Acked: an
|
||||||
|
// active, unacknowledged condition stays exactly that while only its Quality changes.
|
||||||
|
[Fact]
|
||||||
|
public void Quality_only_annotation_keeps_the_prior_alarm_state()
|
||||||
|
{
|
||||||
|
var sut = new NativeAlarmProjector();
|
||||||
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||||
|
|
||||||
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
||||||
|
|
||||||
|
snap.Active.ShouldBeTrue();
|
||||||
|
snap.Acknowledged.ShouldBeFalse();
|
||||||
|
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||||
|
}
|
||||||
|
|
||||||
private static AlarmEventArgs Evt(
|
private static AlarmEventArgs Evt(
|
||||||
AlarmTransitionKind kind,
|
AlarmTransitionKind kind,
|
||||||
AlarmSeverity sev = AlarmSeverity.High,
|
AlarmSeverity sev = AlarmSeverity.High,
|
||||||
|
|||||||
Reference in New Issue
Block a user