@@ -308,9 +308,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
`InvalidDataException` — before #804 the 400 was a transport fault and the `OUT_OF_RANGE` re-baseline never ran
|
||||
live. The restart (`instanceId`) test also runs on every re-baseline snapshot (on 2.x that is where a restart is
|
||||
first seen). 3 re-baselines with no `/sample` chunk ⇒ Degraded with `LastSuccessfulRead` still advancing (no
|
||||
watchdog reconnect loop over a `SampleCount` > `bufferSize` — live: `count == bufferSize` is accepted; Test Connect + init warn, #825). Every parse prohibits DTDs; the streams walk is
|
||||
watchdog reconnect loop over a `SampleCount` > `bufferSize` — live: `count == bufferSize` is accepted; Test Connect + init warn, #825;
|
||||
the adaptive raised count is clamped to the known `bufferSize`, #846). Every parse prohibits DTDs; the streams walk is
|
||||
depth-bounded (64 — an unbounded recursion is a process-killing `StackOverflowException`). Non-finite floats are
|
||||
never Good; integer-valued decimals serve integral tags losslessly. Matrix: `docs/drivers/MTConnect.md`.
|
||||
- **MTConnect refused-connection fast path (#846):** a `/sample`/re-baseline failure whose chain holds
|
||||
`SocketError.ConnectionRefused`, unbroken for `CommsLossConfirmMs` (default 2000; 0 = first; negative = off), publishes
|
||||
every subscribed value `BadNoCommunication` once per outage; the first delivery afterwards republishes all of them from
|
||||
the index. Health/`LastSuccessfulRead` are untouched by it (the stream failure already degraded), so the #527 watchdog
|
||||
still re-inits at 90 s. Timeouts/resets/DNS/pause stay watchdog-only — never widen the definite set to them.
|
||||
- **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig`
|
||||
carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break
|
||||
existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe
|
||||
@@ -337,7 +343,12 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
arrays unchanged. Walked on `Polling.AlarmMs` (2 s) by the control's single loop with the SAME `R_RI 27/28` — the
|
||||
allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, `-1`). A failed walk
|
||||
(refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (`AcknowledgeAsync` no-op success) —
|
||||
never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented names or severities.
|
||||
never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented SEVERITIES; since #839 they
|
||||
carry HEIDENHAIN DNC-interface names (labels + browse leaves only — corroborated by one iTNC 530 record, else
|
||||
hardware-unproven) and set the Part 9 `ConditionClass` (class 5 ⇒ Safety; group 1/2 ⇒ Process; 4/5 ⇒ System; else Base)
|
||||
via the new optional `AlarmEventArgs.ConditionClass` (projector keeps it across class-less events; node manager writes
|
||||
it; rebuild carries it). The browse picker offers `AlarmConditions/` selector leaves; alarms no selector claims are
|
||||
counted (`otopcua.driver.alarms.unmatched`, `/hosts` `<host>.alarms.unmatched[Total]`) + rate-limited Warning.
|
||||
- **Credential references are namespaced per driver (#815):** ONE table (`Core.Abstractions.DriverCredentialPolicy`) +
|
||||
ONE parser/resolver (`CredentialReferences`) for Galaxy (`secret:galaxy/`·`secret:otopcua/galaxy/`·`env:OTOPCUA_GALAXY_`·
|
||||
`env:GALAXY_MXGW_`), OpcUaClient (`secret:opcuaclient/`·`env:OTOPCUA_OPCUACLIENT_`) and Heidenhain. Enforced at save
|
||||
|
||||
+14
-7
@@ -163,8 +163,8 @@ are set explicitly. Leaving them unset shipped them as **null** on every event
|
||||
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||
| `ConditionClassId` | **`BaseConditionClassType`** at materialize; a classifying native driver may set `Process`/`Maintenance`/`System`/`SafetyConditionClassType` at runtime (#839 — today only Heidenhain) | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||
| `ConditionClassName` | **`"BaseConditionClass"`**, or the matching `"<X>ConditionClass"` | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||
| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below |
|
||||
|
||||
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||
@@ -173,11 +173,18 @@ classification at the materialize seam, and `ConditionClassId` is a wire contrac
|
||||
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
||||
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
||||
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
||||
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
||||
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
||||
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
||||
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
||||
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
||||
Safety or Diagnostics, trading a detectable null for an undetectable lie.
|
||||
|
||||
**Per-alarm classification arrives at runtime (#839).** A native driver whose upstream codes say where
|
||||
an alarm comes from sets the optional `AlarmEventArgs.ConditionClass` (`AlarmConditionClass` —
|
||||
Base/Process/Maintenance/System/Safety) on its Raise/Retrigger; `NativeAlarmProjector` carries it on the
|
||||
`AlarmConditionSnapshot` (kept across events that state none — quality-only annotations, Clears) and
|
||||
`WriteAlarmCondition` writes `ConditionClassId`/`ConditionClassName` over the materialize default. It is
|
||||
not in the event delta-gate (a class rides along with the transition that set it) and the #641 rebuild
|
||||
carry-over keeps it, normal conditions included. Only Heidenhain classifies today
|
||||
(`docs/drivers/Heidenhain.md` §Alarm conditions); every other source leaves `BaseConditionClassType`.
|
||||
Galaxy's free-text `AlarmCategory` is still not mapped — the `IAlarmSource` doc comment's
|
||||
"maps to `ConditionClassName` downstream" remains an intent for that field.
|
||||
|
||||
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||
|
||||
@@ -177,10 +177,34 @@ MTConnect #626.
|
||||
| `ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>]` (any subset, any order, each key once) | an alarm matches every named field |
|
||||
|
||||
`<r>` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP
|
||||
are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the
|
||||
reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or
|
||||
a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the
|
||||
driver.
|
||||
are 0–65535 (wire uint16), NUMBER is int32. Selectors always filter on the **raw codes**; observe `Alarms/Classes` /
|
||||
`Alarms/Groups` or a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy
|
||||
inspector and the driver.
|
||||
|
||||
**Class / group names and the Part 9 condition class (Gitea #839).** LSV2 documents neither field (pyLSV2 and the Java
|
||||
port keep them raw). `HeidenhainAlarmClasses` carries HEIDENHAIN's own names from the **DNC interface** (RemoTools SDK
|
||||
`DNC_ERROR_CLASS` / `DNC_ERROR_GROUP`), which describe the same control error list. That the LSV2 codes share that
|
||||
numbering is corroborated by the one real record we hold — the captured iTNC 530 "Run test of cutout channels" is class 7
|
||||
`INFO`, group 4 `GENERAL`, a power-up self-test notice — and is otherwise **hardware-unproven** (the commissioning gate
|
||||
should confirm it on a live control).
|
||||
|
||||
| Class | 1 Warning · 2 FeedHold · 3 ProgramHold · 4 ProgramAbort · **5 EmergencyStop** · 6 Reset · 7 Info · 8 Error · 9 Note |
|
||||
|---|---|
|
||||
| **Group** | **1 Operating** · **2 Programming** · 3 Plc · **4 General** · **5 Remote** · 6 Python |
|
||||
|
||||
The names are labels only — they drive no selection and no severity. They set the condition's Part 9
|
||||
`ConditionClassId`/`ConditionClassName`, and only where a code says *where the alarm comes from*:
|
||||
|
||||
| Controller alarm | Part 9 class |
|
||||
|---|---|
|
||||
| class 5 (EmergencyStop), any group | `SafetyConditionClassType` |
|
||||
| group 1 Operating / 2 Programming (machining and NC-program errors) | `ProcessConditionClassType` |
|
||||
| group 4 General / 5 Remote (the control's own system and remote-interface messages) | `SystemConditionClassType` |
|
||||
| group 3 Plc (machine-builder text: process, maintenance or safety alike), 6 Python, 0 / unknown | `BaseConditionClassType` ("not modelled") |
|
||||
|
||||
Nothing maps to `MaintenanceConditionClassType` — no code is evidence of it. A condition's class is that of its
|
||||
**displayed** alarm, set on every Raise/Retrigger; a Clear or a quality-only event states none, so the node keeps the
|
||||
class of the alarm it last showed (and a rebuild carries it). The condition message is unchanged.
|
||||
|
||||
- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the
|
||||
`Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored
|
||||
@@ -198,7 +222,14 @@ driver.
|
||||
alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a
|
||||
non-displayed member leaves → nothing; nothing matches → `Clear`.
|
||||
- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No
|
||||
class→severity table is invented.
|
||||
class→severity table is invented (the class names above set the condition CLASS, never its severity).
|
||||
- **Unclaimed alarms are reported, not dropped silently (Gitea #839).** On a control with no `*`/`OTHER` selector, an
|
||||
alarm no `CLASS/GROUP/NUMBER` filter matches is shown by no condition. Each such alarm *entering* that set counts once
|
||||
(a standing alarm is not re-counted per walk) on the OTel counter `otopcua.driver.alarms.unmatched`
|
||||
(`driver_type=Heidenhain`, `driver_instance`), and `/hosts` diagnostics carry `<host>.alarms.unmatched` (unclaimed on
|
||||
the last good walk) and `<host>.alarms.unmatchedTotal` (since the driver started). One Warning per control at most
|
||||
per minute names up to three of them and how many more were suppressed since the previous line. A failed walk changes
|
||||
nothing. Add a selector, or an `ALARM:OTHER` catch-all, to surface them.
|
||||
- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the
|
||||
driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its
|
||||
first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept,
|
||||
@@ -217,7 +248,11 @@ driver.
|
||||
every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists
|
||||
(`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged).
|
||||
- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the
|
||||
alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves.
|
||||
alarm object), CSV (`Alarm.AlarmType` / `Alarm.Severity` columns) and — since #839 — the **browse picker**, whose
|
||||
`AlarmConditions/` folder offers `AnyAlarm` (`ALARM:*`), `OtherAlarms` (`ALARM:OTHER`), `ByClass/Class<n>_<Name>`
|
||||
(`ALARM:CLASS=n`, classes 1–9) and `ByGroup/Group<n>_<Name>` (`ALARM:GROUP=n`, groups 1–6) as `IsAlarm` Boolean leaves;
|
||||
a commit seeds the paired `alarm` object (severity 700), so the committed tag passes the factory unchanged. Ranges,
|
||||
`NUMBER` and combined filters are still typed by hand. Discovery still contacts no control.
|
||||
- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`).
|
||||
|
||||
## Quality
|
||||
|
||||
@@ -157,6 +157,7 @@ clears, and the Agent's text is logged once per distinct refusal. An answer that
|
||||
"SampleIntervalMs": 1000,
|
||||
"SampleCount": 1000,
|
||||
"HeartbeatMs": 10000,
|
||||
"CommsLossConfirmMs": 2000,
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
|
||||
"Write": { "Enabled": false },
|
||||
@@ -183,6 +184,7 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||||
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param. **Caps subscription throughput** at `SampleCount / SampleIntervalMs` — see [Subscribe](#subscribe--the-sample-pump) for the adaptive raise |
|
||||
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
|
||||
| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). JSON-only — the AdminUI form preserves it but has no field |
|
||||
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
|
||||
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
|
||||
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
|
||||
@@ -397,7 +399,10 @@ observations per chunk, one chunk per `SampleIntervalMs`; a machine producing mo
|
||||
buffer forever, re-baselining every buffer-length (dropping intermediate transitions) with nothing surfacing it.
|
||||
Each chunk is now measured: `lag = lastSequence + 1 − nextSequence` (unknown when the Agent omits `lastSequence`)
|
||||
and whether it was **saturated** (carried the full `count`). Three consecutive saturated chunks with a positive (or
|
||||
unknown) lag reopen the stream at the cursor reached with `count` doubled, up to 16× `SampleCount` (max 65 536). A
|
||||
unknown) lag reopen the stream at the cursor reached with `count` doubled, up to 16× `SampleCount` (max 65 536) —
|
||||
**and never above the Agent's `/probe` `bufferSize`** (#846; a count above it is refused `OUT_OF_RANGE`, so a raise past
|
||||
it only bought a refused stream and a step-back; a configured `SampleCount` already above the buffer is not raised and
|
||||
is the #825 warning's business). A
|
||||
raised stream that delivers nothing (an Agent refusing the larger count) steps back to the last count that delivered
|
||||
and stops raising; at the ceiling a still-behind stream logs one Warning per pump. Every new pump starts from the
|
||||
configured `SampleCount`. An Agent that refuses the raised count with an `MTConnectError` under HTTP 200 goes through
|
||||
@@ -724,6 +729,32 @@ deliberately different, kept.
|
||||
Agent's `bufferSize`, not a genuinely lapped ring buffer; no real machine tool or non-cppagent Agent was exercised.
|
||||
**Closed by the #823 gate below**, except the real-machine / non-cppagent caveat.
|
||||
|
||||
### Comms loss: refused vs ambiguous (#846)
|
||||
|
||||
The runtime's comms-loss watchdog (Degraded + `LastSuccessfulRead` frozen 90 s ⇒ re-initialize, host fans
|
||||
`BadNoCommunication`) is the contract for every **ambiguous** failure, and the #823 gate below measured what it costs:
|
||||
values Good-but-stale for ~94–118 s. A **refused** connection is not ambiguous — nothing is listening at `AgentUri` — so
|
||||
the `/sample` pump now handles it itself:
|
||||
|
||||
- **Definite = `SocketError.ConnectionRefused`** anywhere in the failure's exception chain (what `HttpClient` throws for
|
||||
a port nobody listens on), on a `/sample` open or a re-baseline `/current`. A request or stream-idle timeout, a reset or
|
||||
closed stream, DNS failure, a paused Agent, an `MTConnectError` refusal — all stay with the watchdog, unchanged.
|
||||
- **Confirmed:** the refusals must continue, unbroken, for `CommsLossConfirmMs` (default 2 s); any answer from the Agent or
|
||||
any ambiguous failure ends the run. While confirming, the reconnect delay is capped at the time remaining.
|
||||
- **Then every subscribed value is published `BadNoCommunication`** (value null) **once per outage**, with one Warning.
|
||||
Health was already Degraded with `LastSuccessfulRead` frozen by the stream failure, so the watchdog still re-initializes
|
||||
at 90 s exactly as before — this only stops stale values reading Good in between. Reads already fall back to `/current`
|
||||
on a degraded stream and fail on their own. Native CONDITIONS are untouched (the host annotates them on the
|
||||
connectivity edge, as before).
|
||||
- **Recovery republishes everything:** the first delivered chunk (a heartbeat counts) or re-baseline after a marked outage
|
||||
republishes every subscribed value from the index — a resumed `/sample` carries only what changed, so an unchanged item
|
||||
would otherwise stay Bad (the (value, status) recovery rule).
|
||||
- Consequence for an Agent **restart** that refuses for longer than the window (the #823 restart refused for ~1 s, inside
|
||||
it): values go Bad for the restart and come back on its first chunk / re-baseline. That is the honest answer — the
|
||||
Agent was down.
|
||||
|
||||
Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window).
|
||||
|
||||
### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev)
|
||||
|
||||
An MTConnect driver (`SampleCount` 1000, `HeartbeatMs` 10000, `RequestTimeoutMs` 5000) against the shared fixture,
|
||||
@@ -735,7 +766,7 @@ NodeIds moved together in every leg. Fixture containers were restored healthy af
|
||||
|---|---|
|
||||
| Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. |
|
||||
| Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). |
|
||||
| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. |
|
||||
| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live. |
|
||||
| Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. |
|
||||
| Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. |
|
||||
| `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). |
|
||||
|
||||
@@ -86,6 +86,17 @@ public static class OtOpcUaTelemetry
|
||||
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
|
||||
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|keepalive_recovered|stale_notification_dropped).");
|
||||
|
||||
/// <summary>
|
||||
/// Controller alarms a native-alarm driver saw that NO configured alarm selector on that device claims, so no
|
||||
/// Part 9 condition shows them (lmxopcua#839 — Heidenhain <c>ALARM:</c> selectors without <c>*</c>/<c>OTHER</c>).
|
||||
/// Counted once per alarm occurrence (an alarm entering the unclaimed set), not once per poll. Tagged
|
||||
/// <see cref="PollOverrunDriverTypeTag"/> + <see cref="PollOverrunDriverInstanceTag"/>. A non-zero rate means
|
||||
/// operators are not seeing some controller alarms — add a selector (or an <c>OTHER</c> catch-all).
|
||||
/// </summary>
|
||||
public static readonly Counter<long> DriverAlarmsUnmatched =
|
||||
Meter.CreateCounter<long>("otopcua.driver.alarms.unmatched", unit: "{alarm}",
|
||||
description: "Controller alarm occurrences no configured native-alarm selector claims (shown by no condition).");
|
||||
|
||||
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
|
||||
|
||||
public static readonly Counter<long> VirtualTagEval =
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,6 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// time instead of the SDK's ~24.8-day ceiling. A timed snapshot without it (a caller that
|
||||
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
|
||||
/// </param>
|
||||
/// <param name="ConditionClass">
|
||||
/// The Part 9 <c>ConditionClass</c> a native source assigned (lmxopcua#839), or <c>null</c> to leave the node's class
|
||||
/// as it is (scripted alarms and every native source that does not classify). Not part of the event delta-gate: a
|
||||
/// class rides along with the transition that set it.
|
||||
/// </param>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
@@ -39,7 +46,8 @@ public sealed record AlarmConditionSnapshot(
|
||||
ushort Severity,
|
||||
string Message,
|
||||
uint StatusCode = OpcUaStatus.Good,
|
||||
DateTime? UnshelveAtUtc = null);
|
||||
DateTime? UnshelveAtUtc = null,
|
||||
AlarmConditionClass? ConditionClass = null);
|
||||
|
||||
/// <summary>
|
||||
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
||||
|
||||
@@ -122,6 +122,13 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
|
||||
/// opt in) keeps every transition's row.
|
||||
/// </param>
|
||||
/// <param name="ConditionClass">
|
||||
/// Optional Part 9 <c>ConditionClass</c> of the condition (lmxopcua#839), for a source whose upstream classifies its
|
||||
/// alarms in a way that maps onto the Part 9 classes with evidence (Heidenhain's error class / group). When set, the
|
||||
/// host writes it to the condition's <c>ConditionClassId</c> / <c>ConditionClassName</c>; <c>null</c> (every other
|
||||
/// source) leaves the node's class untouched — <c>BaseConditionClass</c> ("not modelled") unless an earlier event of
|
||||
/// the same condition set one. A pure annotation: it never moves Active/Acked state and never fires an event on its own.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
@@ -136,7 +143,8 @@ public sealed record AlarmEventArgs(
|
||||
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
|
||||
int? OpcUaSeverity = null,
|
||||
uint? QualityStatusCode = null,
|
||||
bool IsRestatement = false)
|
||||
bool IsRestatement = false,
|
||||
AlarmConditionClass? ConditionClass = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when this event only re-annotates the condition's source-data quality — a
|
||||
@@ -163,3 +171,26 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
/// Members are append-only: their numeric values must not move.
|
||||
/// </summary>
|
||||
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged }
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA Part 9 condition classes (<c>BaseConditionClassType</c> subtypes, Part 9 §5.9) a driver can assign to a
|
||||
/// native condition via <see cref="AlarmEventArgs.ConditionClass"/> (lmxopcua#839). Only the classes a driver has
|
||||
/// evidence for are listed; <see cref="Base"/> is Part 9's "no class modelled". Members are append-only.
|
||||
/// </summary>
|
||||
public enum AlarmConditionClass
|
||||
{
|
||||
/// <summary><c>BaseConditionClassType</c> — the class is not modelled / not known.</summary>
|
||||
Base = 0,
|
||||
|
||||
/// <summary><c>ProcessConditionClassType</c> — the condition relates to the process the system controls.</summary>
|
||||
Process,
|
||||
|
||||
/// <summary><c>MaintenanceConditionClassType</c> — the condition relates to maintenance.</summary>
|
||||
Maintenance,
|
||||
|
||||
/// <summary><c>SystemConditionClassType</c> — the condition relates to the system itself (hardware, software, comms).</summary>
|
||||
System,
|
||||
|
||||
/// <summary><c>SafetyConditionClassType</c> — the condition relates to safety.</summary>
|
||||
Safety,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
/// <summary>
|
||||
/// Names for the class / group codes of a Heidenhain control's active-alarm list, and the Part 9
|
||||
/// <c>ConditionClass</c> each maps to (lmxopcua#839).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Provenance.</b> LSV2 itself documents neither field (pyLSV2 and the ignitionoee Java port keep them
|
||||
/// raw). The names are HEIDENHAIN's own error enums from the DNC interface (RemoTools SDK / HeidenhainDNC
|
||||
/// <c>DNC_ERROR_CLASS</c>, <c>DNC_ERROR_GROUP</c>), which describe the same control error list. That the LSV2
|
||||
/// <c>R_RI 27/28</c> codes use the same numbering is corroborated by the one real record we hold — the captured
|
||||
/// iTNC 530 alarm "Run test of cutout channels" is class 7 (<c>INFO</c>), group 4 (<c>GENERAL</c>), a power-up
|
||||
/// self-test notice — but is not otherwise proven on hardware. Treat the names as labels, never as behaviour:
|
||||
/// they drive no selection and no severity (an authored <c>alarm.severity</c>, else High, still decides that).</para>
|
||||
/// <para><b>Part 9 mapping — only where the code says where the alarm comes from.</b>
|
||||
/// <c>EMERGENCY_STOP</c> (class 5) is a safety reaction ⇒ <see cref="AlarmConditionClass.Safety"/>, whatever its
|
||||
/// group. Otherwise the GROUP decides: <c>OPERATING</c> / <c>PROGRAMMING</c> (machining and NC-program errors) ⇒
|
||||
/// <see cref="AlarmConditionClass.Process"/>; <c>GENERAL</c> / <c>REMOTE</c> (the control's own system and
|
||||
/// remote-interface messages) ⇒ <see cref="AlarmConditionClass.System"/>. <c>PLC</c> messages are written by the
|
||||
/// machine builder and can be process, maintenance or safety alike, and <c>PYTHON</c> / unknown codes say nothing
|
||||
/// ⇒ <see cref="AlarmConditionClass.Base"/> ("not modelled"). Nothing maps to Maintenance: no code is evidence of
|
||||
/// it.</para>
|
||||
/// </remarks>
|
||||
public static class HeidenhainAlarmClasses
|
||||
{
|
||||
/// <summary>The emergency-stop error class.</summary>
|
||||
public const int EmergencyStopClass = 5;
|
||||
|
||||
/// <summary>Class code → HEIDENHAIN DNC name (<c>DNC_ERROR_CLASS</c>).</summary>
|
||||
public static IReadOnlyDictionary<int, string> ClassNames { get; } = new Dictionary<int, string>
|
||||
{
|
||||
[0] = "None",
|
||||
[1] = "Warning",
|
||||
[2] = "FeedHold",
|
||||
[3] = "ProgramHold",
|
||||
[4] = "ProgramAbort",
|
||||
[5] = "EmergencyStop",
|
||||
[6] = "Reset",
|
||||
[7] = "Info",
|
||||
[8] = "Error",
|
||||
[9] = "Note",
|
||||
};
|
||||
|
||||
/// <summary>Group code → HEIDENHAIN DNC name (<c>DNC_ERROR_GROUP</c>).</summary>
|
||||
public static IReadOnlyDictionary<int, string> GroupNames { get; } = new Dictionary<int, string>
|
||||
{
|
||||
[0] = "None",
|
||||
[1] = "Operating",
|
||||
[2] = "Programming",
|
||||
[3] = "Plc",
|
||||
[4] = "General",
|
||||
[5] = "Remote",
|
||||
[6] = "Python",
|
||||
};
|
||||
|
||||
/// <summary>The browse-picker folder holding the <c>ALARM:</c> selectors (lmxopcua#839).</summary>
|
||||
public const string BrowseFolder = "AlarmConditions";
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ALARM:</c> selectors the <c>/raw</c> browse picker offers (lmxopcua#839), as (sub-folder or null, leaf
|
||||
/// name, address): <c>*</c> and <c>OTHER</c> at the folder root, then one <c>CLASS=n</c> per named class
|
||||
/// (<c>ByClass/</c>) and one <c>GROUP=n</c> per named group (<c>ByGroup/</c>), code 0 ("None") excluded. The leaf
|
||||
/// name carries the code AND the DNC name (<c>Class5_EmergencyStop</c>) so a committed tag still says which
|
||||
/// number it filters on. Any other selector (ranges, NUMBER, combinations) is typed by hand as before.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(string? SubFolder, string Name, string Address)> BrowseSelectors { get; } = BuildBrowseSelectors();
|
||||
|
||||
private static List<(string?, string, string)> BuildBrowseSelectors()
|
||||
{
|
||||
var list = new List<(string?, string, string)>
|
||||
{
|
||||
(null, "AnyAlarm", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.AnyToken),
|
||||
(null, "OtherAlarms", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.OtherToken),
|
||||
};
|
||||
foreach (var (code, name) in ClassNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
|
||||
list.Add(("ByClass", $"Class{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}CLASS={code}"));
|
||||
foreach (var (code, name) in GroupNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
|
||||
list.Add(("ByGroup", $"Group{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}GROUP={code}"));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>The Part 9 condition class of one controller alarm (see the remarks for the evidence).</summary>
|
||||
/// <param name="errorClass">The alarm's class code.</param>
|
||||
/// <param name="group">The alarm's group code.</param>
|
||||
/// <returns>The class; <see cref="AlarmConditionClass.Base"/> when the codes say nothing.</returns>
|
||||
public static AlarmConditionClass ConditionClassOf(int errorClass, int group)
|
||||
{
|
||||
if (errorClass == EmergencyStopClass) return AlarmConditionClass.Safety;
|
||||
return group switch
|
||||
{
|
||||
1 or 2 => AlarmConditionClass.Process,
|
||||
4 or 5 => AlarmConditionClass.System,
|
||||
_ => AlarmConditionClass.Base,
|
||||
};
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -51,9 +51,10 @@ public readonly record struct HeidenhainAlarmRange(long Low, long High)
|
||||
/// <para>A range <c><r></c> is <c>n</c> or <c>lo..hi</c> (inclusive; <c>..</c> rather than <c>-</c> because
|
||||
/// alarm numbers can be negative — a real iTNC 530 reports <c>-1</c>). CLASS and GROUP are the wire's uint16
|
||||
/// fields (0–65535); NUMBER is int32.</para>
|
||||
/// <para><b>No semantic names.</b> The meaning of the class/group codes is not documented in the reference
|
||||
/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed:
|
||||
/// observe the raw values on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message, then filter.</para>
|
||||
/// <para><b>Numeric codes only.</b> The grammar takes raw codes, never names: LSV2 itself does not document them.
|
||||
/// <see cref="HeidenhainAlarmClasses"/> carries HEIDENHAIN's DNC-interface names for labels (the browse picker) and
|
||||
/// the Part 9 condition class (lmxopcua#839), but a selector always filters on the number the wire carries —
|
||||
/// observe it on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message.</para>
|
||||
/// <para><b>One authority.</b> Backs the AdminUI editor, the deploy inspector and the driver's table build.</para>
|
||||
/// </remarks>
|
||||
/// <param name="Kind">The selector kind.</param>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
@@ -45,8 +46,10 @@ internal sealed record HeidenhainToldCondition(
|
||||
/// displayed alarm leaves while others remain = <c>Retrigger</c> (un-acknowledges: a statement the operator
|
||||
/// has not seen); a non-displayed member leaves = nothing; nothing matches = <c>Clear</c>.</item>
|
||||
/// <item><b>Severity</b>: an authored integer <c>alarm.severity</c> is sent exactly (<c>OpcUaSeverity</c>);
|
||||
/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no
|
||||
/// class→severity table is invented.</item>
|
||||
/// otherwise the bucket is High and no exact value is sent. No class→severity table is invented.</item>
|
||||
/// <item><b>Condition class</b> (lmxopcua#839): a Raise / Retrigger carries the DISPLAYED alarm's Part 9 class
|
||||
/// (<see cref="HeidenhainAlarmClasses.ConditionClassOf"/>); a Clear or a quality-only event carries none, so the
|
||||
/// node keeps the class of the alarm it last showed.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class HeidenhainAlarmRules
|
||||
@@ -90,10 +93,11 @@ internal static class HeidenhainAlarmRules
|
||||
var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null);
|
||||
var message = MessageOf(matched);
|
||||
var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact);
|
||||
var conditionClass = HeidenhainAlarmClasses.ConditionClassOf(winner.Class, winner.Group);
|
||||
if (told is not { Active: true })
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised);
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact, conditionClass: conditionClass), raised);
|
||||
if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner)
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised);
|
||||
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact, conditionClass: conditionClass), raised);
|
||||
return (null, told with { Keys = keys });
|
||||
}
|
||||
|
||||
@@ -119,7 +123,8 @@ internal static class HeidenhainAlarmRules
|
||||
}
|
||||
|
||||
public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc,
|
||||
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) =>
|
||||
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null,
|
||||
AlarmConditionClass? conditionClass = null) =>
|
||||
new(handle,
|
||||
SourceNodeId: tag.HostName,
|
||||
ConditionId: tag.RawPath,
|
||||
@@ -129,7 +134,8 @@ internal static class HeidenhainAlarmRules
|
||||
SourceTimestampUtc: nowUtc,
|
||||
Kind: kind,
|
||||
OpcUaSeverity: exact,
|
||||
QualityStatusCode: quality);
|
||||
QualityStatusCode: quality,
|
||||
ConditionClass: conditionClass);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -154,8 +160,14 @@ internal static class HeidenhainAlarmRules
|
||||
/// that follows still sees the difference. Delivery runs under <see cref="_lock"/>; the only production handler
|
||||
/// is <c>DriverInstanceActor</c>'s non-blocking <c>Tell</c>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger)
|
||||
internal sealed class HeidenhainAlarmProjection(
|
||||
object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger, string driverInstanceId = "")
|
||||
{
|
||||
/// <summary>The shortest gap between two "unclaimed alarms" log lines for one control (lmxopcua#839).</summary>
|
||||
internal static readonly TimeSpan DefaultUnmatchedLogInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly Dictionary<string, UnmatchedState> _unmatched = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
|
||||
@@ -226,6 +238,8 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
|
||||
logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName);
|
||||
}
|
||||
|
||||
if (status == HeidenhainStatus.Good && alarms is not null) TrackUnmatched(hostName, deviceTags, alarms, now);
|
||||
|
||||
var h = handlers();
|
||||
if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again
|
||||
|
||||
@@ -255,6 +269,77 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gap between two "unclaimed alarms" log lines per control (test seam).</summary>
|
||||
internal TimeSpan UnmatchedLogInterval { get; set; } = DefaultUnmatchedLogInterval;
|
||||
|
||||
/// <summary>
|
||||
/// Per control: how many alarms on its last good walk no configured selector claims, and how many unclaimed
|
||||
/// occurrences were seen since the driver started (lmxopcua#839). Published on <c>/hosts</c> via
|
||||
/// <see cref="HeidenhainDriver.GetDiagnostics"/>.
|
||||
/// </summary>
|
||||
/// <returns>Host → (current, total).</returns>
|
||||
public IReadOnlyDictionary<string, (int Current, long Total)> UnmatchedSnapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
return _unmatched.ToDictionary(kv => kv.Key, kv => (kv.Value.Keys.Count, kv.Value.Total), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controller alarms that no configured <c>ALARM:</c> selector on the control claims are shown by NO condition
|
||||
/// (lmxopcua#839 — they used to vanish silently). A control with a <c>*</c> or <c>OTHER</c> selector claims every
|
||||
/// alarm. Each alarm ENTERING the unclaimed set counts once (metric + total), so a standing alarm is not
|
||||
/// re-counted every walk; the log is rate-limited per control to one line per <see cref="UnmatchedLogInterval"/>,
|
||||
/// naming up to three alarms and how many more were suppressed since the previous line. Must run under
|
||||
/// <see cref="_lock"/>.
|
||||
/// </summary>
|
||||
private void TrackUnmatched(string hostName, List<HeidenhainAlarmTag> deviceTags, IReadOnlyList<Lsv2Alarm> alarms, DateTime now)
|
||||
{
|
||||
if (!_unmatched.TryGetValue(hostName, out var state)) _unmatched[hostName] = state = new UnmatchedState();
|
||||
var claimsAll = deviceTags.Any(t => t.Selector.Kind != HeidenhainAlarmSelectorKind.Filter);
|
||||
var current = claimsAll
|
||||
? new HashSet<HeidenhainAlarmKey>()
|
||||
: alarms.Where(a => !deviceTags.Any(t => t.Selector.Matches(a.ErrorClass, a.Group, a.Number)))
|
||||
.Select(HeidenhainAlarmKey.Of).ToHashSet();
|
||||
var entered = current.Where(k => !state.Keys.Contains(k))
|
||||
.OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number).ThenBy(k => k.Message, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
state.Keys = current;
|
||||
if (entered.Count == 0) return;
|
||||
|
||||
state.Total += entered.Count;
|
||||
OtOpcUaTelemetry.DriverAlarmsUnmatched.Add(entered.Count,
|
||||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.Heidenhain),
|
||||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId));
|
||||
state.PendingLog += entered.Count;
|
||||
if (state.LastLogUtc is { } last && now - last < UnmatchedLogInterval)
|
||||
{
|
||||
state.PendingSample ??= entered;
|
||||
return;
|
||||
}
|
||||
|
||||
var sample = state.PendingSample is { } earlier ? earlier.Concat(entered).Distinct().ToList() : entered;
|
||||
var suppressed = state.PendingLog - entered.Count;
|
||||
logger.LogWarning(
|
||||
"Heidenhain {Host}: {Count} controller alarm(s) match no ALARM: selector on this control and are shown by no condition " +
|
||||
"(e.g. {Examples}){Suppressed}; add a selector or an ALARM:OTHER catch-all to surface them",
|
||||
hostName, entered.Count, string.Join("; ", sample.Take(3).Select(Describe)),
|
||||
suppressed > 0 ? $"; {suppressed} more since the previous report" : "");
|
||||
state.LastLogUtc = now;
|
||||
state.PendingLog = 0;
|
||||
state.PendingSample = null;
|
||||
|
||||
static string Describe(HeidenhainAlarmKey k) => HeidenhainAlarmRules.MessageOf([k]);
|
||||
}
|
||||
|
||||
private sealed class UnmatchedState
|
||||
{
|
||||
public HashSet<HeidenhainAlarmKey> Keys { get; set; } = [];
|
||||
public long Total { get; set; }
|
||||
public DateTime? LastLogUtc { get; set; }
|
||||
public long PendingLog { get; set; }
|
||||
public List<HeidenhainAlarmKey>? PendingSample { get; set; }
|
||||
}
|
||||
|
||||
private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
|
||||
{
|
||||
_told.TryGetValue(tag.RawPath, out var told);
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
_credentials = credentials;
|
||||
_clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t));
|
||||
_time = time ?? TimeProvider.System;
|
||||
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger);
|
||||
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger, driverInstanceId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -419,6 +419,13 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
|
||||
}
|
||||
|
||||
// lmxopcua#839: controller alarms no ALARM: selector claims (shown by no condition) — current + since start.
|
||||
foreach (var (host, (current, total)) in _alarms.UnmatchedSnapshot())
|
||||
{
|
||||
bag[$"{host}.alarms.unmatched"] = current.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
bag[$"{host}.alarms.unmatchedTotal"] = total.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return DriverDiagnostics.Bound(bag);
|
||||
}
|
||||
|
||||
@@ -428,7 +435,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The namespace is a fixed catalog (no control-dependent path churn), so the browse picker can offer it
|
||||
/// without contacting the control. PLC/DATA bindings are authored by hand: LSV2 has no proven universal
|
||||
/// without contacting the control — plus the <c>AlarmConditions/</c> folder of <c>ALARM:</c> selectors
|
||||
/// (lmxopcua#839). PLC/DATA bindings are authored by hand: LSV2 has no proven universal
|
||||
/// symbol model and this driver performs no memory scanning.
|
||||
/// </remarks>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
@@ -449,6 +457,20 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
|
||||
e.Address, e.DataType, e.IsArray, null, SecurityClassification.ViewOnly, IsHistorized: false));
|
||||
}
|
||||
|
||||
// lmxopcua#839: the ALARM: selectors, so a condition can be browse-committed rather than only typed. Each is an
|
||||
// IsAlarm Boolean leaf; the commit's typed model seeds the paired `alarm` object the factory requires.
|
||||
var alarmRoot = builder.Folder(HeidenhainAlarmClasses.BrowseFolder, HeidenhainAlarmClasses.BrowseFolder);
|
||||
var alarmFolders = new Dictionary<string, IAddressSpaceBuilder>(StringComparer.Ordinal);
|
||||
foreach (var (sub, name, address) in HeidenhainAlarmClasses.BrowseSelectors)
|
||||
{
|
||||
var parent = alarmRoot;
|
||||
if (sub is not null && !alarmFolders.TryGetValue(sub, out parent))
|
||||
alarmFolders[sub] = parent = alarmRoot.Folder(sub, sub);
|
||||
parent.Variable(name, name, new DriverAttributeInfo(
|
||||
address, HeidenhainTagDefinitionFactory.AlarmConditionDataType, false, null, SecurityClassification.ViewOnly,
|
||||
IsHistorized: false, IsAlarm: true));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,15 @@ public sealed class MTConnectDriverOptions
|
||||
/// </summary>
|
||||
public int HeartbeatMs { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets how long, in milliseconds, the Agent must keep REFUSING connections (a definite loss — nothing is
|
||||
/// listening at <see cref="AgentUri"/>) before every subscribed value is marked <c>BadNoCommunication</c>
|
||||
/// (lmxopcua#846). Default <c>2000</c>; <c>0</c> marks on the first refusal; a negative value disables the fast
|
||||
/// path, leaving the comms-loss watchdog (~90 s) as the only signal. Ambiguous failures (timeouts, a stalled or
|
||||
/// reset stream, DNS) never use it — they keep the watchdog contract.
|
||||
/// </summary>
|
||||
public int CommsLossConfirmMs { get; init; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// The authored tags this driver instance serves — one <see cref="MTConnectTagDefinition"/>
|
||||
/// per tag, keyed by the DataItem <c>id</c> it binds
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#846 — the fast comms-loss path for a DEFINITE Agent loss. Before it, an Agent outage left every value
|
||||
/// Good-but-stale for ~94–118 s, until the runtime's comms-loss watchdog (Degraded + a frozen
|
||||
/// <c>LastSuccessfulRead</c> for 90 s) re-initialized the driver and the host fanned <c>BadNoCommunication</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only a refusal is definite.</b> A connection REFUSED (<see cref="SocketError.ConnectionRefused"/> anywhere
|
||||
/// in the exception chain — what an <c>HttpClient</c> connect to a port nobody listens on produces) proves
|
||||
/// nothing is serving the Agent URI. Everything else — a request or stream-idle timeout, a reset or half-closed
|
||||
/// stream, a DNS failure, a paused Agent — is ambiguous and keeps the watchdog contract
|
||||
/// (<c>docs/DriverLifecycle.md</c>, <c>.claude/rules/drivers.md</c> #527/#676): those must never mark early.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Confirmed, not instant.</b> Refusals must continue, unbroken, for
|
||||
/// <see cref="MTConnectDriverOptions.CommsLossConfirmMs"/> (default 2 s; 0 = first refusal; negative = off)
|
||||
/// before the values are marked, so a single refused connect during an Agent restart does not flap every value.
|
||||
/// While confirmation is pending the reconnect delay is capped at the time remaining, so the confirming
|
||||
/// attempt is not pushed out by the backoff ladder. Anything that reaches the Agent ends the run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What marking does — the driver half of the quality contract.</b> Every subscribed value is published
|
||||
/// <c>BadNoCommunication</c> (value null) ONCE per outage; the observation index keeps the last values. Health
|
||||
/// is already Degraded with <c>LastSuccessfulRead</c> frozen (the stream failure degraded it), so the watchdog
|
||||
/// still re-initializes at 90 s exactly as before — this path only stops serving stale values as Good in the
|
||||
/// meantime. Reads already fall back to <c>/current</c> on a degraded stream and fail Bad on their own.
|
||||
/// Native CONDITIONS are not touched: the host annotates them on the connectivity edge, as before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Recovery republishes everything.</b> The first delivered chunk or re-baseline after a marked outage
|
||||
/// republishes every subscribed value from the index — a resumed <c>/sample</c> only carries what CHANGED, so
|
||||
/// an unchanged data item would otherwise stay Bad until it next moved (the (value, status) recovery rule).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class MTConnectDriver
|
||||
{
|
||||
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c>.</summary>
|
||||
private const uint BadNoCommunicationStatus = 0x80310000u;
|
||||
|
||||
/// <summary>1 while a confirmed refused-connection outage has marked the subscribed values Bad.</summary>
|
||||
private int _commsLostMarked;
|
||||
|
||||
/// <summary>Whether a refused-connection outage currently has the values marked Bad (test seam / diagnostics).</summary>
|
||||
internal bool CommsLossMarked => Volatile.Read(ref _commsLostMarked) == 1;
|
||||
|
||||
/// <summary>One pump's run of consecutive refused connections. Only that pump's task touches it.</summary>
|
||||
private sealed class RefusalState
|
||||
{
|
||||
/// <summary>When the current unbroken run of refusals started, or null when there is none.</summary>
|
||||
public DateTime? Since { get; set; }
|
||||
|
||||
/// <summary>Ends the run (anything reached the Agent, or an ambiguous failure broke it).</summary>
|
||||
public void Reset() => Since = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="error"/> proves nothing is listening at the Agent URI — a connection refused anywhere
|
||||
/// in its chain. Every other failure is ambiguous (see the class remarks).
|
||||
/// </summary>
|
||||
/// <param name="error">The stream / re-baseline failure.</param>
|
||||
/// <returns>True for a definite loss.</returns>
|
||||
internal static bool IsDefiniteConnectionLoss(Exception? error)
|
||||
{
|
||||
for (var e = error; e is not null; e = e.InnerException)
|
||||
{
|
||||
if (e is SocketException { SocketErrorCode: SocketError.ConnectionRefused })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e is AggregateException agg && agg.InnerExceptions.Any(IsDefiniteConnectionLoss))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one transient stream failure and, once refusals have persisted for the confirmation window, marks
|
||||
/// every subscribed value <c>BadNoCommunication</c> (once per outage).
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when the pump's session has been replaced (nothing marked).</returns>
|
||||
private bool NoteStreamFailure(RefusalState refusals, StreamOutcome outcome, MTConnectDriverOptions options, long generation)
|
||||
{
|
||||
if (options.CommsLossConfirmMs < 0 || !IsDefiniteConnectionLoss(outcome.Error))
|
||||
{
|
||||
// An ambiguous failure breaks the run: only an UNBROKEN series of refusals is a confirmed loss.
|
||||
refusals.Reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
refusals.Since ??= now;
|
||||
if (now - refusals.Since.Value < TimeSpan.FromMilliseconds(options.CommsLossConfirmMs))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryMarkCommsLost(generation, options, now - refusals.Since.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While a refusal run is waiting for confirmation, the next attempt is made no later than the moment the window
|
||||
/// closes — so the confirming refusal is not deferred by a long backoff step.
|
||||
/// </summary>
|
||||
private static TimeSpan CapForCommsLossConfirmation(TimeSpan delay, RefusalState refusals, MTConnectDriverOptions options)
|
||||
{
|
||||
if (refusals.Since is not { } since || options.CommsLossConfirmMs < 0)
|
||||
{
|
||||
return delay;
|
||||
}
|
||||
|
||||
var remaining = since.AddMilliseconds(options.CommsLossConfirmMs) - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return delay; // already confirmed (or about to be): the ordinary ladder paces the outage
|
||||
}
|
||||
|
||||
return remaining < delay ? remaining : delay;
|
||||
}
|
||||
|
||||
private bool TryMarkCommsLost(long generation, MTConnectDriverOptions options, TimeSpan refusedFor)
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_sessionGeneration != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _commsLostMarked, 1) == 1)
|
||||
{
|
||||
return true; // already marked for this outage
|
||||
}
|
||||
|
||||
var references = SubscribedReferences();
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId}: {AgentUri} has refused every connection for {RefusedMs} ms (definite loss); marking {Count} subscribed value(s) BadNoCommunication until it answers again.",
|
||||
_driverInstanceId, AgentDisplay(options), (long)refusedFor.TotalMilliseconds, references.Count);
|
||||
FanOutStatus(references, BadNoCommunicationStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent answered again after a marked outage: republish every subscribed value from the index (once).
|
||||
/// Generation-checked so a superseded pump cannot republish into the session that replaced it.
|
||||
/// </summary>
|
||||
private void RepublishAfterCommsLoss(long generation)
|
||||
{
|
||||
if (Volatile.Read(ref _commsLostMarked) == 0 || !IsCurrentSessionGeneration(generation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _commsLostMarked, 0) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId}: the Agent answered again; republishing every subscribed value.",
|
||||
_driverInstanceId);
|
||||
FanOut(SubscribedReferences(), ObservationIndex);
|
||||
}
|
||||
|
||||
/// <summary>Publishes a value-less <paramref name="status"/> to every handle subscribing each reference.</summary>
|
||||
private void FanOutStatus(IReadOnlyList<string> references, uint status)
|
||||
{
|
||||
if (references.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SubscriptionState[] states;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
states = [.. _subscriptions.Values];
|
||||
}
|
||||
|
||||
var snapshot = new DataValueSnapshot(null, status, null, DateTime.UtcNow);
|
||||
foreach (var reference in references)
|
||||
{
|
||||
foreach (var state in states)
|
||||
{
|
||||
if (state.References.Contains(reference))
|
||||
{
|
||||
RaiseDataChange(state.Handle, reference, snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,9 +79,23 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
|
||||
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
|
||||
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
|
||||
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
|
||||
internal static int SampleCountCeiling(int configured) =>
|
||||
Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling));
|
||||
/// <param name="agentBufferSize">
|
||||
/// The Agent's <c>/probe</c> <c>bufferSize</c>, or <c>0</c> when unknown. lmxopcua#846: an Agent refuses a
|
||||
/// <c>count</c> above it (<c>OUT_OF_RANGE</c>; live, <c>count == bufferSize</c> is accepted), so a known buffer
|
||||
/// caps the raised count — a raise past it would only buy a refused stream and a step-back.
|
||||
/// </param>
|
||||
/// <returns>The ceiling — never below <paramref name="configured"/> (a configured count above the buffer is the
|
||||
/// #825 configuration warning's business, not something to raise further).</returns>
|
||||
internal static int SampleCountCeiling(int configured, long agentBufferSize = 0)
|
||||
{
|
||||
long ceiling = Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling);
|
||||
if (agentBufferSize > 0)
|
||||
{
|
||||
ceiling = Math.Min(ceiling, agentBufferSize);
|
||||
}
|
||||
|
||||
return (int)Math.Max(configured, ceiling);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics()
|
||||
@@ -132,7 +146,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>One pump's adaptive-count state. Only that pump's task touches it.</summary>
|
||||
private sealed class SampleCountState(int configured)
|
||||
private sealed class SampleCountState(int configured, long agentBufferSize)
|
||||
{
|
||||
/// <summary>The count the next stream opens with.</summary>
|
||||
public int Count { get; set; } = configured;
|
||||
@@ -141,7 +155,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
public int LastDelivered { get; set; } = configured;
|
||||
|
||||
/// <summary>The most <see cref="Count"/> may be raised to; lowered to the last good count on a refusal.</summary>
|
||||
public int Ceiling { get; set; } = SampleCountCeiling(configured);
|
||||
public int Ceiling { get; set; } = SampleCountCeiling(configured, agentBufferSize);
|
||||
|
||||
/// <summary>Whether the at-the-ceiling Warning has been logged for this pump.</summary>
|
||||
public bool CeilingWarned { get; set; }
|
||||
|
||||
@@ -2229,8 +2229,9 @@ public sealed partial class MTConnectDriver
|
||||
var cursor = from;
|
||||
var attempt = 0;
|
||||
_reconnectAttempts = 0;
|
||||
var counts = new SampleCountState(options.SampleCount);
|
||||
var counts = new SampleCountState(options.SampleCount, Interlocked.Read(ref _agentBufferSize));
|
||||
Volatile.Write(ref _effectiveSampleCount, counts.Count);
|
||||
var refusals = new RefusalState();
|
||||
|
||||
// #804 (Java-driver parity) — consecutive re-baselines with no /sample chunk delivered in between.
|
||||
var rebaselinesWithoutProgress = 0;
|
||||
@@ -2250,7 +2251,7 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
if (attempt > 0)
|
||||
{
|
||||
var delay = BackoffFor(attempt, options.Reconnect);
|
||||
var delay = CapForCommsLossConfirmation(BackoffFor(attempt, options.Reconnect), refusals, options);
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
@@ -2273,6 +2274,12 @@ public sealed partial class MTConnectDriver
|
||||
{
|
||||
rebaselinesWithoutProgress = 0;
|
||||
}
|
||||
|
||||
// #846 — only an unbroken run of refusals confirms a loss: anything that reached the Agent ends it.
|
||||
if (outcome.DeliveredAny || outcome.Verdict is not StreamVerdict.Transient)
|
||||
{
|
||||
refusals.Reset();
|
||||
}
|
||||
cursor = outcome.Cursor;
|
||||
instanceId = outcome.InstanceId;
|
||||
|
||||
@@ -2375,6 +2382,13 @@ public sealed partial class MTConnectDriver
|
||||
return;
|
||||
}
|
||||
|
||||
// #846 — a REFUSED connection is a definite loss; after CommsLossConfirmMs of nothing but
|
||||
// refusals the values go BadNoCommunication now instead of at the ~90 s watchdog.
|
||||
if (!NoteStreamFailure(refusals, outcome, options, generation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A stream that DELIVERED before it dropped proves the endpoint works, so
|
||||
// its failure starts a fresh backoff ladder. Without this the counter is
|
||||
// monotonic for the life of the process: a healthy agent that drops a
|
||||
@@ -2519,6 +2533,8 @@ public sealed partial class MTConnectDriver
|
||||
return new StreamOutcome(StreamVerdict.Cancelled, expected, instanceId, delivered, null);
|
||||
}
|
||||
|
||||
RepublishAfterCommsLoss(generation);
|
||||
|
||||
// R06:T-5 — a stream persistently behind the Agent reopens at the cursor it reached with a larger
|
||||
// count, rather than falling out of the ring buffer and re-baselining every buffer-length.
|
||||
if (!MeasureSampleLag(chunk, counts.Count))
|
||||
@@ -2643,6 +2659,9 @@ public sealed partial class MTConnectDriver
|
||||
StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
|
||||
}
|
||||
|
||||
// #846 — the Agent answered: if a refused-connection outage marked the values Bad, put them all back.
|
||||
RepublishAfterCommsLoss(generation);
|
||||
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} re-baselined from {AgentUri}'s /current ({ObservationCount} observation(s), agent instanceId {InstanceId}) and resumes /sample from sequence {NextSequence}.",
|
||||
_driverInstanceId,
|
||||
@@ -3247,6 +3266,7 @@ public sealed partial class MTConnectDriver
|
||||
SampleIntervalMs = dto.SampleIntervalMs ?? 1_000,
|
||||
SampleCount = dto.SampleCount ?? 1_000,
|
||||
HeartbeatMs = dto.HeartbeatMs ?? 10_000,
|
||||
CommsLossConfirmMs = dto.CommsLossConfirmMs ?? 2_000,
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
@@ -4447,6 +4467,8 @@ public sealed partial class MTConnectDriver
|
||||
public int? SampleCount { get; init; }
|
||||
/// <summary>The /sample heartbeat in milliseconds.</summary>
|
||||
public int? HeartbeatMs { get; init; }
|
||||
/// <summary>How long refused connections must persist before values go BadNoCommunication (#846).</summary>
|
||||
public int? CommsLossConfirmMs { get; init; }
|
||||
/// <summary>The authored tags.</summary>
|
||||
public List<MTConnectTagDto>? Tags { get; init; }
|
||||
|
||||
|
||||
@@ -1209,6 +1209,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
|
||||
condition.Message!.Value = new LocalizedText(state.Message);
|
||||
// #839: a classifying native source's Part 9 ConditionClass. Null leaves the node's class as it is
|
||||
// (BaseConditionClass from materialise, or what an earlier event set). Not in the delta-gate: a class
|
||||
// rides along with the transition that set it and never fires an event on its own.
|
||||
if (state.ConditionClass is { } conditionClass) ApplyConditionClass(condition, conditionClass);
|
||||
|
||||
// Project the source-data quality. A Good→Bad bucket change is a genuine condition
|
||||
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
|
||||
@@ -1244,6 +1248,27 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Part 9 class type id + browse name of an <see cref="AlarmConditionClass"/> (lmxopcua#839).
|
||||
/// </summary>
|
||||
/// <param name="conditionClass">The driver-assigned class.</param>
|
||||
/// <returns>The <c>ConditionClassId</c> / <c>ConditionClassName</c> pair to write.</returns>
|
||||
internal static (NodeId Id, string Name) ConditionClassOf(AlarmConditionClass conditionClass) => conditionClass switch
|
||||
{
|
||||
AlarmConditionClass.Process => (ObjectTypeIds.ProcessConditionClassType, "ProcessConditionClass"),
|
||||
AlarmConditionClass.Maintenance => (ObjectTypeIds.MaintenanceConditionClassType, "MaintenanceConditionClass"),
|
||||
AlarmConditionClass.System => (ObjectTypeIds.SystemConditionClassType, "SystemConditionClass"),
|
||||
AlarmConditionClass.Safety => (ObjectTypeIds.SafetyConditionClassType, "SafetyConditionClass"),
|
||||
_ => (ObjectTypeIds.BaseConditionClassType, "BaseConditionClass"),
|
||||
};
|
||||
|
||||
private static void ApplyConditionClass(AlarmConditionState condition, AlarmConditionClass conditionClass)
|
||||
{
|
||||
var (id, name) = ConditionClassOf(conditionClass);
|
||||
if (condition.ConditionClassId is not null) condition.ConditionClassId.Value = id;
|
||||
if (condition.ConditionClassName is not null) condition.ConditionClassName.Value = new LocalizedText(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
|
||||
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
|
||||
@@ -1623,9 +1648,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
|
||||
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
|
||||
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
|
||||
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
|
||||
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
|
||||
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
|
||||
// upstream category is Safety/Diagnostics. Per-alarm classification arrives at RUNTIME instead: a
|
||||
// classifying native driver states AlarmEventArgs.ConditionClass on its transitions (#839, Heidenhain),
|
||||
// which WriteAlarmCondition applies over this default — the deploy-time seam still holds none.
|
||||
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
|
||||
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
|
||||
|
||||
@@ -1783,7 +1808,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
|
||||
private sealed record NativeConditionCarryOver(
|
||||
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
|
||||
LocalizedText? Comment = null, string? ClientUserId = null);
|
||||
LocalizedText? Comment = null, string? ClientUserId = null, NodeId? ConditionClassId = null,
|
||||
LocalizedText? ConditionClassName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Read what a native condition node holds right now, so the rebuild can put it back.
|
||||
@@ -1810,7 +1836,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
condition.ActiveState?.TransitionTime?.Value.ToDateTime(),
|
||||
condition.Quality?.SourceTimestamp?.Value.ToDateTime(),
|
||||
condition.Comment?.Value,
|
||||
condition.ClientUserId?.Value);
|
||||
condition.ClientUserId?.Value,
|
||||
condition.ConditionClassId?.Value,
|
||||
condition.ConditionClassName?.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Put a carried state back onto the freshly materialised native condition. MUST be called
|
||||
@@ -1839,6 +1867,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
|
||||
{
|
||||
var state = carried.State;
|
||||
// #839: a driver-assigned ConditionClass is a property of the condition, not of its state — restored whether
|
||||
// or not the condition is off-normal (the materialise default is BaseConditionClass).
|
||||
if (alarm.ConditionClassId is { } classIdNode && carried.ConditionClassId is { } classId && !classId.IsNull)
|
||||
classIdNode.Value = classId;
|
||||
if (alarm.ConditionClassName is not null && carried.ConditionClassName is { } className)
|
||||
alarm.ConditionClassName.Value = className;
|
||||
if (alarm.Quality is not null)
|
||||
{
|
||||
alarm.Quality.Value = state.Quality;
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmProjector
|
||||
{
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode)> _prior =
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode, AlarmConditionClass? Class)> _prior =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
@@ -82,7 +82,7 @@ public sealed class NativeAlarmProjector
|
||||
// driver's word, so a late ack failure must not rewrite it.
|
||||
_pendingLocalAck.Remove(nodeId);
|
||||
var prev = _prior.TryGetValue(nodeId, out var p)
|
||||
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good);
|
||||
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good, Class: (AlarmConditionClass?)null);
|
||||
// A source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||
// Only a statement saying the SAME thing the condition already shows is a re-statement.
|
||||
@@ -154,14 +154,18 @@ public sealed class NativeAlarmProjector
|
||||
}
|
||||
|
||||
var statusCode = e.QualityStatusCode ?? OpcUaStatus.Good;
|
||||
_prior[nodeId] = (active, acked, e.Message, sev, statusCode);
|
||||
// A source that classifies its conditions (#839) states the class on its transitions; one that does not (or a
|
||||
// quality-only annotation) keeps whatever the condition last carried.
|
||||
var conditionClass = e.ConditionClass ?? prev.Class;
|
||||
_prior[nodeId] = (active, acked, e.Message, sev, statusCode, conditionClass);
|
||||
return new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||
// A per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
|
||||
// (every earlier source) ⇒ Good, as before — driver-wide comms loss stays the host's
|
||||
// out-of-band WriteAlarmQuality annotation, not something the projector infers.
|
||||
StatusCode: statusCode);
|
||||
StatusCode: statusCode,
|
||||
ConditionClass: conditionClass);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -195,7 +199,7 @@ public sealed class NativeAlarmProjector
|
||||
snapshot = new AlarmConditionSnapshot(
|
||||
Active: p.Active, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: p.Severity, Message: p.Message ?? string.Empty,
|
||||
StatusCode: p.StatusCode);
|
||||
StatusCode: p.StatusCode, ConditionClass: p.Class);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,131 @@ public sealed class HeidenhainAlarmTests
|
||||
e.OpcUaSeverity.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Part 9 condition class (lmxopcua#839) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(5, 3, AlarmConditionClass.Safety)] // EMERGENCY STOP, whatever the group
|
||||
[InlineData(5, 0, AlarmConditionClass.Safety)]
|
||||
[InlineData(8, 1, AlarmConditionClass.Process)] // OPERATING
|
||||
[InlineData(1, 2, AlarmConditionClass.Process)] // PROGRAMMING
|
||||
[InlineData(7, 4, AlarmConditionClass.System)] // GENERAL — the captured iTNC 530 "Run test of cutout channels"
|
||||
[InlineData(8, 5, AlarmConditionClass.System)] // REMOTE
|
||||
[InlineData(8, 3, AlarmConditionClass.Base)] // PLC: machine-builder text, no evidence either way
|
||||
[InlineData(8, 6, AlarmConditionClass.Base)] // PYTHON
|
||||
[InlineData(8, 0, AlarmConditionClass.Base)]
|
||||
[InlineData(99, 99, AlarmConditionClass.Base)] // unknown codes never guess
|
||||
public void Condition_class_maps_only_where_the_codes_say_where_the_alarm_comes_from(int cls, int group, AlarmConditionClass expected) =>
|
||||
HeidenhainAlarmClasses.ConditionClassOf(cls, group).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Raise_and_retrigger_carry_the_displayed_alarms_class_and_clear_carries_none()
|
||||
{
|
||||
var tag = Tag("*");
|
||||
var all = new[] { tag.Selector };
|
||||
var now = DateTime.UtcNow;
|
||||
var info = new Lsv2Alarm(7, 4, -1, "Run test of cutout channels");
|
||||
var estop = new Lsv2Alarm(5, 3, 10, "Emergency stop");
|
||||
|
||||
var (e1, t1) = HeidenhainAlarmRules.Transition(null, HeidenhainAlarmRules.Evaluate(tag.Selector, [info], all), tag, H, now);
|
||||
e1!.ConditionClass.ShouldBe(AlarmConditionClass.System);
|
||||
var (e2, t2) = HeidenhainAlarmRules.Transition(t1, HeidenhainAlarmRules.Evaluate(tag.Selector, [info, estop], all), tag, H, now);
|
||||
e2!.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
e2.ConditionClass.ShouldBe(AlarmConditionClass.Safety); // the class-5 alarm is now the displayed one
|
||||
var (e3, _) = HeidenhainAlarmRules.Transition(t2, [], tag, H, now);
|
||||
e3!.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
e3.ConditionClass.ShouldBeNull(); // the node keeps the class of the alarm it last showed
|
||||
e1.Message.ShouldBe("-1 Run test of cutout channels (class 7, group 4)"); // the message is unchanged by #839
|
||||
}
|
||||
|
||||
// ---- unclaimed alarms are counted and logged (lmxopcua#839) ----
|
||||
|
||||
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger
|
||||
{
|
||||
public ConcurrentQueue<(Microsoft.Extensions.Logging.LogLevel Level, string Text)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Enqueue((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private static HeidenhainAlarmProjection Projection(ListLogger log, params HeidenhainAlarmTag[] tags)
|
||||
{
|
||||
var p = new HeidenhainAlarmProjection(new object(), () => null, log, "hh-1")
|
||||
{
|
||||
Tags = tags.ToDictionary(t => t.RawPath, StringComparer.Ordinal),
|
||||
};
|
||||
p.Subscribe(tags.Select(t => t.RawPath).ToList());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static HeidenhainAlarmTag TagOn(string host, string selector, string name) =>
|
||||
new($"Plant/H/dev1/{name}", host, Parse(selector), null);
|
||||
|
||||
[Fact]
|
||||
public void Alarms_no_selector_claims_are_counted_once_per_occurrence_and_logged_rate_limited()
|
||||
{
|
||||
var log = new ListLogger();
|
||||
var p = Projection(log, TagOn("h:1", "CLASS=7", "Info"));
|
||||
var claimed = new Lsv2Alarm(7, 4, -1, "Run test of cutout channels");
|
||||
var lost = new Lsv2Alarm(3, 1, 100, "Coolant low");
|
||||
var lost2 = new Lsv2Alarm(8, 2, 200, "Program error");
|
||||
static int Warnings(ListLogger l) => l.Entries.Count(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [claimed, lost]);
|
||||
p.UnmatchedSnapshot()["h:1"].ShouldBe((1, 1L));
|
||||
Warnings(log).ShouldBe(1);
|
||||
log.Entries.Last().Text.ShouldContain("100 Coolant low (class 3, group 1)");
|
||||
|
||||
// A standing alarm is not re-counted, and the log is rate-limited.
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [claimed, lost]);
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [lost, lost2]);
|
||||
p.UnmatchedSnapshot()["h:1"].ShouldBe((2, 2L));
|
||||
Warnings(log).ShouldBe(1);
|
||||
|
||||
// It leaves and comes back — a new occurrence. The next permitted line reports what was suppressed.
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [lost2]);
|
||||
p.UnmatchedLogInterval = TimeSpan.Zero;
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [lost, lost2]);
|
||||
p.UnmatchedSnapshot()["h:1"].ShouldBe((2, 3L));
|
||||
Warnings(log).ShouldBe(2);
|
||||
log.Entries.Last().Text.ShouldContain("1 more since the previous report");
|
||||
|
||||
// A failed walk is not "no alarms": it changes nothing.
|
||||
p.OnWalk("h:1", HeidenhainStatus.BadNoCommunication, null);
|
||||
p.UnmatchedSnapshot()["h:1"].ShouldBe((2, 3L));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("OTHER")]
|
||||
public void A_catch_all_selector_claims_every_alarm(string catchAll)
|
||||
{
|
||||
var log = new ListLogger();
|
||||
var p = Projection(log, TagOn("h:1", "CLASS=7", "Info"), TagOn("h:1", catchAll, "All"));
|
||||
p.OnWalk("h:1", HeidenhainStatus.Good, [new Lsv2Alarm(3, 1, 100, "Coolant low")]);
|
||||
p.UnmatchedSnapshot()["h:1"].ShouldBe((0, 0L));
|
||||
log.Entries.ShouldNotContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unclaimed_alarms_reach_the_hosts_diagnostics()
|
||||
{
|
||||
var control = new SimulatedControl();
|
||||
var list = new AlarmList();
|
||||
list.Install(control);
|
||||
list.Set(Golden(), Record(3, 1, 100, "Coolant low"));
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var cls7 = AlarmTag("CLASS=7", "Class7");
|
||||
await using var d = Create(Options(sim.Port, cls7));
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAlarmsAsync([cls7.RawPath], Ct);
|
||||
var host = $"127.0.0.1:{sim.Port}";
|
||||
await WaitUntil(() => d.GetDiagnostics().TryGetValue($"{host}.alarms.unmatched", out var v) && v == "1");
|
||||
d.GetDiagnostics()[$"{host}.alarms.unmatchedTotal"].ShouldBe("1");
|
||||
}
|
||||
|
||||
// ---- end to end against the simulator ----
|
||||
|
||||
/// <summary>A control whose active-alarm list can be changed between walks (27 = first, 28 = next, 57 = end).</summary>
|
||||
|
||||
@@ -80,7 +80,21 @@ public sealed class HeidenhainDriverTests
|
||||
await d.DiscoverAsync(builder, Ct);
|
||||
await Task.Delay(200, Ct);
|
||||
sim.Connections.ShouldBe(0);
|
||||
builder.Variables.Count.ShouldBe(HeidenhainCatalog.Entries.Count);
|
||||
// The fixed catalog plus the ALARM: selectors (lmxopcua#839) — every one an IsAlarm Boolean leaf whose address
|
||||
// the driver's own parser takes as a condition selector.
|
||||
builder.Variables.Count.ShouldBe(HeidenhainCatalog.Entries.Count + HeidenhainAlarmClasses.BrowseSelectors.Count);
|
||||
var alarmLeaves = builder.Variables.Where(v => v.IsAlarm).ToList();
|
||||
alarmLeaves.Select(v => v.FullName).ShouldBe(HeidenhainAlarmClasses.BrowseSelectors.Select(x => x.Address));
|
||||
alarmLeaves.ShouldAllBe(v => v.DriverDataType == DriverDataType.Boolean && !v.IsArray);
|
||||
foreach (var leaf in alarmLeaves)
|
||||
{
|
||||
HeidenhainAddress.TryParse(leaf.FullName, out var a, out var error).ShouldBeTrue(error);
|
||||
a!.Kind.ShouldBe(HeidenhainAddressKind.Alarm);
|
||||
}
|
||||
|
||||
alarmLeaves.Select(v => v.FullName).ShouldContain("ALARM:*");
|
||||
alarmLeaves.Select(v => v.FullName).ShouldContain("ALARM:OTHER");
|
||||
alarmLeaves.Select(v => v.FullName).ShouldContain("ALARM:CLASS=5");
|
||||
builder.Variables.ShouldContain(v => v.FullName == "Axes/Positions" && v.IsArray && v.DriverDataType == DriverDataType.Float64);
|
||||
builder.Variables.ShouldAllBe(v => v.SecurityClass == SecurityClassification.ViewOnly);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#846 — a REFUSED Agent connection is a definite loss: after <c>CommsLossConfirmMs</c> of nothing but
|
||||
/// refusals every subscribed value goes <c>BadNoCommunication</c> (instead of Good-but-stale until the ~90 s
|
||||
/// watchdog), and the first delivery afterwards republishes every value Good — including unchanged ones. Ambiguous
|
||||
/// failures (timeouts, a closed stream) keep the watchdog contract. Plus the raised-count ceiling is clamped to the
|
||||
/// Agent's <c>bufferSize</c>.
|
||||
/// </summary>
|
||||
public sealed class MTConnectCommsLossTests
|
||||
{
|
||||
private const long Instance = 1655000000L;
|
||||
private const long Primed = 108L;
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static MTConnectStreamsResult Chunk(long first, int observations, long? last = null)
|
||||
{
|
||||
var obs = Enumerable.Range(0, observations)
|
||||
.Select(i => new MTConnectObservation(
|
||||
"dev1_pos", (first + i).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
new DateTime(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc).AddSeconds(first + i)))
|
||||
.ToList();
|
||||
return new MTConnectStreamsResult(Instance, first + observations, first, obs, last);
|
||||
}
|
||||
|
||||
private static Exception Refused() =>
|
||||
new HttpRequestException("Connection refused (fixture-agent:5000)", new SocketException((int)SocketError.ConnectionRefused));
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client, ConcurrentQueue<DataChangeEventArgs> Seen)>
|
||||
SubscribedAsync(int commsLossConfirmMs, int sampleCount = 1000, long? bufferSize = null)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
if (bufferSize is { } size) client.Probe = client.Probe with { BufferSize = size };
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
SampleCount = sampleCount,
|
||||
CommsLossConfirmMs = commsLossConfirmMs,
|
||||
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 200 },
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-846",
|
||||
_ => client);
|
||||
var seen = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, e) => seen.Enqueue(e);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
return (driver, client, seen);
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 10_000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
|
||||
await Task.Delay(20, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_a_refused_connection_is_a_definite_loss()
|
||||
{
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(Refused()).ShouldBeTrue();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new SocketException((int)SocketError.ConnectionRefused)).ShouldBeTrue();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new AggregateException(Refused())).ShouldBeTrue();
|
||||
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(null).ShouldBeFalse();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new TimeoutException("no answer")).ShouldBeFalse();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new HttpRequestException("reset", new SocketException((int)SocketError.ConnectionReset))).ShouldBeFalse();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new HttpRequestException("dns", new SocketException((int)SocketError.HostNotFound))).ShouldBeFalse();
|
||||
MTConnectDriver.IsDefiniteConnectionLoss(new MTConnectStreamEndedException(MTConnectStreamEndReason.ConnectionClosed, "x", 3)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_agent_marks_values_bad_at_once_and_the_first_delivery_republishes_them_good()
|
||||
{
|
||||
var (driver, client, seen) = await SubscribedAsync(commsLossConfirmMs: 0);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 1)).WaitAsync(Watchdog, Ct);
|
||||
seen.Last().Snapshot.StatusCode.ShouldBe(0u);
|
||||
|
||||
client.SampleFailure = Refused();
|
||||
client.EndStream(); // the connection drops; every reconnect is refused
|
||||
await WaitUntil(() => seen.Last().Snapshot.StatusCode == BadNoCommunication);
|
||||
driver.CommsLossMarked.ShouldBeTrue();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var badCount = seen.Count(e => e.Snapshot.StatusCode == BadNoCommunication);
|
||||
await Task.Delay(400, Ct); // more refused attempts: marked ONCE per outage
|
||||
seen.Count(e => e.Snapshot.StatusCode == BadNoCommunication).ShouldBe(badCount);
|
||||
|
||||
// The Agent is back. An observation-free heartbeat is enough: the UNCHANGED value comes back Good.
|
||||
client.SampleFailure = null;
|
||||
var calls = client.SampleCallCount;
|
||||
await client.WaitForSampleCallsAsync(calls + 1).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 1, 0)).WaitAsync(Watchdog, Ct);
|
||||
await WaitUntil(() => seen.Last().Snapshot.StatusCode == 0u);
|
||||
var recovered = seen.Last().Snapshot;
|
||||
recovered.Value.ShouldBe(108.0);
|
||||
driver.CommsLossMarked.ShouldBeFalse();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_ambiguous_failure_keeps_the_watchdog_contract()
|
||||
{
|
||||
var (driver, client, seen) = await SubscribedAsync(commsLossConfirmMs: 0);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 1)).WaitAsync(Watchdog, Ct);
|
||||
client.SampleFailure = new TimeoutException("the Agent did not answer /sample in time");
|
||||
var calls = client.SampleCallCount;
|
||||
client.EndStream();
|
||||
await client.WaitForSampleCallsAsync(calls + 3).WaitAsync(Watchdog, Ct);
|
||||
seen.ShouldNotContain(e => e.Snapshot.StatusCode == BadNoCommunication);
|
||||
driver.CommsLossMarked.ShouldBeFalse();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded); // the watchdog still has its evidence
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(60_000)] // a confirmation window not yet elapsed
|
||||
[InlineData(-1)] // the fast path disabled
|
||||
public async Task Refusals_inside_the_confirmation_window_or_with_the_fast_path_off_mark_nothing(int confirmMs)
|
||||
{
|
||||
var (driver, client, seen) = await SubscribedAsync(commsLossConfirmMs: confirmMs);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 1)).WaitAsync(Watchdog, Ct);
|
||||
client.SampleFailure = Refused();
|
||||
var calls = client.SampleCallCount;
|
||||
client.EndStream();
|
||||
await client.WaitForSampleCallsAsync(calls + 3).WaitAsync(Watchdog, Ct);
|
||||
seen.ShouldNotContain(e => e.Snapshot.StatusCode == BadNoCommunication);
|
||||
driver.CommsLossMarked.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_short_confirmation_window_marks_once_the_refusals_have_lasted_it()
|
||||
{
|
||||
var (driver, client, seen) = await SubscribedAsync(commsLossConfirmMs: 300);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 1)).WaitAsync(Watchdog, Ct);
|
||||
client.SampleFailure = Refused();
|
||||
var started = DateTime.UtcNow;
|
||||
client.EndStream();
|
||||
await WaitUntil(() => driver.CommsLossMarked, timeoutMs: 5000);
|
||||
(DateTime.UtcNow - started).ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(280));
|
||||
seen.Last().Snapshot.StatusCode.ShouldBe(BadNoCommunication);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the raised-count ceiling vs the Agent's bufferSize ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, 0, 16_000)] // unknown buffer: the 16× rule
|
||||
[InlineData(1000, 4096, 4096)] // a known buffer caps it
|
||||
[InlineData(1000, 131_072, 16_000)] // a big buffer changes nothing
|
||||
[InlineData(1000, 500, 1000)] // never below the configured count (#825 warns about that config)
|
||||
[InlineData(10_000, 16_384, 16_384)]
|
||||
public void The_count_ceiling_never_exceeds_the_agents_buffer(int configured, long bufferSize, int expected) =>
|
||||
MTConnectDriver.SampleCountCeiling(configured, bufferSize).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task A_behind_stream_stops_raising_at_the_agents_buffer_size()
|
||||
{
|
||||
var (driver, client, _) = await SubscribedAsync(commsLossConfirmMs: 0, sampleCount: 2, bufferSize: 5);
|
||||
try
|
||||
{
|
||||
var next = Primed;
|
||||
async Task Behind(int count)
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await client.PumpAsync(Chunk(next, count, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
next += count;
|
||||
}
|
||||
}
|
||||
|
||||
await Behind(2);
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
await Behind(4);
|
||||
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ToArray().ShouldBe(new[] { 2, 4, 5 }, "the raise is clamped to bufferSize 5, never 8");
|
||||
|
||||
await Behind(5); // at the ceiling: still behind, but not reopened
|
||||
await Task.Delay(200, Ct);
|
||||
client.SampleCallCount.ShouldBe(3);
|
||||
driver.GetDiagnostics()["sampleCount"].ShouldBe("5");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,4 +122,27 @@ public sealed class HeidenhainAuthoringTests
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
HeidenhainTagConfigModel.FromJson(json).Address.ShouldBe(address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#839: every <c>ALARM:</c> selector the browse picker offers commits as a complete condition tag — the
|
||||
/// paired <c>alarm</c> object seeded, Boolean, accepted by the driver's own factory and the editor's validator —
|
||||
/// so a browse-commit never lands a tag the deploy gate would refuse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Browse_committed_alarm_selectors_are_complete_conditions_the_driver_accepts()
|
||||
{
|
||||
HeidenhainAlarmClasses.BrowseSelectors.Count.ShouldBeGreaterThan(2);
|
||||
foreach (var (_, name, address) in HeidenhainAlarmClasses.BrowseSelectors)
|
||||
{
|
||||
var row = RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.Heidenhain, address, name, nameof(DriverDataType.Boolean), "String",
|
||||
groupPrefix: null, folderPath: null, createGroups: false);
|
||||
row.Tag.DataType.ShouldBe("Boolean");
|
||||
var json = row.Tag.TagConfig!;
|
||||
JsonNode.Parse(json)!["alarm"].ShouldNotBeNull(address);
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out var error).ShouldBeTrue(error);
|
||||
def!.IsAlarmCondition.ShouldBeTrue();
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -123,6 +123,37 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#839 — a classifying native source's Part 9 ConditionClass lands on the node, a later snapshot
|
||||
/// without one (a quality-only annotation, a non-classifying path) keeps it, and a rebuild carries it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_driver_assigned_condition_class_is_applied_kept_and_carried_across_a_rebuild()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
Condition(nm).ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { ConditionClass = ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmConditionClass.Safety },
|
||||
DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
Condition(nm).ConditionClassId!.Value.ShouldBe(ObjectTypeIds.SafetyConditionClassType);
|
||||
Condition(nm).ConditionClassName!.Value.Text.ShouldBe("SafetyConditionClass");
|
||||
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { StatusCode = OpcUaStatus.Bad }, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
Condition(nm).ConditionClassId!.Value.ShouldBe(ObjectTypeIds.SafetyConditionClassType);
|
||||
|
||||
// Even a NORMAL condition keeps its class across a rebuild — the class is not state.
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: true) with { Active = false }, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
Condition(nm).ConditionClassId!.Value.ShouldBe(ObjectTypeIds.SafetyConditionClassType);
|
||||
Condition(nm).ConditionClassName!.Value.Text.ShouldBe("SafetyConditionClass");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
|
||||
/// materialise builds it, and reports nothing.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
|
||||
@@ -24,6 +24,26 @@ public class NativeAlarmProjectorTests
|
||||
snap.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#839 — a classifying source's ConditionClass rides the snapshot; an event without one (a
|
||||
/// quality-only annotation, a Clear) keeps the class the condition last carried, and the default is null
|
||||
/// (= leave the node's class alone).</summary>
|
||||
[Fact]
|
||||
public void Condition_class_is_carried_and_kept_until_restated()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise)).ConditionClass.ShouldBeNull();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger) with { ConditionClass = AlarmConditionClass.System })
|
||||
.ConditionClass.ShouldBe(AlarmConditionClass.System);
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = 0x80310000u })
|
||||
.ConditionClass.ShouldBe(AlarmConditionClass.System);
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear)).ConditionClass.ShouldBe(AlarmConditionClass.System);
|
||||
sut.MarkAcknowledged("n1");
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise) with { ConditionClass = AlarmConditionClass.Safety });
|
||||
sut.MarkAcknowledged("n1");
|
||||
sut.TryRevertAcknowledge("n1", out var reverted).ShouldBeTrue();
|
||||
reverted!.ConditionClass.ShouldBe(AlarmConditionClass.Safety);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
|
||||
/// one's (which then starts from the default inactive + acked).</summary>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user