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
This commit is contained in:
@@ -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
|
||||
|
||||
+95
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+131
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #626 over the wire — an MTConnect CONDITION transition, streamed by a scripted Agent through the real
|
||||
/// <see cref="MTConnectDriver"/>, projected by the production <see cref="NativeAlarmProjector"/> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one seam not exercised is <c>DriverHostActor</c>'s routing (ConditionId → condition NodeId), which
|
||||
/// <c>DriverHostActorNativeAlarmTests</c> covers; here the driver's ConditionId is asserted to BE the
|
||||
/// materialised condition's RawPath, which is the whole contract that routing relies on.
|
||||
/// </remarks>
|
||||
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<string>();
|
||||
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")),
|
||||
]);
|
||||
|
||||
/// <summary>An Agent whose <c>/current</c> reports the condition Normal and whose <c>/sample</c> a test drives.</summary>
|
||||
private sealed class ScriptedAgent : IMTConnectAgentClient
|
||||
{
|
||||
private readonly Channel<MTConnectStreamsResult> _chunks = Channel.CreateUnbounded<MTConnectStreamsResult>();
|
||||
|
||||
public void Push(MTConnectStreamsResult chunk) => _chunks.Writer.TryWrite(chunk);
|
||||
|
||||
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct) =>
|
||||
Task.FromResult(new MTConnectProbeModel(
|
||||
[
|
||||
new MTConnectDevice("dev1", "dev1", [],
|
||||
[new MTConnectDataItem(DataItemId, "system", "CONDITION", "SYSTEM", null, null, null, null)]),
|
||||
]));
|
||||
|
||||
public Task<MTConnectStreamsResult> 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<MTConnectStreamsResult> SampleAsync(
|
||||
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
yield return await _chunks.Reader.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public void Dispose() => _chunks.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user