From f4d513e1112114ad5c990c6d48c5668907745c01 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 15:13:30 -0400 Subject: [PATCH] test+docs(mtconnect): CONDITION alarm wire test, reinit barrier, docs (#626) MTConnectConditionAlarmWireTests drives a scripted Agent's Fault/Normal through the real driver, NativeAlarmProjector and sink to a client event subscription on the raw device folder. docs/drivers/MTConnect.md replaces 'CONDITION v1: plain String' with the alarm mapping, opt-in authoring, multi-condition, ack, replay and comms-loss behaviour, and a live-check recipe; EVENT vocabularies as enumerations marked won't-fix. AlarmTracking.md lists the MTConnect source and the two new AlarmEventArgs fields. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/AlarmTracking.md | 15 ++ docs/drivers/MTConnect.md | 106 ++++++++++++-- .../MTConnectAlarmSourceTests.cs | 3 + .../MTConnectConditionAlarmWireTests.cs | 131 ++++++++++++++++++ 4 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index dca77b43..ed2518f5 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` | @@ -263,6 +264,20 @@ 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 → inactive 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). +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..58084ef6 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,83 @@ 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` | inactive | — | `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. + +**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** (newest on a tie). +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 +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` + text, never on 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. +- 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 +534,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 +556,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 +598,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/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs index 23dacc66..38f637c3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs @@ -153,6 +153,9 @@ public sealed class MTConnectAlarmSourceTests 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); 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..d822579b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs @@ -0,0 +1,131 @@ +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_and_its_clear_reach_the_raw_device_folder_as_part9_events() + { + 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. + var projector = new NativeAlarmProjector(); + var routed = new List(); + driver.OnAlarmEvent += (_, e) => + { + lock (routed) routed.Add(e.ConditionId); + 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)); + + agent.Push(Condition("Normal", null, null, 109)); + 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 + { + 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(); + } +}