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:
@@ -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
@@ -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(
|
||||
|
||||
+32
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user