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