Merge #839 #846 Heidenhain alarms + MTConnect comms loss

This commit is contained in:
Joseph Doherty
2026-11-11 03:50:08 -05:00
23 changed files with 1115 additions and 49 deletions
@@ -86,6 +86,17 @@ public static class OtOpcUaTelemetry
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|keepalive_recovered|stale_notification_dropped).");
/// <summary>
/// Controller alarms a native-alarm driver saw that NO configured alarm selector on that device claims, so no
/// Part 9 condition shows them (lmxopcua#839 — Heidenhain <c>ALARM:</c> selectors without <c>*</c>/<c>OTHER</c>).
/// Counted once per alarm occurrence (an alarm entering the unclaimed set), not once per poll. Tagged
/// <see cref="PollOverrunDriverTypeTag"/> + <see cref="PollOverrunDriverInstanceTag"/>. A non-zero rate means
/// operators are not seeing some controller alarms — add a selector (or an <c>OTHER</c> catch-all).
/// </summary>
public static readonly Counter<long> DriverAlarmsUnmatched =
Meter.CreateCounter<long>("otopcua.driver.alarms.unmatched", unit: "{alarm}",
description: "Controller alarm occurrences no configured native-alarm selector claims (shown by no condition).");
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
public static readonly Counter<long> VirtualTagEval =
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
@@ -30,6 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// time instead of the SDK's ~24.8-day ceiling. A timed snapshot without it (a caller that
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
/// </param>
/// <param name="ConditionClass">
/// The Part 9 <c>ConditionClass</c> a native source assigned (lmxopcua#839), or <c>null</c> to leave the node's class
/// as it is (scripted alarms and every native source that does not classify). Not part of the event delta-gate: a
/// class rides along with the transition that set it.
/// </param>
public sealed record AlarmConditionSnapshot(
bool Active,
bool Acknowledged,
@@ -39,7 +46,8 @@ public sealed record AlarmConditionSnapshot(
ushort Severity,
string Message,
uint StatusCode = OpcUaStatus.Good,
DateTime? UnshelveAtUtc = null);
DateTime? UnshelveAtUtc = null,
AlarmConditionClass? ConditionClass = null);
/// <summary>
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
@@ -122,6 +122,13 @@ public sealed record AlarmAcknowledgeRequest(
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
/// opt in) keeps every transition's row.
/// </param>
/// <param name="ConditionClass">
/// Optional Part 9 <c>ConditionClass</c> of the condition (lmxopcua#839), for a source whose upstream classifies its
/// alarms in a way that maps onto the Part 9 classes with evidence (Heidenhain's error class / group). When set, the
/// host writes it to the condition's <c>ConditionClassId</c> / <c>ConditionClassName</c>; <c>null</c> (every other
/// source) leaves the node's class untouched — <c>BaseConditionClass</c> ("not modelled") unless an earlier event of
/// the same condition set one. A pure annotation: it never moves Active/Acked state and never fires an event on its own.
/// </param>
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
@@ -136,7 +143,8 @@ public sealed record AlarmEventArgs(
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
int? OpcUaSeverity = null,
uint? QualityStatusCode = null,
bool IsRestatement = false)
bool IsRestatement = false,
AlarmConditionClass? ConditionClass = null)
{
/// <summary>
/// True when this event only re-annotates the condition's source-data quality — a
@@ -163,3 +171,26 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
/// Members are append-only: their numeric values must not move.
/// </summary>
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged }
/// <summary>
/// The OPC UA Part 9 condition classes (<c>BaseConditionClassType</c> subtypes, Part 9 §5.9) a driver can assign to a
/// native condition via <see cref="AlarmEventArgs.ConditionClass"/> (lmxopcua#839). Only the classes a driver has
/// evidence for are listed; <see cref="Base"/> is Part 9's "no class modelled". Members are append-only.
/// </summary>
public enum AlarmConditionClass
{
/// <summary><c>BaseConditionClassType</c> — the class is not modelled / not known.</summary>
Base = 0,
/// <summary><c>ProcessConditionClassType</c> — the condition relates to the process the system controls.</summary>
Process,
/// <summary><c>MaintenanceConditionClassType</c> — the condition relates to maintenance.</summary>
Maintenance,
/// <summary><c>SystemConditionClassType</c> — the condition relates to the system itself (hardware, software, comms).</summary>
System,
/// <summary><c>SafetyConditionClassType</c> — the condition relates to safety.</summary>
Safety,
}
@@ -0,0 +1,98 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// Names for the class / group codes of a Heidenhain control's active-alarm list, and the Part 9
/// <c>ConditionClass</c> each maps to (lmxopcua#839).
/// </summary>
/// <remarks>
/// <para><b>Provenance.</b> LSV2 itself documents neither field (pyLSV2 and the ignitionoee Java port keep them
/// raw). The names are HEIDENHAIN's own error enums from the DNC interface (RemoTools SDK / HeidenhainDNC
/// <c>DNC_ERROR_CLASS</c>, <c>DNC_ERROR_GROUP</c>), which describe the same control error list. That the LSV2
/// <c>R_RI 27/28</c> codes use the same numbering is corroborated by the one real record we hold — the captured
/// iTNC 530 alarm "Run test of cutout channels" is class 7 (<c>INFO</c>), group 4 (<c>GENERAL</c>), a power-up
/// self-test notice — but is not otherwise proven on hardware. Treat the names as labels, never as behaviour:
/// they drive no selection and no severity (an authored <c>alarm.severity</c>, else High, still decides that).</para>
/// <para><b>Part 9 mapping — only where the code says where the alarm comes from.</b>
/// <c>EMERGENCY_STOP</c> (class 5) is a safety reaction ⇒ <see cref="AlarmConditionClass.Safety"/>, whatever its
/// group. Otherwise the GROUP decides: <c>OPERATING</c> / <c>PROGRAMMING</c> (machining and NC-program errors) ⇒
/// <see cref="AlarmConditionClass.Process"/>; <c>GENERAL</c> / <c>REMOTE</c> (the control's own system and
/// remote-interface messages) ⇒ <see cref="AlarmConditionClass.System"/>. <c>PLC</c> messages are written by the
/// machine builder and can be process, maintenance or safety alike, and <c>PYTHON</c> / unknown codes say nothing
/// ⇒ <see cref="AlarmConditionClass.Base"/> ("not modelled"). Nothing maps to Maintenance: no code is evidence of
/// it.</para>
/// </remarks>
public static class HeidenhainAlarmClasses
{
/// <summary>The emergency-stop error class.</summary>
public const int EmergencyStopClass = 5;
/// <summary>Class code → HEIDENHAIN DNC name (<c>DNC_ERROR_CLASS</c>).</summary>
public static IReadOnlyDictionary<int, string> ClassNames { get; } = new Dictionary<int, string>
{
[0] = "None",
[1] = "Warning",
[2] = "FeedHold",
[3] = "ProgramHold",
[4] = "ProgramAbort",
[5] = "EmergencyStop",
[6] = "Reset",
[7] = "Info",
[8] = "Error",
[9] = "Note",
};
/// <summary>Group code → HEIDENHAIN DNC name (<c>DNC_ERROR_GROUP</c>).</summary>
public static IReadOnlyDictionary<int, string> GroupNames { get; } = new Dictionary<int, string>
{
[0] = "None",
[1] = "Operating",
[2] = "Programming",
[3] = "Plc",
[4] = "General",
[5] = "Remote",
[6] = "Python",
};
/// <summary>The browse-picker folder holding the <c>ALARM:</c> selectors (lmxopcua#839).</summary>
public const string BrowseFolder = "AlarmConditions";
/// <summary>
/// The <c>ALARM:</c> selectors the <c>/raw</c> browse picker offers (lmxopcua#839), as (sub-folder or null, leaf
/// name, address): <c>*</c> and <c>OTHER</c> at the folder root, then one <c>CLASS=n</c> per named class
/// (<c>ByClass/</c>) and one <c>GROUP=n</c> per named group (<c>ByGroup/</c>), code 0 ("None") excluded. The leaf
/// name carries the code AND the DNC name (<c>Class5_EmergencyStop</c>) so a committed tag still says which
/// number it filters on. Any other selector (ranges, NUMBER, combinations) is typed by hand as before.
/// </summary>
public static IReadOnlyList<(string? SubFolder, string Name, string Address)> BrowseSelectors { get; } = BuildBrowseSelectors();
private static List<(string?, string, string)> BuildBrowseSelectors()
{
var list = new List<(string?, string, string)>
{
(null, "AnyAlarm", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.AnyToken),
(null, "OtherAlarms", HeidenhainAlarmSelector.Prefix + HeidenhainAlarmSelector.OtherToken),
};
foreach (var (code, name) in ClassNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
list.Add(("ByClass", $"Class{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}CLASS={code}"));
foreach (var (code, name) in GroupNames.Where(kv => kv.Key != 0).OrderBy(kv => kv.Key))
list.Add(("ByGroup", $"Group{code}_{name}", $"{HeidenhainAlarmSelector.Prefix}GROUP={code}"));
return list;
}
/// <summary>The Part 9 condition class of one controller alarm (see the remarks for the evidence).</summary>
/// <param name="errorClass">The alarm's class code.</param>
/// <param name="group">The alarm's group code.</param>
/// <returns>The class; <see cref="AlarmConditionClass.Base"/> when the codes say nothing.</returns>
public static AlarmConditionClass ConditionClassOf(int errorClass, int group)
{
if (errorClass == EmergencyStopClass) return AlarmConditionClass.Safety;
return group switch
{
1 or 2 => AlarmConditionClass.Process,
4 or 5 => AlarmConditionClass.System,
_ => AlarmConditionClass.Base,
};
}
}
@@ -51,9 +51,10 @@ public readonly record struct HeidenhainAlarmRange(long Low, long High)
/// <para>A range <c>&lt;r&gt;</c> is <c>n</c> or <c>lo..hi</c> (inclusive; <c>..</c> rather than <c>-</c> because
/// alarm numbers can be negative — a real iTNC 530 reports <c>-1</c>). CLASS and GROUP are the wire's uint16
/// fields (0–65535); NUMBER is int32.</para>
/// <para><b>No semantic names.</b> The meaning of the class/group codes is not documented in the reference
/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed:
/// observe the raw values on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message, then filter.</para>
/// <para><b>Numeric codes only.</b> The grammar takes raw codes, never names: LSV2 itself does not document them.
/// <see cref="HeidenhainAlarmClasses"/> carries HEIDENHAIN's DNC-interface names for labels (the browse picker) and
/// the Part 9 condition class (lmxopcua#839), but a selector always filters on the number the wire carries —
/// observe it on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message.</para>
/// <para><b>One authority.</b> Backs the AdminUI editor, the deploy inspector and the driver's table build.</para>
/// </remarks>
/// <param name="Kind">The selector kind.</param>
@@ -1,5 +1,6 @@
using System.Text;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
@@ -45,8 +46,10 @@ internal sealed record HeidenhainToldCondition(
/// displayed alarm leaves while others remain = <c>Retrigger</c> (un-acknowledges: a statement the operator
/// has not seen); a non-displayed member leaves = nothing; nothing matches = <c>Clear</c>.</item>
/// <item><b>Severity</b>: an authored integer <c>alarm.severity</c> is sent exactly (<c>OpcUaSeverity</c>);
/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no
/// class→severity table is invented.</item>
/// otherwise the bucket is High and no exact value is sent. No class→severity table is invented.</item>
/// <item><b>Condition class</b> (lmxopcua#839): a Raise / Retrigger carries the DISPLAYED alarm's Part 9 class
/// (<see cref="HeidenhainAlarmClasses.ConditionClassOf"/>); a Clear or a quality-only event carries none, so the
/// node keeps the class of the alarm it last showed.</item>
/// </list>
/// </remarks>
internal static class HeidenhainAlarmRules
@@ -90,10 +93,11 @@ internal static class HeidenhainAlarmRules
var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null);
var message = MessageOf(matched);
var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact);
var conditionClass = HeidenhainAlarmClasses.ConditionClassOf(winner.Class, winner.Group);
if (told is not { Active: true })
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised);
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact, conditionClass: conditionClass), raised);
if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner)
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised);
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact, conditionClass: conditionClass), raised);
return (null, told with { Keys = keys });
}
@@ -119,7 +123,8 @@ internal static class HeidenhainAlarmRules
}
public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc,
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) =>
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null,
AlarmConditionClass? conditionClass = null) =>
new(handle,
SourceNodeId: tag.HostName,
ConditionId: tag.RawPath,
@@ -129,7 +134,8 @@ internal static class HeidenhainAlarmRules
SourceTimestampUtc: nowUtc,
Kind: kind,
OpcUaSeverity: exact,
QualityStatusCode: quality);
QualityStatusCode: quality,
ConditionClass: conditionClass);
}
/// <summary>
@@ -154,8 +160,14 @@ internal static class HeidenhainAlarmRules
/// that follows still sees the difference. Delivery runs under <see cref="_lock"/>; the only production handler
/// is <c>DriverInstanceActor</c>'s non-blocking <c>Tell</c>.</para>
/// </remarks>
internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger)
internal sealed class HeidenhainAlarmProjection(
object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger, string driverInstanceId = "")
{
/// <summary>The shortest gap between two "unclaimed alarms" log lines for one control (lmxopcua#839).</summary>
internal static readonly TimeSpan DefaultUnmatchedLogInterval = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, UnmatchedState> _unmatched = new(StringComparer.Ordinal);
private readonly Lock _lock = new();
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
@@ -226,6 +238,8 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName);
}
if (status == HeidenhainStatus.Good && alarms is not null) TrackUnmatched(hostName, deviceTags, alarms, now);
var h = handlers();
if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again
@@ -255,6 +269,77 @@ internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler
}
}
/// <summary>The gap between two "unclaimed alarms" log lines per control (test seam).</summary>
internal TimeSpan UnmatchedLogInterval { get; set; } = DefaultUnmatchedLogInterval;
/// <summary>
/// Per control: how many alarms on its last good walk no configured selector claims, and how many unclaimed
/// occurrences were seen since the driver started (lmxopcua#839). Published on <c>/hosts</c> via
/// <see cref="HeidenhainDriver.GetDiagnostics"/>.
/// </summary>
/// <returns>Host → (current, total).</returns>
public IReadOnlyDictionary<string, (int Current, long Total)> UnmatchedSnapshot()
{
lock (_lock)
return _unmatched.ToDictionary(kv => kv.Key, kv => (kv.Value.Keys.Count, kv.Value.Total), StringComparer.Ordinal);
}
/// <summary>
/// Controller alarms that no configured <c>ALARM:</c> selector on the control claims are shown by NO condition
/// (lmxopcua#839 — they used to vanish silently). A control with a <c>*</c> or <c>OTHER</c> selector claims every
/// alarm. Each alarm ENTERING the unclaimed set counts once (metric + total), so a standing alarm is not
/// re-counted every walk; the log is rate-limited per control to one line per <see cref="UnmatchedLogInterval"/>,
/// naming up to three alarms and how many more were suppressed since the previous line. Must run under
/// <see cref="_lock"/>.
/// </summary>
private void TrackUnmatched(string hostName, List<HeidenhainAlarmTag> deviceTags, IReadOnlyList<Lsv2Alarm> alarms, DateTime now)
{
if (!_unmatched.TryGetValue(hostName, out var state)) _unmatched[hostName] = state = new UnmatchedState();
var claimsAll = deviceTags.Any(t => t.Selector.Kind != HeidenhainAlarmSelectorKind.Filter);
var current = claimsAll
? new HashSet<HeidenhainAlarmKey>()
: alarms.Where(a => !deviceTags.Any(t => t.Selector.Matches(a.ErrorClass, a.Group, a.Number)))
.Select(HeidenhainAlarmKey.Of).ToHashSet();
var entered = current.Where(k => !state.Keys.Contains(k))
.OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number).ThenBy(k => k.Message, StringComparer.Ordinal)
.ToList();
state.Keys = current;
if (entered.Count == 0) return;
state.Total += entered.Count;
OtOpcUaTelemetry.DriverAlarmsUnmatched.Add(entered.Count,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.Heidenhain),
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId));
state.PendingLog += entered.Count;
if (state.LastLogUtc is { } last && now - last < UnmatchedLogInterval)
{
state.PendingSample ??= entered;
return;
}
var sample = state.PendingSample is { } earlier ? earlier.Concat(entered).Distinct().ToList() : entered;
var suppressed = state.PendingLog - entered.Count;
logger.LogWarning(
"Heidenhain {Host}: {Count} controller alarm(s) match no ALARM: selector on this control and are shown by no condition " +
"(e.g. {Examples}){Suppressed}; add a selector or an ALARM:OTHER catch-all to surface them",
hostName, entered.Count, string.Join("; ", sample.Take(3).Select(Describe)),
suppressed > 0 ? $"; {suppressed} more since the previous report" : "");
state.LastLogUtc = now;
state.PendingLog = 0;
state.PendingSample = null;
static string Describe(HeidenhainAlarmKey k) => HeidenhainAlarmRules.MessageOf([k]);
}
private sealed class UnmatchedState
{
public HashSet<HeidenhainAlarmKey> Keys { get; set; } = [];
public long Total { get; set; }
public DateTime? LastLogUtc { get; set; }
public long PendingLog { get; set; }
public List<HeidenhainAlarmKey>? PendingSample { get; set; }
}
private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
{
_told.TryGetValue(tag.RawPath, out var told);
@@ -87,7 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
_credentials = credentials;
_clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t));
_time = time ?? TimeProvider.System;
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger);
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger, driverInstanceId);
}
/// <inheritdoc />
@@ -419,6 +419,13 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
}
// lmxopcua#839: controller alarms no ALARM: selector claims (shown by no condition) — current + since start.
foreach (var (host, (current, total)) in _alarms.UnmatchedSnapshot())
{
bag[$"{host}.alarms.unmatched"] = current.ToString(System.Globalization.CultureInfo.InvariantCulture);
bag[$"{host}.alarms.unmatchedTotal"] = total.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
return DriverDiagnostics.Bound(bag);
}
@@ -428,7 +435,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
/// <inheritdoc />
/// <remarks>
/// The namespace is a fixed catalog (no control-dependent path churn), so the browse picker can offer it
/// without contacting the control. PLC/DATA bindings are authored by hand: LSV2 has no proven universal
/// without contacting the control — plus the <c>AlarmConditions/</c> folder of <c>ALARM:</c> selectors
/// (lmxopcua#839). PLC/DATA bindings are authored by hand: LSV2 has no proven universal
/// symbol model and this driver performs no memory scanning.
/// </remarks>
public bool SupportsOnlineDiscovery => true;
@@ -449,6 +457,20 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
e.Address, e.DataType, e.IsArray, null, SecurityClassification.ViewOnly, IsHistorized: false));
}
// lmxopcua#839: the ALARM: selectors, so a condition can be browse-committed rather than only typed. Each is an
// IsAlarm Boolean leaf; the commit's typed model seeds the paired `alarm` object the factory requires.
var alarmRoot = builder.Folder(HeidenhainAlarmClasses.BrowseFolder, HeidenhainAlarmClasses.BrowseFolder);
var alarmFolders = new Dictionary<string, IAddressSpaceBuilder>(StringComparer.Ordinal);
foreach (var (sub, name, address) in HeidenhainAlarmClasses.BrowseSelectors)
{
var parent = alarmRoot;
if (sub is not null && !alarmFolders.TryGetValue(sub, out parent))
alarmFolders[sub] = parent = alarmRoot.Folder(sub, sub);
parent.Variable(name, name, new DriverAttributeInfo(
address, HeidenhainTagDefinitionFactory.AlarmConditionDataType, false, null, SecurityClassification.ViewOnly,
IsHistorized: false, IsAlarm: true));
}
return Task.CompletedTask;
}
@@ -63,6 +63,15 @@ public sealed class MTConnectDriverOptions
/// </summary>
public int HeartbeatMs { get; init; } = 10000;
/// <summary>
/// Gets how long, in milliseconds, the Agent must keep REFUSING connections (a definite loss — nothing is
/// listening at <see cref="AgentUri"/>) before every subscribed value is marked <c>BadNoCommunication</c>
/// (lmxopcua#846). Default <c>2000</c>; <c>0</c> marks on the first refusal; a negative value disables the fast
/// path, leaving the comms-loss watchdog (~90 s) as the only signal. Ambiguous failures (timeouts, a stalled or
/// reset stream, DNS) never use it — they keep the watchdog contract.
/// </summary>
public int CommsLossConfirmMs { get; init; } = 2000;
/// <summary>
/// The authored tags this driver instance serves — one <see cref="MTConnectTagDefinition"/>
/// per tag, keyed by the DataItem <c>id</c> it binds
@@ -0,0 +1,201 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// lmxopcua#846 — the fast comms-loss path for a DEFINITE Agent loss. Before it, an Agent outage left every value
/// Good-but-stale for ~94–118 s, until the runtime's comms-loss watchdog (Degraded + a frozen
/// <c>LastSuccessfulRead</c> for 90 s) re-initialized the driver and the host fanned <c>BadNoCommunication</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only a refusal is definite.</b> A connection REFUSED (<see cref="SocketError.ConnectionRefused"/> anywhere
/// in the exception chain — what an <c>HttpClient</c> connect to a port nobody listens on produces) proves
/// nothing is serving the Agent URI. Everything else — a request or stream-idle timeout, a reset or half-closed
/// stream, a DNS failure, a paused Agent — is ambiguous and keeps the watchdog contract
/// (<c>docs/DriverLifecycle.md</c>, <c>.claude/rules/drivers.md</c> #527/#676): those must never mark early.
/// </para>
/// <para>
/// <b>Confirmed, not instant.</b> Refusals must continue, unbroken, for
/// <see cref="MTConnectDriverOptions.CommsLossConfirmMs"/> (default 2 s; 0 = first refusal; negative = off)
/// before the values are marked, so a single refused connect during an Agent restart does not flap every value.
/// While confirmation is pending the reconnect delay is capped at the time remaining, so the confirming
/// attempt is not pushed out by the backoff ladder. Anything that reaches the Agent ends the run.
/// </para>
/// <para>
/// <b>What marking does — the driver half of the quality contract.</b> Every subscribed value is published
/// <c>BadNoCommunication</c> (value null) ONCE per outage; the observation index keeps the last values. Health
/// is already Degraded with <c>LastSuccessfulRead</c> frozen (the stream failure degraded it), so the watchdog
/// still re-initializes at 90 s exactly as before — this path only stops serving stale values as Good in the
/// meantime. Reads already fall back to <c>/current</c> on a degraded stream and fail Bad on their own.
/// Native CONDITIONS are not touched: the host annotates them on the connectivity edge, as before.
/// </para>
/// <para>
/// <b>Recovery republishes everything.</b> The first delivered chunk or re-baseline after a marked outage
/// republishes every subscribed value from the index — a resumed <c>/sample</c> only carries what CHANGED, so
/// an unchanged data item would otherwise stay Bad until it next moved (the (value, status) recovery rule).
/// </para>
/// </remarks>
public sealed partial class MTConnectDriver
{
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c>.</summary>
private const uint BadNoCommunicationStatus = 0x80310000u;
/// <summary>1 while a confirmed refused-connection outage has marked the subscribed values Bad.</summary>
private int _commsLostMarked;
/// <summary>Whether a refused-connection outage currently has the values marked Bad (test seam / diagnostics).</summary>
internal bool CommsLossMarked => Volatile.Read(ref _commsLostMarked) == 1;
/// <summary>One pump's run of consecutive refused connections. Only that pump's task touches it.</summary>
private sealed class RefusalState
{
/// <summary>When the current unbroken run of refusals started, or null when there is none.</summary>
public DateTime? Since { get; set; }
/// <summary>Ends the run (anything reached the Agent, or an ambiguous failure broke it).</summary>
public void Reset() => Since = null;
}
/// <summary>
/// Whether <paramref name="error"/> proves nothing is listening at the Agent URI — a connection refused anywhere
/// in its chain. Every other failure is ambiguous (see the class remarks).
/// </summary>
/// <param name="error">The stream / re-baseline failure.</param>
/// <returns>True for a definite loss.</returns>
internal static bool IsDefiniteConnectionLoss(Exception? error)
{
for (var e = error; e is not null; e = e.InnerException)
{
if (e is SocketException { SocketErrorCode: SocketError.ConnectionRefused })
{
return true;
}
if (e is AggregateException agg && agg.InnerExceptions.Any(IsDefiniteConnectionLoss))
{
return true;
}
}
return false;
}
/// <summary>
/// Records one transient stream failure and, once refusals have persisted for the confirmation window, marks
/// every subscribed value <c>BadNoCommunication</c> (once per outage).
/// </summary>
/// <returns><see langword="false"/> when the pump's session has been replaced (nothing marked).</returns>
private bool NoteStreamFailure(RefusalState refusals, StreamOutcome outcome, MTConnectDriverOptions options, long generation)
{
if (options.CommsLossConfirmMs < 0 || !IsDefiniteConnectionLoss(outcome.Error))
{
// An ambiguous failure breaks the run: only an UNBROKEN series of refusals is a confirmed loss.
refusals.Reset();
return true;
}
var now = DateTime.UtcNow;
refusals.Since ??= now;
if (now - refusals.Since.Value < TimeSpan.FromMilliseconds(options.CommsLossConfirmMs))
{
return true;
}
return TryMarkCommsLost(generation, options, now - refusals.Since.Value);
}
/// <summary>
/// While a refusal run is waiting for confirmation, the next attempt is made no later than the moment the window
/// closes — so the confirming refusal is not deferred by a long backoff step.
/// </summary>
private static TimeSpan CapForCommsLossConfirmation(TimeSpan delay, RefusalState refusals, MTConnectDriverOptions options)
{
if (refusals.Since is not { } since || options.CommsLossConfirmMs < 0)
{
return delay;
}
var remaining = since.AddMilliseconds(options.CommsLossConfirmMs) - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
return delay; // already confirmed (or about to be): the ordinary ladder paces the outage
}
return remaining < delay ? remaining : delay;
}
private bool TryMarkCommsLost(long generation, MTConnectDriverOptions options, TimeSpan refusedFor)
{
lock (_subscriptionLock)
{
if (_sessionGeneration != generation)
{
return false;
}
}
if (Interlocked.Exchange(ref _commsLostMarked, 1) == 1)
{
return true; // already marked for this outage
}
var references = SubscribedReferences();
_logger.LogWarning(
"MTConnect driver {DriverInstanceId}: {AgentUri} has refused every connection for {RefusedMs} ms (definite loss); marking {Count} subscribed value(s) BadNoCommunication until it answers again.",
_driverInstanceId, AgentDisplay(options), (long)refusedFor.TotalMilliseconds, references.Count);
FanOutStatus(references, BadNoCommunicationStatus);
return true;
}
/// <summary>
/// The Agent answered again after a marked outage: republish every subscribed value from the index (once).
/// Generation-checked so a superseded pump cannot republish into the session that replaced it.
/// </summary>
private void RepublishAfterCommsLoss(long generation)
{
if (Volatile.Read(ref _commsLostMarked) == 0 || !IsCurrentSessionGeneration(generation))
{
return;
}
if (Interlocked.Exchange(ref _commsLostMarked, 0) == 0)
{
return;
}
_logger.LogInformation(
"MTConnect driver {DriverInstanceId}: the Agent answered again; republishing every subscribed value.",
_driverInstanceId);
FanOut(SubscribedReferences(), ObservationIndex);
}
/// <summary>Publishes a value-less <paramref name="status"/> to every handle subscribing each reference.</summary>
private void FanOutStatus(IReadOnlyList<string> references, uint status)
{
if (references.Count == 0)
{
return;
}
SubscriptionState[] states;
lock (_subscriptionLock)
{
states = [.. _subscriptions.Values];
}
var snapshot = new DataValueSnapshot(null, status, null, DateTime.UtcNow);
foreach (var reference in references)
{
foreach (var state in states)
{
if (state.References.Contains(reference))
{
RaiseDataChange(state.Handle, reference, snapshot);
}
}
}
}
}
@@ -79,9 +79,23 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
internal static int SampleCountCeiling(int configured) =>
Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling));
/// <param name="agentBufferSize">
/// The Agent's <c>/probe</c> <c>bufferSize</c>, or <c>0</c> when unknown. lmxopcua#846: an Agent refuses a
/// <c>count</c> above it (<c>OUT_OF_RANGE</c>; live, <c>count == bufferSize</c> is accepted), so a known buffer
/// caps the raised count — a raise past it would only buy a refused stream and a step-back.
/// </param>
/// <returns>The ceiling — never below <paramref name="configured"/> (a configured count above the buffer is the
/// #825 configuration warning's business, not something to raise further).</returns>
internal static int SampleCountCeiling(int configured, long agentBufferSize = 0)
{
long ceiling = Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling);
if (agentBufferSize > 0)
{
ceiling = Math.Min(ceiling, agentBufferSize);
}
return (int)Math.Max(configured, ceiling);
}
/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetDiagnostics()
@@ -132,7 +146,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
}
/// <summary>One pump's adaptive-count state. Only that pump's task touches it.</summary>
private sealed class SampleCountState(int configured)
private sealed class SampleCountState(int configured, long agentBufferSize)
{
/// <summary>The count the next stream opens with.</summary>
public int Count { get; set; } = configured;
@@ -141,7 +155,7 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
public int LastDelivered { get; set; } = configured;
/// <summary>The most <see cref="Count"/> may be raised to; lowered to the last good count on a refusal.</summary>
public int Ceiling { get; set; } = SampleCountCeiling(configured);
public int Ceiling { get; set; } = SampleCountCeiling(configured, agentBufferSize);
/// <summary>Whether the at-the-ceiling Warning has been logged for this pump.</summary>
public bool CeilingWarned { get; set; }
@@ -2229,8 +2229,9 @@ public sealed partial class MTConnectDriver
var cursor = from;
var attempt = 0;
_reconnectAttempts = 0;
var counts = new SampleCountState(options.SampleCount);
var counts = new SampleCountState(options.SampleCount, Interlocked.Read(ref _agentBufferSize));
Volatile.Write(ref _effectiveSampleCount, counts.Count);
var refusals = new RefusalState();
// #804 (Java-driver parity) — consecutive re-baselines with no /sample chunk delivered in between.
var rebaselinesWithoutProgress = 0;
@@ -2250,7 +2251,7 @@ public sealed partial class MTConnectDriver
{
if (attempt > 0)
{
var delay = BackoffFor(attempt, options.Reconnect);
var delay = CapForCommsLossConfirmation(BackoffFor(attempt, options.Reconnect), refusals, options);
if (delay > TimeSpan.Zero)
{
try
@@ -2273,6 +2274,12 @@ public sealed partial class MTConnectDriver
{
rebaselinesWithoutProgress = 0;
}
// #846 — only an unbroken run of refusals confirms a loss: anything that reached the Agent ends it.
if (outcome.DeliveredAny || outcome.Verdict is not StreamVerdict.Transient)
{
refusals.Reset();
}
cursor = outcome.Cursor;
instanceId = outcome.InstanceId;
@@ -2375,6 +2382,13 @@ public sealed partial class MTConnectDriver
return;
}
// #846 — a REFUSED connection is a definite loss; after CommsLossConfirmMs of nothing but
// refusals the values go BadNoCommunication now instead of at the ~90 s watchdog.
if (!NoteStreamFailure(refusals, outcome, options, generation))
{
return;
}
// A stream that DELIVERED before it dropped proves the endpoint works, so
// its failure starts a fresh backoff ladder. Without this the counter is
// monotonic for the life of the process: a healthy agent that drops a
@@ -2519,6 +2533,8 @@ public sealed partial class MTConnectDriver
return new StreamOutcome(StreamVerdict.Cancelled, expected, instanceId, delivered, null);
}
RepublishAfterCommsLoss(generation);
// R06:T-5 — a stream persistently behind the Agent reopens at the cursor it reached with a larger
// count, rather than falling out of the ring buffer and re-baselining every buffer-length.
if (!MeasureSampleLag(chunk, counts.Count))
@@ -2643,6 +2659,9 @@ public sealed partial class MTConnectDriver
StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
}
// #846 — the Agent answered: if a refused-connection outage marked the values Bad, put them all back.
RepublishAfterCommsLoss(generation);
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} re-baselined from {AgentUri}'s /current ({ObservationCount} observation(s), agent instanceId {InstanceId}) and resumes /sample from sequence {NextSequence}.",
_driverInstanceId,
@@ -3247,6 +3266,7 @@ public sealed partial class MTConnectDriver
SampleIntervalMs = dto.SampleIntervalMs ?? 1_000,
SampleCount = dto.SampleCount ?? 1_000,
HeartbeatMs = dto.HeartbeatMs ?? 10_000,
CommsLossConfirmMs = dto.CommsLossConfirmMs ?? 2_000,
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
@@ -4447,6 +4467,8 @@ public sealed partial class MTConnectDriver
public int? SampleCount { get; init; }
/// <summary>The /sample heartbeat in milliseconds.</summary>
public int? HeartbeatMs { get; init; }
/// <summary>How long refused connections must persist before values go BadNoCommunication (#846).</summary>
public int? CommsLossConfirmMs { get; init; }
/// <summary>The authored tags.</summary>
public List<MTConnectTagDto>? Tags { get; init; }
@@ -1209,6 +1209,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
condition.Message!.Value = new LocalizedText(state.Message);
// #839: a classifying native source's Part 9 ConditionClass. Null leaves the node's class as it is
// (BaseConditionClass from materialise, or what an earlier event set). Not in the delta-gate: a class
// rides along with the transition that set it and never fires an event on its own.
if (state.ConditionClass is { } conditionClass) ApplyConditionClass(condition, conditionClass);
// Project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
@@ -1244,6 +1248,27 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
}
/// <summary>
/// The Part 9 class type id + browse name of an <see cref="AlarmConditionClass"/> (lmxopcua#839).
/// </summary>
/// <param name="conditionClass">The driver-assigned class.</param>
/// <returns>The <c>ConditionClassId</c> / <c>ConditionClassName</c> pair to write.</returns>
internal static (NodeId Id, string Name) ConditionClassOf(AlarmConditionClass conditionClass) => conditionClass switch
{
AlarmConditionClass.Process => (ObjectTypeIds.ProcessConditionClassType, "ProcessConditionClass"),
AlarmConditionClass.Maintenance => (ObjectTypeIds.MaintenanceConditionClassType, "MaintenanceConditionClass"),
AlarmConditionClass.System => (ObjectTypeIds.SystemConditionClassType, "SystemConditionClass"),
AlarmConditionClass.Safety => (ObjectTypeIds.SafetyConditionClassType, "SafetyConditionClass"),
_ => (ObjectTypeIds.BaseConditionClassType, "BaseConditionClass"),
};
private static void ApplyConditionClass(AlarmConditionState condition, AlarmConditionClass conditionClass)
{
var (id, name) = ConditionClassOf(conditionClass);
if (condition.ConditionClassId is not null) condition.ConditionClassId.Value = id;
if (condition.ConditionClassName is not null) condition.ConditionClassName.Value = new LocalizedText(name);
}
/// <summary>
/// The <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
@@ -1623,9 +1648,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
// upstream category is Safety/Diagnostics. Per-alarm classification arrives at RUNTIME instead: a
// classifying native driver states AlarmEventArgs.ConditionClass on its transitions (#839, Heidenhain),
// which WriteAlarmCondition applies over this default — the deploy-time seam still holds none.
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
@@ -1783,7 +1808,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
LocalizedText? Comment = null, string? ClientUserId = null, NodeId? ConditionClassId = null,
LocalizedText? ConditionClassName = null);
/// <summary>
/// Read what a native condition node holds right now, so the rebuild can put it back.
@@ -1810,7 +1836,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
condition.ActiveState?.TransitionTime?.Value.ToDateTime(),
condition.Quality?.SourceTimestamp?.Value.ToDateTime(),
condition.Comment?.Value,
condition.ClientUserId?.Value);
condition.ClientUserId?.Value,
condition.ConditionClassId?.Value,
condition.ConditionClassName?.Value);
/// <summary>
/// Put a carried state back onto the freshly materialised native condition. MUST be called
@@ -1839,6 +1867,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
// #839: a driver-assigned ConditionClass is a property of the condition, not of its state — restored whether
// or not the condition is off-normal (the materialise default is BaseConditionClass).
if (alarm.ConditionClassId is { } classIdNode && carried.ConditionClassId is { } classId && !classId.IsNull)
classIdNode.Value = classId;
if (alarm.ConditionClassName is not null && carried.ConditionClassName is { } className)
alarm.ConditionClassName.Value = className;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode)> _prior =
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode, AlarmConditionClass? Class)> _prior =
new(StringComparer.Ordinal);
/// <summary>
@@ -82,7 +82,7 @@ public sealed class NativeAlarmProjector
// driver's word, so a late ack failure must not rewrite it.
_pendingLocalAck.Remove(nodeId);
var prev = _prior.TryGetValue(nodeId, out var p)
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good);
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good, Class: (AlarmConditionClass?)null);
// A source with a finer scale than the four buckets supplies the exact Part 9 severity.
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
// Only a statement saying the SAME thing the condition already shows is a re-statement.
@@ -154,14 +154,18 @@ public sealed class NativeAlarmProjector
}
var statusCode = e.QualityStatusCode ?? OpcUaStatus.Good;
_prior[nodeId] = (active, acked, e.Message, sev, statusCode);
// A source that classifies its conditions (#839) states the class on its transitions; one that does not (or a
// quality-only annotation) keeps whatever the condition last carried.
var conditionClass = e.ConditionClass ?? prev.Class;
_prior[nodeId] = (active, acked, e.Message, sev, statusCode, conditionClass);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
// A per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
// (every earlier source) ⇒ Good, as before — driver-wide comms loss stays the host's
// out-of-band WriteAlarmQuality annotation, not something the projector infers.
StatusCode: statusCode);
StatusCode: statusCode,
ConditionClass: conditionClass);
}
/// <summary>
@@ -195,7 +199,7 @@ public sealed class NativeAlarmProjector
snapshot = new AlarmConditionSnapshot(
Active: p.Active, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: p.Severity, Message: p.Message ?? string.Empty,
StatusCode: p.StatusCode);
StatusCode: p.StatusCode, ConditionClass: p.Class);
return true;
}