diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index e4ba256b..5a021ac3 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -12,6 +12,7 @@ historical reference. | 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` | +| **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` | | **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 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 For Galaxy templates without `$Alarm*` extensions, the value-driven diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index d19186f7..1a8921d5 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -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** | | `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) | +| `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 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 [`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 -the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`. -There is no native OPC UA Part 9 alarm plumbing in this build; -`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so -the browse side-panel flags it and a future upgrade to native alarms doesn't -need re-authoring. See [Deferred](#deferred-not-in-this-build). +A `CONDITION` DataItem can be served two ways, and the choice is **per raw tag**: + +- **As a String value** (the default — every existing CONDITION tag, and every browse-committed + one). The value is the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE` + (`UNAVAILABLE` → `BadNoCommunication`). Unchanged by #626. +- **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 @@ -464,7 +547,8 @@ for anything beyond values-and-conditions. practice — the fixture's canned `probe.xml`/`Devices.xml`, which does declare a `sampleCount`, is unrealistic on this point and exists only to 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 `BadNotSupported`**, not an approximation — see the Quality mapping table. `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) §2.1. - **P1.5 fast-follow** (design - [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): - CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy - native-alarm pattern); EVENT controlled-vocabulary values → OPC UA enumerations. + [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9 + alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT + controlled-vocabulary values → OPC UA enumerations is **won't-fix**. - **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"` switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses 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, `rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run `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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 1d763c7c..cf87783c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// Driver capability for alarm events. Optional — only drivers whose backends expose /// 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). /// public interface IAlarmSource { @@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest( /// other fields. when the upstream /// path doesn't surface a distinct kind. /// +/// +/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four +/// buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it +/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); null — every +/// pre-#626 source — keeps the bucket mapping. must still carry the nearest +/// bucket for consumers that only read the bucket. +/// +/// +/// Optional status code of the condition's SOURCE data (Part 9 ConditionType.Quality), for a +/// source that can report "no value for this condition" per alarm rather than per connection (Gitea +/// #626: MTConnect Unavailable). null keeps the pre-#626 behaviour (projected Good; +/// driver-wide comms loss is annotated separately by the host). An event whose is +/// and which carries a quality is a +/// quality-only annotation (): the host updates (via its Quality-only node path) the condition's +/// Quality without touching its Active/Acked state and publishes no alerts row for it. +/// public sealed record AlarmEventArgs( IAlarmSubscriptionHandle SubscriptionHandle, string SourceNodeId, @@ -96,7 +113,17 @@ public sealed record AlarmEventArgs( string? OperatorComment = null, DateTime? OriginalRaiseTimestampUtc = null, string? AlarmCategory = null, - AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified); + AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified, + int? OpcUaSeverity = null, + uint? QualityStatusCode = null) +{ + /// + /// True when this event only re-annotates the condition's source-data quality — a + /// with an kind. No + /// alarm-state change happened, so the host must not publish it as an alarm transition. + /// + public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null; +} /// Mirrors the NodePermissions alarm-severity enum in docs/v2/acl-design.md. public enum AlarmSeverity { Low, Medium, High, Critical } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs new file mode 100644 index 00000000..fc257264 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs @@ -0,0 +1,211 @@ +using System.Text; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// 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. +/// +/// The last statement delivered; null when only an Unavailable was ever seen. +/// Whether the condition's Quality was last annotated BadNoCommunication. +internal sealed record MTConnectToldCondition(MTConnectConditionState? Statement, bool QualityBad); + +/// +/// 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 to +/// raise — or none — and what the server has then been told. +/// +/// +/// +/// Agent levelPart 9 +/// Normalinactive (return to normal), quality Good +/// Warningactive, severity 500 () +/// Faultactive, severity 800 ( bucket) +/// Unavailablestate UNCHANGED, quality 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): an active condition STAYS active (and keeps its ack), only its +/// Quality goes Bad, as a quality-only annotation (, no +/// /alerts row). When data returns: the same statement ⇒ quality-only Good; Normal ⇒ Clear; a +/// different active statement ⇒ Retrigger. +/// +/// +/// Otherwise: inactive → active is a ; active → a DIFFERENT active +/// statement (Warning → Fault, a new winning nativeCode) is a +/// — 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 +/// . An unchanged statement +/// ( — text excluded) emits nothing, which keeps a +/// reconnect's replayed /current 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. +/// +/// +/// Resync. 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. +/// +/// +internal static class MTConnectConditionAlarmMapper +{ + /// OPC UA severity of an active Warning. + internal const int WarningSeverity = 500; + + /// OPC UA severity of an active Fault. + internal const int FaultSeverity = 800; + + /// Canonical Opc.Ua.StatusCodes.Good. + private const uint Good = 0x00000000u; + + /// Canonical Opc.Ua.StatusCodes.BadNoCommunication — the Agent has no state for the item. + private const uint BadNoCommunication = 0x80310000u; + + /// + /// The event moving the server from to (or null + /// when nothing needs to be said), and the new told record. + /// + /// What the server was last told for this condition; null when nothing yet. + /// The Agent's current statement. + /// The condition's ConditionId — the alarm-bearing raw tag's RawPath. + /// The Agent DataItem id (carried as ). + /// The alarm subscription the event belongs to. + /// True on a (re)subscribe: re-assert an unchanged Bad quality. + /// The event to raise (or null) and what the server has been told afterwards. + 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); + } + + /// The Part 9 severity of an active level. + /// An active level. + internal static int SeverityOf(MTConnectConditionLevel level) => + level == MTConnectConditionLevel.Fault ? FaultSeverity : WarningSeverity; + + /// + /// The condition's message: its text content, else "{type} {level}"; followed by whichever of + /// nativeCode / nativeSeverity / qualifier 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. + /// + /// The state to describe. + 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(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(); + } + + /// + /// 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. + /// + 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs new file mode 100644 index 00000000..ffa18d81 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs @@ -0,0 +1,228 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// The four MTConnect CONDITION levels the alarm path distinguishes (#626). +internal enum MTConnectConditionLevel +{ + /// <Normal/> — nothing is active. + Normal = 0, + + /// <Warning/> — active, Part 9 severity 500. + Warning = 1, + + /// <Fault/> — active, Part 9 severity 800. + Fault = 2, + + /// <Unavailable/> — the Agent has no state for the item (inactive, Bad quality). + Unavailable = 3, +} + +/// +/// 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. +/// +/// The derived level. +/// +/// The detail of the observation the level comes from — the winning active condition, or the latest +/// Normal/Unavailable observation. Never null. +/// +/// That observation's Agent timestamp. +/// How many distinct conditions (by nativeCode) are active at once. +internal sealed record MTConnectConditionState( + MTConnectConditionLevel Level, + MTConnectConditionDetail Detail, + DateTime TimestampUtc, + int ActiveCount) +{ + /// Whether the derived condition is active (Warning or Fault). + public bool IsActive => Level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault; + + /// + /// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active + /// states match when level, nativeCode, nativeSeverity and qualifier 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 + /// /current 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). + /// + /// The state to compare against. + /// True when the alarm path must emit nothing to move from one to the other. + 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); + } +} + +/// +/// Folds MTConnect CONDITION observations into per-DataItem alarm state with the protocol's own +/// multi-condition semantics (#626) — the alarm path's analogue of . +/// +/// +/// +/// Why the index is not enough. The index reconciles several states reported for one DataItem +/// only within one document and otherwise treats each document as the new statement of the +/// value. That is right for a /current snapshot but wrong for a /sample delta: an Agent +/// streams each nativeCode's activation and clearing separately, so a chunk carrying +/// <Normal nativeCode="A"/> clears code A only — code B, raised in an earlier chunk, +/// is still active. The alarm path must not report "return to normal" there. +/// +/// +/// Rules, per DataItem, applied in document order. Warning/Fault activates (or +/// replaces) the entry keyed by its nativeCode (blank = one unnamed entry). Normal with a +/// nativeCode clears that code; Normal without one clears every code. Unavailable +/// clears every code and marks the item unavailable until any other level arrives (the alarm path does +/// NOT treat that as a clear — see ). A baseline +/// (/current, 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 Warning — surfacing an unknown +/// condition beats hiding it. +/// +/// +/// Thread safety. One internal lock; every member is atomic. The pump is the only writer. +/// +/// +internal sealed class MTConnectConditionTracker +{ + /// The parser's normalised spelling of <Unavailable/>. + private const string UnavailableSentinel = "UNAVAILABLE"; + + private readonly Lock _lock = new(); + private readonly Dictionary _items = new(StringComparer.Ordinal); + + /// + /// Folds every CONDITION observation of into the tracked state. + /// + /// A /current snapshot or one /sample chunk. + /// + /// True for a /current answer — each DataItem it mentions is reset before its observations are + /// applied; false for a /sample delta. + /// + /// The DataItem ids whose condition state the document touched (empty when none). + public IReadOnlySet Apply(MTConnectStreamsResult document, bool baseline) + { + ArgumentNullException.ThrowIfNull(document); + + var touched = new HashSet(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; + } + + /// The derived state of one DataItem, or null when it has never been observed as a condition. + /// The DataItem id. + 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); + } + } + + /// Drops every tracked state — the Agent-restart re-baseline, mirroring . + public void Clear() + { + lock (_lock) _items.Clear(); + } + + /// Maps a parsed condition state word onto a level (unknown vendor words ⇒ Warning). + /// The observation's state word. + 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 + { + /// Active conditions keyed by nativeCode ("" for an unnamed one). + public Dictionary Active { get; } = new(StringComparer.Ordinal); + + /// Whether the latest inactive statement was Unavailable. + public bool Unavailable { get; set; } + + /// The latest Normal/Unavailable observation (its text and type feed the clear message). + public Entry? LastInactive { get; set; } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs new file mode 100644 index 00000000..6c82d568 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// The half of (#626): CONDITION data items as +/// native OPC UA Part 9 alarms. +/// +/// +/// +/// Which tags are alarms. The server decides, not this driver: a raw tag whose TagConfig +/// carries the cross-driver alarm object is materialised as a Part 9 condition at its RawPath, and +/// its RawPath arrives here through . Every event this driver raises +/// uses that RawPath as — the key the host routes on. A CONDITION +/// tag WITHOUT an alarm object is unchanged: it stays the plain String value it always was. +/// +/// +/// One condition per DataItem. MTConnect can hold several simultaneous conditions on one +/// DataItem (distinct nativeCodes); the Part 9 condition shows the most severe active one +/// (), and an escalation or a new code re-raises it +/// (). +/// +/// +/// Delivery is diffed against what the server was actually TOLD. +/// records a state only when an handler was attached to receive it. The host's +/// DriverInstanceActor 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. +/// +/// +/// Raised under . Two threads emit — the /sample 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 DriverInstanceActor's, a +/// non-blocking Tell. is never taken while holding +/// or the lifecycle semaphore, and never takes either. +/// +/// +/// Comms loss emits nothing here, 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 +/// BadNoCommunication on the connectivity edge (#477), keeping each one's Active state. On recovery +/// the re-baselined /current is diffed like any other document. +/// +/// +public sealed partial class MTConnectDriver : IAlarmSource +{ + /// Serializes condition-alarm diffing and delivery. See the class remarks. + private readonly Lock _alarmLock = new(); + + /// + /// ConditionId (RawPath) → what the server was last told. Guarded by . 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. + /// + private readonly Dictionary _deliveredConditions = new(StringComparer.Ordinal); + + /// + /// The reference set of the last , kept across an unsubscribe so a + /// release-then-resubscribe of an identical set is still recognised. Guarded by . + /// + private HashSet? _lastAlarmReferences; + + /// The CONDITION tracker for the current session; replaced alongside the observation index. + private MTConnectConditionTracker _conditions = new(); + + /// + /// The one live alarm subscription, or null. Written under (it + /// keeps the shared /sample pump alive); read with elsewhere. + /// + private AlarmSubscriptionState? _alarmSubscription; + + private long _nextAlarmSubscriptionId; + + /// + public event EventHandler? OnAlarmEvent; + + /// The condition tracker the pump folds CONDITION observations into. Exposed for tests. + internal MTConnectConditionTracker ConditionTracker => Volatile.Read(ref _conditions); + + /// + /// + /// Collapses to ONE live subscription (the latest), like the Galaxy and FOCAS sources: the owning + /// DriverInstanceActor is the only caller and re-subscribes on every Connected entry. Starts the + /// shared /sample 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. + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceNodeIds); + + var references = new HashSet(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(handle); + } + + /// + /// An unknown or superseded handle is a no-op, matching . + 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); + } + } + + /// + /// + /// 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. + /// + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(acknowledgements); + + return Task.CompletedTask; + } + + /// Whether the alarm subscription names any condition. Caller must hold . + private bool HasAlarmReferencesCore() => Volatile.Read(ref _alarmSubscription) is { References.Count: > 0 }; + + /// + /// 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. + /// + /// + /// Only conditions bound to these DataItems are evaluated; null evaluates every subscribed one. + /// + /// Whether this is a (re)subscribe — see . + private void EmitConditionTransitions(IReadOnlySet? 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)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); + } + } + } + } + } + + /// The live alarm subscription: its handle and the ConditionIds (RawPaths) it names. + private sealed record AlarmSubscriptionState(MTConnectAlarmSubscriptionHandle Handle, HashSet References); +} + +/// Handle returned by . +/// The driver-local subscription number. +public sealed record MTConnectAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + /// + public string DiagnosticId => $"mtconnect-alarm-sub-{Id}"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index a84f454a..e59aeb5e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -47,7 +47,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// , , , /// and — the last two /// built on state this class already caches (the Agent instanceId and the probe -/// model). +/// model) — plus for CONDITION data items (#626, in +/// MTConnectDriver.Alarms.cs). /// /// /// is observation injection, not machine command, and it is @@ -71,7 +72,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// (see ). /// /// -public sealed class MTConnectDriver +public sealed partial class MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable, ISkippedTagReporter { @@ -1981,6 +1982,10 @@ public sealed class MTConnectDriver if (republishOnStart) { 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) @@ -2140,6 +2145,7 @@ public sealed class MTConnectDriver // then fails, holding stale values from a dead device model would be worse than // holding none. ObservationIndex.Clear(); + ConditionTracker.Clear(); FanOut(SubscribedReferences(), ObservationIndex); // 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); PublishAgentCursor(current.InstanceId, current.NextSequence); - ApplyAndFanOut(current); + ApplyAndFanOut(current, baseline: true); _streamDegraded = false; PublishHealthy(DateTime.UtcNow); @@ -2328,11 +2334,16 @@ public sealed class MTConnectDriver /// Folds a /current snapshot or one /sample chunk into the shared index and /// publishes every reference it touched to the handles that subscribe it. /// - private void ApplyAndFanOut(MTConnectStreamsResult document) + private void ApplyAndFanOut(MTConnectStreamsResult document, bool baseline = false) { var index = ObservationIndex; 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 }) { return; @@ -2509,6 +2520,13 @@ public sealed class MTConnectDriver /// 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) { if (state.References.Count > 0) @@ -2786,6 +2804,12 @@ public sealed class MTConnectDriver index.Apply(current); 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 // re-initialize is precisely the event that means an operator changed something. _streamDegraded = false; @@ -2867,6 +2891,7 @@ public sealed class MTConnectDriver // against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather // than BadNodeIdUnknown ("no such tag"). Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags)); + Volatile.Write(ref _conditions, new MTConnectConditionTracker()); return Task.CompletedTask; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index 0158722b..14ba87ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -167,13 +167,35 @@ public sealed record MTConnectStreamsResult( /// observation. Carried for the parser contract; not yet surfaced on the served node (see /// docs/drivers/MTConnect.md). null when absent. /// +/// +/// The CONDITION-only detail (#626) — set exactly when the observation came from a +/// <Condition> container, null for every Sample/Event. Its presence is what marks an +/// observation as a condition for the alarm path; still carries the state word. +/// public sealed record MTConnectObservation( string DataItemId, string Value, DateTime TimestampUtc, bool IsStructured = false, int? SampleCount = null, - double? SampleRate = null); + double? SampleRate = null, + MTConnectConditionDetail? Condition = null); + +/// +/// What an MTConnect CONDITION observation says beyond its state word (#626). Every field is the +/// Agent's own text, trimmed, or null when absent or blank — none is interpreted at this layer. +/// +/// The element's text content: the operator-facing condition message. +/// The nativeCode attribute — the controller's own alarm number/code. +/// The nativeSeverity attribute — the controller's own severity, verbatim. +/// The qualifier attribute (e.g. HIGH, LOW). +/// The element's type attribute (e.g. SYSTEM, TEMPERATURE). +public sealed record MTConnectConditionDetail( + string? Text, + string? NativeCode, + string? NativeSeverity, + string? Qualifier, + string? ConditionType); /// /// Recursive helpers over the device/component tree. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs index 06a9a5cf..ba631790 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs @@ -379,6 +379,7 @@ internal static class MTConnectStreamsParser string? conditionState = null; int? sampleCount = null; double? sampleRate = null; + (string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default; if (isObservation) { @@ -387,6 +388,17 @@ internal static class MTConnectStreamsParser timestampUtc = ReadTimestampUtc(reader, dataItemId); 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 // 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 @@ -413,9 +425,11 @@ internal static class MTConnectStreamsParser observations.Add(null!); } - // A CONDITION's value is its element NAME, so nothing below it is collected for it — but an - // enclosing observation's text still is, hence the ancestor sink rather than none at all. - var sink = isObservation && !isCondition ? new ValueSink() : textSink; + // A CONDITION's VALUE is its element NAME, so its text never becomes the value — but since #626 + // the text is still collected, into a sink of its own, because it is the condition's + // 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); @@ -425,9 +439,17 @@ internal static class MTConnectStreamsParser } string value; + MTConnectConditionDetail? condition = null; if (isCondition) { value = conditionState!; + var text = sink!.Value.Trim(); + condition = new MTConnectConditionDetail( + text.Length == 0 ? null : text, + conditionAttributes.NativeCode, + conditionAttributes.NativeSeverity, + conditionAttributes.Qualifier, + conditionAttributes.Type); } else { @@ -452,7 +474,8 @@ internal static class MTConnectStreamsParser // away a perfectly well-determined state. IsStructured: !isCondition && hasElements, SampleCount: sampleCount, - SampleRate: sampleRate); + SampleRate: sampleRate, + Condition: condition); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index d3e974e0..33eafca8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -1789,6 +1789,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS _localNode, msg.DriverInstanceId, msg.Args.ConditionId); 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 // 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index a7e2b75e..4e52cf90 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -21,7 +21,8 @@ public sealed class NativeAlarmProjector public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) { 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 { AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false), @@ -32,7 +33,11 @@ public sealed class NativeAlarmProjector _prior[nodeId] = (active, acked); return new AlarmConditionSnapshot( 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); } /// Clears all tracked per-node state (call on address-space rebuild). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs new file mode 100644 index 00000000..ccabf8d7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs @@ -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; + +/// +/// Gitea #626 — 's half against +/// : CONDITION transitions off the /sample 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. +/// +/// +/// 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. +/// +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 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(); + 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(); + EventHandler 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(); + 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); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs new file mode 100644 index 00000000..7d3392c2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs @@ -0,0 +1,368 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Gitea #626 — the pure halves of the CONDITION → Part 9 path: +/// (MTConnect's multi-condition semantics) and (state mapping and +/// transition sequencing). The driver wiring is covered by MTConnectAlarmSourceTests. +/// +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 "); + 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 ---- + + /// Maps one step from a told state of (Good quality). + 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; + + /// Threads the told record through a sequence of Agent statements, returning each step's event. + 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); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs index 6c039508..b13e3dee 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs @@ -131,6 +131,36 @@ public sealed class MTConnectStreamsParseTests 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( + """ Spindle overtemperature """)); + + 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 " marker — never on a Sample/Event. + current["dev1_pos"].Condition.ShouldBeNull(); + current["dev1_execution"].Condition.ShouldBeNull(); + } + [Fact] public void An_unavailable_condition_element_is_the_unavailable_sentinel() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs new file mode 100644 index 00000000..fb249c56 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs @@ -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; + +/// +/// Gitea #626 over the wire — an MTConnect CONDITION transition, streamed by a scripted Agent through the real +/// , projected by the production 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. +/// +/// +/// The one seam not exercised is DriverHostActor's routing (ConditionId → condition NodeId), which +/// DriverHostActorNativeAlarmTests covers; here the driver's ConditionId is asserted to BE the +/// materialised condition's RawPath, which is the whole contract that routing relies on. +/// +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(); + 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")), + ]); + + /// An Agent whose /current reports the condition Normal and whose /sample a test drives. + private sealed class ScriptedAgent : IMTConnectAgentClient + { + private readonly Channel _chunks = Channel.CreateUnbounded(); + + public void Push(MTConnectStreamsResult chunk) => _chunks.Writer.TryWrite(chunk); + + public Task ProbeAsync(CancellationToken ct) => + Task.FromResult(new MTConnectProbeModel( + [ + new MTConnectDevice("dev1", "dev1", [], + [new MTConnectDataItem(DataItemId, "system", "CONDITION", "SYSTEM", null, null, null, null)]), + ])); + + public Task 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 SampleAsync( + long from, [EnumeratorCancellation] CancellationToken ct) + { + while (true) + { + yield return await _chunks.Reader.ReadAsync(ct); + } + } + + public Task WriteObservationAsync( + string deviceName, string dataItemId, string value, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public void Dispose() => _chunks.Writer.TryComplete(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index e14b57aa..848ee47d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -271,6 +271,103 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase evt.User.ShouldBe("device"); } + /// Gitea #626 — a QUALITY-ONLY native event (an kind + /// carrying , e.g. an MTConnect condition gone Unavailable while + /// already Normal) re-annotates the condition's Quality through the quality-only node path + /// ( — NOT a projected snapshot), and publishes NO + /// alerts 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. + [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(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(TimeSpan.FromSeconds(5)); + raised.State.Active.ShouldBeTrue(); + raised.State.Severity.ShouldBe((ushort)800); + raised.State.StatusCode.ShouldBe(OpcUaStatus.Good); + + var evt = alerts.ExpectMsg(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); + } + + /// 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 (which writes Quality alone) may be + /// sent. Proven by order: the next message after it is the positive control's state update. + [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(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().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + var control = publish.FishForMessage( + m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate, + TimeSpan.FromSeconds(5)); + control.ShouldBeOfType().State.Message.ShouldBe("control", + "the quality-only event must produce no state snapshot ahead of the control"); + } + /// #477 Layer 2 — a driver annotates the /// Quality of the driver's native conditions via : comms /// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index b7df4648..eaee1d77 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -166,6 +166,49 @@ public class NativeAlarmProjectorTests 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( AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High,