From ddc1f29e3ae4902f425c9441a6eac67bdfb4d45b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 03:41:14 -0500 Subject: [PATCH 1/2] feat(heidenhain): Part 9 condition class from alarm class/group, ALARM: selectors in the browse picker, unclaimed alarms counted + logged (#839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core: optional AlarmEventArgs.ConditionClass (AlarmConditionClass Base/Process/Maintenance/System/Safety) carried by NativeAlarmProjector on AlarmConditionSnapshot and written to ConditionClassId/Name by OtOpcUaNodeManager (kept across class-less events, carried across a #641 rebuild). - Heidenhain: HeidenhainAlarmClasses — HEIDENHAIN DNC-interface class/group names (corroborated by the captured iTNC 530 record); class 5 => Safety, group 1/2 => Process, 4/5 => System, else Base. - DiscoverAsync offers AlarmConditions/ selector leaves (IsAlarm Boolean); browse-commit seeds the alarm object. - Alarms no selector claims: otopcua.driver.alarms.unmatched counter, /hosts diagnostics, rate-limited Warning. Refs #839 --- .claude/rules/drivers.md | 7 +- docs/AlarmTracking.md | 21 ++- docs/drivers/Heidenhain.md | 47 ++++++- .../Observability/OtOpcUaTelemetry.cs | 11 ++ .../OpcUa/AlarmConditionSnapshot.cs | 10 +- .../IAlarmSource.cs | 33 ++++- .../HeidenhainAlarmClasses.cs | 98 ++++++++++++++ .../HeidenhainAlarmSelector.cs | 7 +- .../HeidenhainAlarmProjection.cs | 99 +++++++++++++- .../HeidenhainDriver.cs | 26 +++- .../OtOpcUaNodeManager.cs | 44 +++++- .../Drivers/NativeAlarmProjector.cs | 14 +- .../HeidenhainAlarmTests.cs | 125 ++++++++++++++++++ .../HeidenhainDriverTests.cs | 16 ++- .../Uns/HeidenhainAuthoringTests.cs | 23 ++++ ...ManagerNativeAlarmRebuildCarryOverTests.cs | 31 +++++ .../Drivers/NativeAlarmProjectorTests.cs | 20 +++ 17 files changed, 593 insertions(+), 39 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmClasses.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index aec25c30f..a92a559fa 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -337,7 +337,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` `.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 diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 11bb3b9a0..9502a047b 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -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 `"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 diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 703a3cd4f..dece3503d 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -177,10 +177,34 @@ MTConnect #626. | `ALARM:CLASS=[/GROUP=][/NUMBER=]` (any subset, any order, each key once) | an alarm matches every named field | `` 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 `.alarms.unmatched` (unclaimed on + the last good walk) and `.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_` + (`ALARM:CLASS=n`, classes 1–9) and `ByGroup/Group_` (`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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs index 2d31f9734..67524d267 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs @@ -86,6 +86,17 @@ public static class OtOpcUaTelemetry Meter.CreateCounter("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)."); + /// + /// 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 ALARM: selectors without */OTHER). + /// Counted once per alarm occurrence (an alarm entering the unclaimed set), not once per poll. Tagged + /// + . A non-zero rate means + /// operators are not seeing some controller alarms — add a selector (or an OTHER catch-all). + /// + public static readonly Counter DriverAlarmsUnmatched = + Meter.CreateCounter("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 VirtualTagEval = diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs index e1813824e..7e2cfbd44 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// @@ -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. /// +/// +/// The Part 9 ConditionClass a native source assigned (lmxopcua#839), or null 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. +/// 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); /// /// Commons-local mirror of the Core ShelvingKind enum so this assembly carries no diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 707a40fa7..bdb82eb05 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -122,6 +122,13 @@ public sealed record AlarmAcknowledgeRequest( /// that writes nothing and a host retaining it Active gets a real "Cleared". false (the default, every source that does not /// opt in) keeps every transition's row. /// +/// +/// Optional Part 9 ConditionClass 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 ConditionClassId / ConditionClassName; null (every other +/// source) leaves the node's class untouched — BaseConditionClass ("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. +/// 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) { /// /// 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. /// public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged } + +/// +/// The OPC UA Part 9 condition classes (BaseConditionClassType subtypes, Part 9 §5.9) a driver can assign to a +/// native condition via (lmxopcua#839). Only the classes a driver has +/// evidence for are listed; is Part 9's "no class modelled". Members are append-only. +/// +public enum AlarmConditionClass +{ + /// BaseConditionClassType — the class is not modelled / not known. + Base = 0, + + /// ProcessConditionClassType — the condition relates to the process the system controls. + Process, + + /// MaintenanceConditionClassType — the condition relates to maintenance. + Maintenance, + + /// SystemConditionClassType — the condition relates to the system itself (hardware, software, comms). + System, + + /// SafetyConditionClassType — the condition relates to safety. + Safety, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmClasses.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmClasses.cs new file mode 100644 index 000000000..00b6275b7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmClasses.cs @@ -0,0 +1,98 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// +/// Names for the class / group codes of a Heidenhain control's active-alarm list, and the Part 9 +/// ConditionClass each maps to (lmxopcua#839). +/// +/// +/// Provenance. 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 +/// DNC_ERROR_CLASS, DNC_ERROR_GROUP), which describe the same control error list. That the LSV2 +/// R_RI 27/28 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 (INFO), group 4 (GENERAL), 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 alarm.severity, else High, still decides that). +/// Part 9 mapping — only where the code says where the alarm comes from. +/// EMERGENCY_STOP (class 5) is a safety reaction ⇒ , whatever its +/// group. Otherwise the GROUP decides: OPERATING / PROGRAMMING (machining and NC-program errors) ⇒ +/// ; GENERAL / REMOTE (the control's own system and +/// remote-interface messages) ⇒ . PLC messages are written by the +/// machine builder and can be process, maintenance or safety alike, and PYTHON / unknown codes say nothing +/// ⇒ ("not modelled"). Nothing maps to Maintenance: no code is evidence of +/// it. +/// +public static class HeidenhainAlarmClasses +{ + /// The emergency-stop error class. + public const int EmergencyStopClass = 5; + + /// Class code → HEIDENHAIN DNC name (DNC_ERROR_CLASS). + public static IReadOnlyDictionary ClassNames { get; } = new Dictionary + { + [0] = "None", + [1] = "Warning", + [2] = "FeedHold", + [3] = "ProgramHold", + [4] = "ProgramAbort", + [5] = "EmergencyStop", + [6] = "Reset", + [7] = "Info", + [8] = "Error", + [9] = "Note", + }; + + /// Group code → HEIDENHAIN DNC name (DNC_ERROR_GROUP). + public static IReadOnlyDictionary GroupNames { get; } = new Dictionary + { + [0] = "None", + [1] = "Operating", + [2] = "Programming", + [3] = "Plc", + [4] = "General", + [5] = "Remote", + [6] = "Python", + }; + + /// The browse-picker folder holding the ALARM: selectors (lmxopcua#839). + public const string BrowseFolder = "AlarmConditions"; + + /// + /// The ALARM: selectors the /raw browse picker offers (lmxopcua#839), as (sub-folder or null, leaf + /// name, address): * and OTHER at the folder root, then one CLASS=n per named class + /// (ByClass/) and one GROUP=n per named group (ByGroup/), code 0 ("None") excluded. The leaf + /// name carries the code AND the DNC name (Class5_EmergencyStop) so a committed tag still says which + /// number it filters on. Any other selector (ranges, NUMBER, combinations) is typed by hand as before. + /// + 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; + } + + /// The Part 9 condition class of one controller alarm (see the remarks for the evidence). + /// The alarm's class code. + /// The alarm's group code. + /// The class; when the codes say nothing. + 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, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs index 7fb8c0823..70dd800e4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAlarmSelector.cs @@ -51,9 +51,10 @@ public readonly record struct HeidenhainAlarmRange(long Low, long High) /// A range <r> is n or lo..hi (inclusive; .. rather than - because /// alarm numbers can be negative — a real iTNC 530 reports -1). CLASS and GROUP are the wire's uint16 /// fields (0–65535); NUMBER is int32. -/// No semantic names. 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 Alarms/Classes / Alarms/Groups or in a condition message, then filter. +/// Numeric codes only. The grammar takes raw codes, never names: LSV2 itself does not document them. +/// 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 Alarms/Classes / Alarms/Groups or in a condition message. /// One authority. Backs the AdminUI editor, the deploy inspector and the driver's table build. /// /// The selector kind. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs index 53e6c7c86..b73630f44 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainAlarmProjection.cs @@ -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 = 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 sent exactly (OpcUaSeverity); -/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no -/// class→severity table is invented. +/// otherwise the bucket is High and no exact value is sent. No class→severity table is invented. +/// Condition class (lmxopcua#839): a Raise / Retrigger carries the DISPLAYED alarm's Part 9 class +/// (); a Clear or a quality-only event carries none, so the +/// node keeps the class of the alarm it last showed. /// /// 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); } /// @@ -154,8 +160,14 @@ internal static class HeidenhainAlarmRules /// that follows still sees the difference. Delivery runs under ; the only production handler /// is DriverInstanceActor's non-blocking Tell. /// -internal sealed class HeidenhainAlarmProjection(object sender, Func?> handlers, ILogger logger) +internal sealed class HeidenhainAlarmProjection( + object sender, Func?> handlers, ILogger logger, string driverInstanceId = "") { + /// The shortest gap between two "unclaimed alarms" log lines for one control (lmxopcua#839). + internal static readonly TimeSpan DefaultUnmatchedLogInterval = TimeSpan.FromMinutes(1); + + private readonly Dictionary _unmatched = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); private readonly Dictionary _told = new(StringComparer.Ordinal); private readonly Dictionary _qualityTold = new(StringComparer.Ordinal); @@ -226,6 +238,8 @@ internal sealed class HeidenhainAlarmProjection(object sender, FuncThe gap between two "unclaimed alarms" log lines per control (test seam). + internal TimeSpan UnmatchedLogInterval { get; set; } = DefaultUnmatchedLogInterval; + + /// + /// 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 /hosts via + /// . + /// + /// Host → (current, total). + public IReadOnlyDictionary UnmatchedSnapshot() + { + lock (_lock) + return _unmatched.ToDictionary(kv => kv.Key, kv => (kv.Value.Keys.Count, kv.Value.Total), StringComparer.Ordinal); + } + + /// + /// Controller alarms that no configured ALARM: selector on the control claims are shown by NO condition + /// (lmxopcua#839 — they used to vanish silently). A control with a * or OTHER 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 , + /// naming up to three alarms and how many more were suppressed since the previous line. Must run under + /// . + /// + private void TrackUnmatched(string hostName, List deviceTags, IReadOnlyList 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() + : 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(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.Heidenhain), + new KeyValuePair(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 Keys { get; set; } = []; + public long Total { get; set; } + public DateTime? LastLogUtc { get; set; } + public long PendingLog { get; set; } + public List? PendingSample { get; set; } + } + private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now) { _told.TryGetValue(tag.RawPath, out var told); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 8f5980ac8..77293840c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -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); } /// @@ -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 /// /// /// 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 AlarmConditions/ folder of ALARM: selectors + /// (lmxopcua#839). PLC/DATA bindings are authored by hand: LSV2 has no proven universal /// symbol model and this driver performs no memory scanning. /// 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(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; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 62489d97c..c4f84ac0a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -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 } } + /// + /// The Part 9 class type id + browse name of an (lmxopcua#839). + /// + /// The driver-assigned class. + /// The ConditionClassId / ConditionClassName pair to write. + 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); + } + /// /// The shelvingTime (ms) to hand SetShelvingState for a timed shelve expiring at /// . 0 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 /// Who left that comment, if recorded. 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); /// /// 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); /// /// 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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index e93bfa64f..47f04db98 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// public sealed class NativeAlarmProjector { - private readonly Dictionary _prior = + private readonly Dictionary _prior = new(StringComparer.Ordinal); /// @@ -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); } /// @@ -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; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs index 91fb89ebc..a1cdad3f9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainAlarmTests.cs @@ -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 state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception? exception, Func 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 ---- /// A control whose active-alarm list can be changed between walks (27 = first, 28 = next, 57 = end). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs index c69b67a22..6bd78288d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainDriverTests.cs @@ -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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs index 0c2c5262d..70c7c0a27 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs @@ -122,4 +122,27 @@ public sealed class HeidenhainAuthoringTests TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull(); HeidenhainTagConfigModel.FromJson(json).Address.ShouldBe(address); } + + /// + /// lmxopcua#839: every ALARM: selector the browse picker offers commits as a complete condition tag — the + /// paired alarm 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. + /// + [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(); + } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs index fc34aa95e..bfd4d7693 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs @@ -123,6 +123,37 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable await host.DisposeAsync(); } + /// + /// 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. + /// + [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(); + } + /// Falsification control: a condition in the no-event position comes back exactly as a fresh /// materialise builds it, and reports nothing. [Trait("Category", "Unit")] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index c3deefbd1..edb69b6d7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -24,6 +24,26 @@ public class NativeAlarmProjectorTests snap.Message.ShouldBe("boom"); } + /// 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). + [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); + } + /// Gitea #641 — RetainOnly keeps a still-deployed condition's prior state and forgets a removed /// one's (which then starts from the default inactive + acked). [Fact] From 19ffb3c9026174786e0f0b4813cf424941575593 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 03:48:05 -0500 Subject: [PATCH 2/2] feat(mtconnect): refused Agent connections mark values BadNoCommunication after a short confirmation; clamp the raised sample count to the Agent's bufferSize (#846) - A /sample or re-baseline failure whose chain holds SocketError.ConnectionRefused, unbroken for CommsLossConfirmMs (new, default 2000; 0 = first refusal; negative = off), publishes every subscribed value BadNoCommunication once per outage; the first delivery afterwards republishes all of them from the index (unchanged values included). Timeouts/resets/DNS/pause keep the #527 watchdog contract. - SampleCountCeiling clamps the adaptive raise to the /probe bufferSize (never below SampleCount). - Docs: MTConnect.md comms-loss section + config key; drivers.md rule. Refs #846 --- .claude/rules/drivers.md | 8 +- docs/drivers/MTConnect.md | 35 ++- .../MTConnectDriverOptions.cs | 9 + .../MTConnectDriver.CommsLoss.cs | 201 +++++++++++++++ .../MTConnectDriver.SampleLag.cs | 24 +- .../MTConnectDriver.cs | 26 +- .../MTConnectCommsLossTests.cs | 229 ++++++++++++++++++ 7 files changed, 522 insertions(+), 10 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectCommsLossTests.cs diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index a92a559fa..e4447b742 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -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 diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 222216b11..8242ca237 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -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`). | diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs index d46bfaf6f..18388cfbb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs @@ -63,6 +63,15 @@ public sealed class MTConnectDriverOptions /// public int HeartbeatMs { get; init; } = 10000; + /// + /// Gets how long, in milliseconds, the Agent must keep REFUSING connections (a definite loss — nothing is + /// listening at ) before every subscribed value is marked BadNoCommunication + /// (lmxopcua#846). Default 2000; 0 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. + /// + public int CommsLossConfirmMs { get; init; } = 2000; + /// /// The authored tags this driver instance serves — one /// per tag, keyed by the DataItem id it binds diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs new file mode 100644 index 000000000..f10a5ff13 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs @@ -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; + +/// +/// 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 +/// LastSuccessfulRead for 90 s) re-initialized the driver and the host fanned BadNoCommunication. +/// +/// +/// +/// Only a refusal is definite. A connection REFUSED ( anywhere +/// in the exception chain — what an HttpClient 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 +/// (docs/DriverLifecycle.md, .claude/rules/drivers.md #527/#676): those must never mark early. +/// +/// +/// Confirmed, not instant. Refusals must continue, unbroken, for +/// (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. +/// +/// +/// What marking does — the driver half of the quality contract. Every subscribed value is published +/// BadNoCommunication (value null) ONCE per outage; the observation index keeps the last values. Health +/// is already Degraded with LastSuccessfulRead 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 /current 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. +/// +/// +/// Recovery republishes everything. The first delivered chunk or re-baseline after a marked outage +/// republishes every subscribed value from the index — a resumed /sample only carries what CHANGED, so +/// an unchanged data item would otherwise stay Bad until it next moved (the (value, status) recovery rule). +/// +/// +public sealed partial class MTConnectDriver +{ + /// Canonical Opc.Ua.StatusCodes.BadNoCommunication. + private const uint BadNoCommunicationStatus = 0x80310000u; + + /// 1 while a confirmed refused-connection outage has marked the subscribed values Bad. + private int _commsLostMarked; + + /// Whether a refused-connection outage currently has the values marked Bad (test seam / diagnostics). + internal bool CommsLossMarked => Volatile.Read(ref _commsLostMarked) == 1; + + /// One pump's run of consecutive refused connections. Only that pump's task touches it. + private sealed class RefusalState + { + /// When the current unbroken run of refusals started, or null when there is none. + public DateTime? Since { get; set; } + + /// Ends the run (anything reached the Agent, or an ambiguous failure broke it). + public void Reset() => Since = null; + } + + /// + /// Whether proves nothing is listening at the Agent URI — a connection refused anywhere + /// in its chain. Every other failure is ambiguous (see the class remarks). + /// + /// The stream / re-baseline failure. + /// True for a definite loss. + 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; + } + + /// + /// Records one transient stream failure and, once refusals have persisted for the confirmation window, marks + /// every subscribed value BadNoCommunication (once per outage). + /// + /// when the pump's session has been replaced (nothing marked). + 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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); + } + + /// Publishes a value-less to every handle subscribing each reference. + private void FanOutStatus(IReadOnlyList 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); + } + } + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs index 1d130fbd1..860ad56fa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs @@ -79,9 +79,23 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics /// The ceiling a pump started with may raise its count to. /// The configured . - /// The ceiling — never below . - internal static int SampleCountCeiling(int configured) => - Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling)); + /// + /// The Agent's /probe bufferSize, or 0 when unknown. lmxopcua#846: an Agent refuses a + /// count above it (OUT_OF_RANGE; live, count == bufferSize is accepted), so a known buffer + /// caps the raised count — a raise past it would only buy a refused stream and a step-back. + /// + /// The ceiling — never below (a configured count above the buffer is the + /// #825 configuration warning's business, not something to raise further). + 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); + } /// public IReadOnlyDictionary GetDiagnostics() @@ -132,7 +146,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics } /// One pump's adaptive-count state. Only that pump's task touches it. - private sealed class SampleCountState(int configured) + private sealed class SampleCountState(int configured, long agentBufferSize) { /// The count the next stream opens with. public int Count { get; set; } = configured; @@ -141,7 +155,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics public int LastDelivered { get; set; } = configured; /// The most may be raised to; lowered to the last good count on a refusal. - public int Ceiling { get; set; } = SampleCountCeiling(configured); + public int Ceiling { get; set; } = SampleCountCeiling(configured, agentBufferSize); /// Whether the at-the-ceiling Warning has been logged for this pump. public bool CeilingWarned { get; set; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 8cc6566a1..b8b53c157 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -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; } /// The /sample heartbeat in milliseconds. public int? HeartbeatMs { get; init; } + /// How long refused connections must persist before values go BadNoCommunication (#846). + public int? CommsLossConfirmMs { get; init; } /// The authored tags. public List? Tags { get; init; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectCommsLossTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectCommsLossTests.cs new file mode 100644 index 000000000..6ba8f3659 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectCommsLossTests.cs @@ -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; + +/// +/// lmxopcua#846 — a REFUSED Agent connection is a definite loss: after CommsLossConfirmMs of nothing but +/// refusals every subscribed value goes BadNoCommunication (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 bufferSize. +/// +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 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(); + 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 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); + } + } +}