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
This commit is contained in:
Joseph Doherty
2026-10-08 15:44:50 -04:00
parent 9c8e3da5da
commit 95b389497a
4 changed files with 64 additions and 22 deletions
+6 -3
View File
@@ -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:
+25 -12
View File
@@ -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`,
@@ -99,7 +99,7 @@ public sealed record AlarmAcknowledgeRequest(
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates the condition's
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
/// </param>
public sealed record AlarmEventArgs(
@@ -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<string>();
driver.OnAlarmEvent += (_, e) =>
{
lock (routed) routed.Add(e.ConditionId);
if (e is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
rig.Sink.WriteAlarmQuality(e.ConditionId, quality, e.SourceTimestampUtc, AddressSpaceRealm.Raw);
return;
}
rig.Sink.WriteAlarmCondition(e.ConditionId, projector.Project(e.ConditionId, e), e.SourceTimestampUtc, AddressSpaceRealm.Raw);
};
@@ -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