From 95b389497a95094bf8f0749ece942c08f0c1f6cb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 15:44:50 -0400 Subject: [PATCH] test+docs(mtconnect): wire test for the Unavailable blip + ack survival; docs for the review rulings (#626) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/AlarmTracking.md | 9 +++-- docs/drivers/MTConnect.md | 37 ++++++++++++------ .../IAlarmSource.cs | 2 +- .../MTConnectConditionAlarmWireTests.cs | 38 ++++++++++++++++--- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 16f58aa5..5a021ac3 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -302,11 +302,14 @@ verbose mode) read the new fields when present. 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 → inactive with `BadNoCommunication` +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`; with an `Unspecified` kind it is a -**quality-only annotation** — `DriverHostActor` writes the condition but publishes no `/alerts` row). +**`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: diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 58084ef6..1a8921d5 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -413,14 +413,17 @@ CONDITION String tag into a condition node on upgrade. | `Normal` | inactive (return to normal) | — | Good | | `Warning` | active, unacknowledged | 500 | Good | | `Fault` | active, unacknowledged | 800 | Good | -| `Unavailable` | inactive | — | `BadNoCommunication` | +| `Unavailable` | **unchanged** (an active condition stays active and acknowledged) | — | `BadNoCommunication` | -`Unavailable` has no precedent among native sources (none reports an "unknown" condition per alarm), -so it follows the #477 comms-loss annotation: inactive, with the condition's `Quality` Bad. An active -condition going Unavailable is a **Clear** carrying the Bad quality; Normal ⇄ Unavailable while inactive -is a **quality-only annotation** (no `/alerts` row). 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. +**`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 — @@ -428,13 +431,18 @@ Whichever of `nativeCode` / `nativeSeverity` / `qualifier` the Agent supplied is 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** (newest on a tie). +(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), a different winning code, or a changed message is a **Retrigger** — active and -unacknowledged again with the new severity/message. (The String value surface still reconciles per +(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 @@ -443,8 +451,8 @@ AddComment only; the ack is applied server-side and the driver's `AcknowledgeAsy native source. **Restart / reconnect / comms loss.** The driver diffs each condition against what the server was last -**told** — dedup on level + `nativeCode` + `nativeSeverity` + `qualifier` + text, never on the value — and -records a state only when an alarm handler was attached to receive it. Consequences: +**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. @@ -454,6 +462,11 @@ records a state only when an alarm handler was attached to receive it. Consequen 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`, 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 51671cde..cf87783c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -99,7 +99,7 @@ public sealed record AlarmAcknowledgeRequest( /// #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 the condition's +/// 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( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs index d822579b..fb249c56 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs @@ -27,7 +27,7 @@ public sealed class MTConnectConditionAlarmWireTests private const string DataItemId = "dev1_system_cond"; [Fact] - public async Task A_condition_fault_and_its_clear_reach_the_raw_device_folder_as_part9_events() + 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); @@ -53,12 +53,19 @@ public sealed class MTConnectConditionAlarmWireTests "mt-wire", _ => agent); - // What DriverHostActor.ForwardNativeAlarm does for a routed transition, minus the routing. + // 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); }; @@ -69,14 +76,33 @@ public sealed class MTConnectConditionAlarmWireTests const string FaultMessage = "Spindle overtemperature [nativeCode=E17]"; await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, FaultMessage)); - agent.Push(Condition("Normal", null, null, 109)); + 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"); - var fault = events.ConditionEvents(folderItem, FaultMessage).ShouldHaveSingleItem(); - fault[RecordedEvents.AckedField].Value.ShouldBe(false, "a fresh fault is unacknowledged"); - await driver.ShutdownAsync(ct); } finally