feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831)

- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
  ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed).
  An ALARM: address and the cross-driver alarm object only work together — the factory
  refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type
  fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription.
- HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath,
  driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new
  Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag).
  FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text,
  deterministic display order, told-record on the driver instance, recorded only while a
  handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear.
- Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture);
  Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists.
- Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged.
- IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from
  the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic.
- AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM:
  tags at Boolean and seeds/drops the alarm object with the selector; driver form gains
  "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.)

Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530
alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery),
Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a
DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
This commit is contained in:
Joseph Doherty
2026-11-11 01:37:41 -05:00
parent 5bbe1e3c7a
commit 2b0eed1a8f
22 changed files with 1379 additions and 31 deletions
+10 -1
View File
@@ -61,7 +61,9 @@ design treated it as an implementation detail.
⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but
implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download
or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT,
MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an
MTConnect and MQTT raise natively and are unaffected. Heidenhain (#831) raises on the event-driven shape this
recommends: a reconnect whose `R_VR` identity (model / NC / PLC version / options) differs from the previous session —
no timer, no extra request. If that coverage matters later, reach for an
event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a
deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`,
`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
@@ -319,6 +321,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
`BadConfigurationError` on that role's reads only
(`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence;
`Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`.
- **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=<r>[/GROUP=<r>][/NUMBER=<r>]`, `<r>` = `n` |
`lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save
path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*`
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.
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server), MTConnect
/// (CONDITION data items).
/// (CONDITION data items), AbCip (ALMD/ALMA), Heidenhain (LSV2 controller alarm list, #831).
/// </summary>
public interface IAlarmSource
{
@@ -30,6 +30,12 @@ public enum HeidenhainAddressKind
/// <summary>A data-path value read via <c>R_DP</c> (DATA role).</summary>
DataPath,
/// <summary>
/// An <c>ALARM:</c> selector — a native Part 9 condition driven from the alarm list (<c>R_RI 27/28</c>), never a
/// value (lmxopcua#831). Requires the cross-driver <c>alarm</c> object on the tag.
/// </summary>
Alarm,
}
/// <summary>A parsed, validated Heidenhain tag address.</summary>
@@ -39,13 +45,15 @@ public enum HeidenhainAddressKind
/// <param name="Area">The PLC area, for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="Index">The PLC <b>element</b> index (not an absolute address), for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="DataPath">The data path as authored, for <see cref="HeidenhainAddressKind.DataPath"/>.</param>
/// <param name="AlarmSelector">The alarm selector, for <see cref="HeidenhainAddressKind.Alarm"/>.</param>
public sealed record HeidenhainAddress(
HeidenhainAddressKind Kind,
string Text,
HeidenhainCatalogEntry? Catalog = null,
HeidenhainPlcArea? Area = null,
long Index = 0,
string? DataPath = null)
string? DataPath = null,
HeidenhainAlarmSelector? AlarmSelector = null)
{
/// <summary>Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).</summary>
public const int MaxPayload = 247;
@@ -117,6 +125,14 @@ public sealed record HeidenhainAddress(
return false;
}
if (t.StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal))
{
if (!HeidenhainAlarmSelector.TryParse(t[HeidenhainAlarmSelector.Prefix.Length..], out var selector, out error)) return false;
address = new HeidenhainAddress(HeidenhainAddressKind.Alarm, HeidenhainAlarmSelector.Prefix + selector.Canonical,
AlarmSelector: selector);
return true;
}
if (t.StartsWith(DataPrefix, StringComparison.Ordinal))
{
var path = t[DataPrefix.Length..];
@@ -157,7 +173,7 @@ public sealed record HeidenhainAddress(
return true;
}
error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding.";
error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding, a DATA:/path binding or an ALARM: selector.";
return false;
}
@@ -0,0 +1,191 @@
using System.Globalization;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>Which controller alarms an <c>ALARM:</c> address selects.</summary>
public enum HeidenhainAlarmSelectorKind
{
/// <summary><c>ALARM:*</c> — any active alarm on the tag's control (a device summary condition).</summary>
Any,
/// <summary>
/// <c>ALARM:OTHER</c> — active alarms that no <see cref="Filter"/> selector on the same control matches (the
/// "unmatched" bucket). <see cref="Any"/> and other <see cref="Other"/> tags do not claim an alarm.
/// </summary>
Other,
/// <summary><c>ALARM:CLASS=7</c>, <c>ALARM:CLASS=7/GROUP=4</c>, <c>ALARM:NUMBER=100..199</c>, …</summary>
Filter,
}
/// <summary>An inclusive integer range; <see cref="Low"/> == <see cref="High"/> for a single value.</summary>
/// <param name="Low">Lowest matched value.</param>
/// <param name="High">Highest matched value (inclusive).</param>
public readonly record struct HeidenhainAlarmRange(long Low, long High)
{
/// <summary>Whether <paramref name="value"/> lies in the range.</summary>
/// <param name="value">The value.</param>
/// <returns>True when matched.</returns>
public bool Contains(long value) => value >= Low && value <= High;
/// <inheritdoc />
public override string ToString() => Low == High
? Low.ToString(CultureInfo.InvariantCulture)
: $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}";
}
/// <summary>
/// The selector half of an <c>ALARM:</c> Heidenhain address (lmxopcua#831): which entries of the control's
/// active-alarm list (<c>R_RI 27</c> then <c>28</c> — class, group, number, text) drive the ONE Part 9 condition
/// the raw tag is materialised as.
/// </summary>
/// <remarks>
/// <para>Grammar of the text after <c>ALARM:</c> (keys case-insensitive, no whitespace):</para>
/// <list type="table">
/// <listheader><term>Form</term><description>Matches</description></listheader>
/// <item><term><c>*</c></term><description>any active alarm on the control</description></item>
/// <item><term><c>OTHER</c></term><description>alarms no filter selector on the control matches</description></item>
/// <item><term><c>CLASS=&lt;r&gt;</c>, <c>GROUP=&lt;r&gt;</c>, <c>NUMBER=&lt;r&gt;</c>, joined by <c>/</c></term>
/// <description>every named field in its range (each key at most once)</description></item>
/// </list>
/// <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>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>
/// <param name="Class">The class range, or null for any class (filter only).</param>
/// <param name="Group">The group range, or null for any group (filter only).</param>
/// <param name="Number">The number range, or null for any number (filter only).</param>
public sealed record HeidenhainAlarmSelector(
HeidenhainAlarmSelectorKind Kind,
HeidenhainAlarmRange? Class = null,
HeidenhainAlarmRange? Group = null,
HeidenhainAlarmRange? Number = null)
{
/// <summary>Address prefix of an alarm condition.</summary>
public const string Prefix = "ALARM:";
/// <summary>The <c>*</c> body.</summary>
public const string AnyToken = "*";
/// <summary>The <c>OTHER</c> body.</summary>
public const string OtherToken = "OTHER";
/// <summary>The canonical text after <c>ALARM:</c>.</summary>
public string Canonical => Kind switch
{
HeidenhainAlarmSelectorKind.Any => AnyToken,
HeidenhainAlarmSelectorKind.Other => OtherToken,
_ => string.Join("/", new[]
{
Class is { } c ? $"CLASS={c}" : null,
Group is { } g ? $"GROUP={g}" : null,
Number is { } n ? $"NUMBER={n}" : null,
}.Where(s => s is not null)),
};
/// <summary>
/// Whether this selector matches one alarm on its own terms. <see cref="HeidenhainAlarmSelectorKind.Other"/>
/// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates.
/// </summary>
/// <param name="errorClass">The alarm's class.</param>
/// <param name="group">The alarm's group.</param>
/// <param name="number">The alarm's number.</param>
/// <returns>True when selected.</returns>
public bool Matches(int errorClass, int group, int number) => Kind switch
{
HeidenhainAlarmSelectorKind.Any => true,
HeidenhainAlarmSelectorKind.Other => false,
_ => (Class is not { } c || c.Contains(errorClass))
&& (Group is not { } g || g.Contains(group))
&& (Number is not { } n || n.Contains(number)),
};
/// <summary>Parses the text after <c>ALARM:</c>. Never throws.</summary>
/// <param name="body">The selector text.</param>
/// <param name="selector">The selector on success.</param>
/// <param name="error">Why it was rejected.</param>
/// <returns>True when valid.</returns>
public static bool TryParse(string? body, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAlarmSelector? selector, out string? error)
{
selector = null;
error = null;
var t = body?.Trim() ?? "";
if (t.Length == 0)
{
error = Grammar("an alarm selector is required");
return false;
}
if (t == AnyToken)
{
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Any);
return true;
}
if (string.Equals(t, OtherToken, StringComparison.OrdinalIgnoreCase))
{
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Other);
return true;
}
HeidenhainAlarmRange? cls = null, grp = null, num = null;
foreach (var segment in t.Split('/'))
{
var eq = segment.IndexOf('=');
if (eq <= 0)
{
error = Grammar($"'{segment}' is not KEY=VALUE");
return false;
}
var key = segment[..eq].ToUpperInvariant();
var value = segment[(eq + 1)..];
var (min, max) = key == "NUMBER" ? ((long)int.MinValue, (long)int.MaxValue) : (0L, 65535L);
if (key is not ("CLASS" or "GROUP" or "NUMBER"))
{
error = Grammar($"unknown key '{segment[..eq]}'");
return false;
}
if (!TryParseRange(value, min, max, out var range))
{
error = Grammar($"{key} value '{value}' must be an integer {min}–{max} or lo..hi with lo ≤ hi");
return false;
}
ref var slot = ref key == "CLASS" ? ref cls : ref key == "GROUP" ? ref grp : ref num;
if (slot is not null)
{
error = Grammar($"{key} appears twice");
return false;
}
slot = range;
}
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Filter, cls, grp, num);
return true;
}
private static bool TryParseRange(string text, long min, long max, out HeidenhainAlarmRange range)
{
range = default;
var dots = text.IndexOf("..", StringComparison.Ordinal);
var loText = dots < 0 ? text : text[..dots];
var hiText = dots < 0 ? text : text[(dots + 2)..];
if (!long.TryParse(loText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var lo)
|| !long.TryParse(hiText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var hi))
return false;
if (lo < min || hi > max || lo > hi) return false;
range = new HeidenhainAlarmRange(lo, hi);
return true;
}
private static string Grammar(string why) =>
$"Invalid alarm selector: {why}. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
}
@@ -13,6 +13,9 @@ public enum HeidenhainPollGroup
/// <summary>Identity, tool, alarms, timers and capability strings (<c>Polling.SlowMs</c>).</summary>
Slow,
/// <summary>The alarm-list walk behind <c>ALARM:</c> Part 9 conditions (<c>Polling.AlarmMs</c>, lmxopcua#831).</summary>
Alarm,
}
/// <summary>One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.</summary>
@@ -50,6 +53,9 @@ public static class HeidenhainCatalog
/// <summary>Feature id reported for the optional DNC login.</summary>
public const string DncLoginFeature = "DncLogin";
/// <summary>Feature id of the alarm-list walk (<c>R_RI 27/28</c>) — shared by the <c>Alarms/*</c> leaves and <c>ALARM:</c> conditions.</summary>
public const string AlarmsFeature = "Alarms";
/// <summary>Feature id reported for the opt-in DATA login.</summary>
public const string DataLoginFeature = "DataLogin";
@@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions
/// <summary>Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.</summary>
public int SlowMs { get; init; } = 10000;
/// <summary>
/// Alarm-condition group: the alarm-list walk that drives authored <c>ALARM:</c> Part 9 conditions
/// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an
/// <c>ALARM:</c> tag; the <c>Alarms/*</c> typed arrays stay on <see cref="SlowMs"/>.
/// </summary>
public int AlarmMs { get; init; } = 2000;
/// <summary>Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.</summary>
public int MaxAlarms { get; init; } = 64;
@@ -110,7 +110,7 @@ public static class HeidenhainOptionsParser
var errors = new List<string>();
if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
var p = o.Polling;
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs), ("AlarmMs", p.AlarmMs) })
if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
var r = o.Reconnect;
@@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <param name="Address">The parsed address.</param>
/// <param name="DataType">The OPC data type the driver publishes.</param>
/// <param name="IsArray">Whether the value is a 1-D array.</param>
/// <param name="Alarm">
/// The cross-driver <c>alarm</c> object, present exactly when <see cref="Address"/> is an <c>ALARM:</c> selector
/// (lmxopcua#831); the tag is then a Part 9 condition, never a value.
/// </param>
public sealed record HeidenhainTagDefinition(
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray,
HeidenhainTagAlarm? Alarm = null)
{
/// <summary>The poll cadence this tag rides.</summary>
public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
? Address.Catalog!.Group
: HeidenhainPollGroup.Custom;
public HeidenhainPollGroup Group => Address.Kind switch
{
HeidenhainAddressKind.Standard => Address.Catalog!.Group,
HeidenhainAddressKind.Alarm => HeidenhainPollGroup.Alarm,
_ => HeidenhainPollGroup.Custom,
};
/// <summary>True for an <c>ALARM:</c> condition tag.</summary>
public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm;
}
/// <summary>The driver's view of a tag's cross-driver <c>alarm</c> object.</summary>
/// <param name="Severity">The authored integer <c>alarm.severity</c>, or null when absent / not an integer.</param>
public sealed record HeidenhainTagAlarm(int? Severity);
/// <summary>
/// The single authority on a Heidenhain raw tag's <c>TagConfig</c>: consumed by the driver's table build,
/// the deploy-time gate (<see cref="Inspect"/>) and the AdminUI typed editor's <c>Validate()</c>, so the
@@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition(
/// <c>{"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}</c>. Array leaves
/// (<c>Axes/*</c>, <c>Alarms/Classes|Groups|Numbers|Messages</c>) must carry <c>"isArray": true</c> so the
/// node's rank matches what the driver publishes; every other address must not.</para>
/// <para><b>Alarm conditions</b> (lmxopcua#831): <c>{"address": "ALARM:CLASS=7", "dataType": "Boolean",
/// "alarm": {...}}</c>. An <c>ALARM:</c> address and the <c>alarm</c> object only work together — either alone is
/// refused (a condition nothing can raise, or a selector with no value to read). The data type is fixed at
/// <c>Boolean</c>.</para>
/// <para><b>Never a write flag.</b> There is no <c>writable</c> key: the driver is read-only and the deploy
/// gate refuses a ReadWrite access level on any Heidenhain tag.</para>
/// <para><b>Data types are parsed by NAME only.</b> A digits-only value (a numerically-serialized enum) is
@@ -59,6 +78,7 @@ public static class HeidenhainTagDefinitionFactory
string? dataTypeText;
bool dataTypePresent;
bool? isArray;
HeidenhainTagAlarm? alarm;
try
{
using var doc = JsonDocument.Parse(tagConfig);
@@ -95,6 +115,15 @@ public static class HeidenhainTagDefinitionFactory
}
}
alarm = null;
// Exact-case, exactly as the host's TagConfigIntent reads it: the condition exists iff the host sees it.
if (root.TryGetProperty("alarm", out var a) && a.ValueKind == JsonValueKind.Object)
{
int? severity = a.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.Number
&& sev.TryGetInt32(out var sv) ? sv : null;
alarm = new HeidenhainTagAlarm(severity);
}
if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True)
{
error = "Heidenhain tags are read-only; remove \"writable\": true.";
@@ -121,9 +150,34 @@ public static class HeidenhainTagDefinitionFactory
declared = parsed;
}
if (address.Kind == HeidenhainAddressKind.Alarm && alarm is null)
{
error = $"'{address.Text}' is an alarm selector — enable the tag's native alarm (the 'alarm' object); " +
"an ALARM: tag is a Part 9 condition and has no value to read.";
return false;
}
if (address.Kind != HeidenhainAddressKind.Alarm && alarm is not null)
{
error = $"The tag carries an 'alarm' object but '{address.Text}' is not an ALARM: selector — nothing could " +
"ever raise the condition. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
return false;
}
DriverDataType type;
bool array;
if (address.Kind == HeidenhainAddressKind.Standard)
if (address.Kind == HeidenhainAddressKind.Alarm)
{
if (declared is { } d && d != AlarmConditionDataType)
{
error = $"An ALARM: condition tag is {AlarmConditionDataType}; dataType {d} does not match.";
return false;
}
type = AlarmConditionDataType;
array = false;
}
else if (address.Kind == HeidenhainAddressKind.Standard)
{
var entry = address.Catalog!;
if (declared is { } d && d != entry.DataType)
@@ -172,10 +226,13 @@ public static class HeidenhainTagDefinitionFactory
return false;
}
definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array);
definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array, alarm);
return true;
}
/// <summary>The fixed <c>Tag.DataType</c> of an <c>ALARM:</c> condition tag (active / inactive).</summary>
public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean;
/// <summary>
/// Deploy-time inspection: empty when <see cref="TryFromTagConfig"/> accepts the blob (never stricter),
/// otherwise one warning explaining the rejection. Never throws.
@@ -0,0 +1,287 @@
using System.Text;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for.</summary>
/// <param name="RawPath">The tag's RawPath — the ConditionId, the only key the host routes on.</param>
/// <param name="HostName">The control (<c>host:port</c>) whose alarm list the tag selects from.</param>
/// <param name="Selector">Which alarms on that control drive the condition.</param>
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or null.</param>
internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity);
/// <summary>
/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real
/// iTNC 530 reports <c>-1</c> for (at least) "Run test of cutout channels" — so two different texts under one
/// number are two alarms.
/// </summary>
internal readonly record struct HeidenhainAlarmKey(int Class, int Group, int Number, string Message)
{
public static HeidenhainAlarmKey Of(Lsv2Alarm a) => new(a.ErrorClass, a.Group, a.Number, (a.Message ?? "").Trim());
}
/// <summary>What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise).</summary>
internal sealed record HeidenhainToldCondition(
bool Active,
IReadOnlySet<HeidenhainAlarmKey> Keys,
HeidenhainAlarmKey? Winner,
string Message,
AlarmSeverity Severity,
int? OpcUaSeverity);
/// <summary>
/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics
/// applied to the LSV2 alarm list.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><b>Overlap is intended</b>: one alarm drives every selector that matches it (<c>*</c> and
/// <c>CLASS=7</c>). <c>OTHER</c> is what no FILTER selector on the same control claims.</item>
/// <item><b>Displayed alarm is deterministic</b> — ascending (class, group, number, text), never wire order,
/// so a reordered unchanged list cannot re-raise.</item>
/// <item><b>Transitions</b>: inactive → active = <c>Raise</c>; a key not in the told set appears, or the
/// 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>
/// </list>
/// </remarks>
internal static class HeidenhainAlarmRules
{
/// <summary>The <see cref="AlarmEventArgs.AlarmType"/> every Heidenhain condition event carries.</summary>
public const string AlarmType = "HeidenhainAlarm";
/// <summary>The alarms one condition matches, in display order (distinct by key).</summary>
public static IReadOnlyList<HeidenhainAlarmKey> Evaluate(
HeidenhainAlarmSelector selector, IReadOnlyList<Lsv2Alarm> alarms, IReadOnlyCollection<HeidenhainAlarmSelector> deviceSelectors)
{
var filters = selector.Kind == HeidenhainAlarmSelectorKind.Other
? deviceSelectors.Where(s => s.Kind == HeidenhainAlarmSelectorKind.Filter).ToList()
: null;
return alarms
.Where(a => filters is null
? selector.Matches(a.ErrorClass, a.Group, a.Number)
: !filters.Any(f => f.Matches(a.ErrorClass, a.Group, a.Number)))
.Select(HeidenhainAlarmKey.Of)
.Distinct()
.OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number)
.ThenBy(k => k.Message, StringComparer.Ordinal)
.ToList();
}
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="matched"/>, and the new record.</summary>
public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition(
HeidenhainToldCondition? told, IReadOnlyList<HeidenhainAlarmKey> matched, HeidenhainAlarmTag tag,
IAlarmSubscriptionHandle handle, DateTime nowUtc)
{
var keys = matched.ToHashSet();
if (matched.Count == 0)
{
if (told is not { Active: true })
return (null, told ?? new HeidenhainToldCondition(false, keys, null, "", AlarmSeverity.Low, null));
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}", told.Severity, told.OpcUaSeverity),
told with { Active = false, Keys = keys, Winner = null });
}
var winner = matched[0];
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);
if (told is not { Active: true })
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised);
if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner)
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised);
return (null, told with { Keys = keys });
}
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
public static AlarmSeverity BucketOf(int severity) => severity switch
{
< 400 => AlarmSeverity.Low,
< 600 => AlarmSeverity.Medium,
< 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// <summary><c>-1 Run test of cutout channels (class 7, group 4) [+2 more]</c>.</summary>
public static string MessageOf(IReadOnlyList<HeidenhainAlarmKey> matched)
{
var w = matched[0];
var b = new StringBuilder();
b.Append(w.Number);
if (w.Message.Length > 0) b.Append(' ').Append(w.Message);
b.Append(" (class ").Append(w.Class).Append(", group ").Append(w.Group).Append(')');
if (matched.Count > 1) b.Append(" [+").Append(matched.Count - 1).Append(" more]");
return b.ToString();
}
public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc,
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) =>
new(handle,
SourceNodeId: tag.HostName,
ConditionId: tag.RawPath,
AlarmType: AlarmType,
Message: message,
Severity: bucket,
SourceTimestampUtc: nowUtc,
Kind: kind,
OpcUaSeverity: exact,
QualityStatusCode: quality);
}
/// <summary>
/// Drives one Part 9 condition per subscribed <c>ALARM:</c> raw tag from the alarm walks the device loops already
/// perform (lmxopcua#831). There is NO extra connection and NO extra request type: the walk is the same
/// allowlisted <c>R_RI 27/28</c> the <c>Alarms/*</c> arrays use, issued by the control's single acquisition loop on
/// the <c>Polling.AlarmMs</c> cadence.
/// </summary>
/// <remarks>
/// <para><b>Lives as long as the driver</b> (not re-created on re-initialise), so the told record survives a
/// reconnect, a same-set re-subscribe and an in-place re-init — re-raising there would un-acknowledge. A changed
/// subscribed set prunes only the RawPaths that left it.</para>
/// <para><b>A failed walk is not "no alarms".</b> A refusal, a list overflow, a decode error or comms loss keeps
/// every condition's Active/Acked state and annotates its quality once (quality-only event, no <c>/alerts</c>
/// row); the next good walk lifts it to Good before diffing. Only a down session degrades driver health (the
/// runtime's rule) — a refusal never does, or the #527 watchdog would reconnect-loop a production control.</para>
/// <para><b>Read-only acknowledgement.</b> LSV2 has no acknowledge request in the allowlist, and none is added:
/// <see cref="HeidenhainDriver.AcknowledgeAsync"/> is the no-op success the host routes a Part 9 Acknowledge to
/// (the FOCAS / MTConnect posture) — the ack is applied server-side only; the control clears an alarm when its
/// cause is reset. Confirm / shelve / enable are refused by the host for every native condition.</para>
/// <para>Recorded only while a handler is attached (the host detaches while it reconnects), so the re-subscribe
/// 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)
{
private readonly Lock _lock = new();
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _failing = new(StringComparer.Ordinal);
private IReadOnlyDictionary<string, HeidenhainAlarmTag> _tags = new Dictionary<string, HeidenhainAlarmTag>();
private Subscription? _current;
private long _nextId;
/// <summary>The authored alarm tags (RawPath → tag). Swapped whole on every table build.</summary>
public IReadOnlyDictionary<string, HeidenhainAlarmTag> Tags
{
get => Volatile.Read(ref _tags);
set => Volatile.Write(ref _tags, value);
}
/// <summary>The live subscription (test seam).</summary>
internal Subscription? Current => Volatile.Read(ref _current);
public IAlarmSubscriptionHandle Subscribe(IReadOnlyList<string> sourceNodeIds)
{
ArgumentNullException.ThrowIfNull(sourceNodeIds);
var refs = sourceNodeIds.Where(r => !string.IsNullOrWhiteSpace(r)).ToHashSet(StringComparer.Ordinal);
var sub = new Subscription(new HeidenhainAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
lock (_lock)
{
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone);
// A (re)subscribe follows a host-side reconnect, where the host re-annotates its conditions; forget which
// ones this projection marked, so a still-failing control re-asserts its quality on the next walk.
_qualityTold.Clear();
Volatile.Write(ref _current, sub);
}
return sub.Handle;
}
public void Unsubscribe(IAlarmSubscriptionHandle handle)
{
lock (_lock)
{
if (Volatile.Read(ref _current) is { } live && ReferenceEquals(live.Handle, handle)) Volatile.Write(ref _current, null);
}
}
/// <summary>One alarm-walk outcome of <paramref name="hostName"/> (Good + list, or a non-Good status).</summary>
public void OnWalk(string hostName, uint status, IReadOnlyList<Lsv2Alarm>? alarms)
{
var now = DateTime.UtcNow;
var tags = Tags;
lock (_lock)
{
if (Volatile.Read(ref _current) is not { } sub) return;
var deviceTags = tags.Values.Where(t => t.HostName == hostName).ToList();
var subscribed = deviceTags.Where(t => sub.References.Contains(t.RawPath)).ToList();
if (subscribed.Count == 0) return;
if (status != HeidenhainStatus.Good || alarms is null)
{
if (!_failing.TryGetValue(hostName, out var was) || was != status)
{
_failing[hostName] = status;
logger.LogWarning(
"Heidenhain {Host}: alarm list unavailable ({Status}); its {Count} alarm condition(s) keep their state and are marked with that quality",
hostName, HeidenhainStatus.State(status), subscribed.Count);
}
}
else if (_failing.Remove(hostName))
{
logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName);
}
var h = handlers();
if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again
if (status != HeidenhainStatus.Good || alarms is null)
{
foreach (var tag in subscribed)
{
if (_qualityTold.TryGetValue(tag.RawPath, out var q) && q == status) continue;
_qualityTold[tag.RawPath] = status;
Deliver(h, QualityOnly(tag, sub.Handle, status, now));
}
return;
}
var selectors = deviceTags.Select(t => t.Selector).ToList();
foreach (var tag in subscribed)
{
// Lift a non-Good this projection put there BEFORE the diff, so recovery + a change reads Good then the transition.
if (_qualityTold.Remove(tag.RawPath)) Deliver(h, QualityOnly(tag, sub.Handle, HeidenhainStatus.Good, now));
_told.TryGetValue(tag.RawPath, out var told);
var (evt, nowTold) = HeidenhainAlarmRules.Transition(
told, HeidenhainAlarmRules.Evaluate(tag.Selector, alarms, selectors), tag, sub.Handle, now);
_told[tag.RawPath] = nowTold;
if (evt is not null) Deliver(h, evt);
}
}
}
private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
{
_told.TryGetValue(tag.RawPath, out var told);
return HeidenhainAlarmRules.Build(tag, handle, now, AlarmTransitionKind.Unspecified, told?.Message ?? "",
told?.Severity ?? AlarmSeverity.Low, told?.OpcUaSeverity, quality);
}
private void Deliver(EventHandler<AlarmEventArgs> h, AlarmEventArgs evt)
{
foreach (var registration in h.GetInvocationList())
{
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(sender, evt); }
catch (Exception ex)
{
logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId);
}
}
}
/// <summary>The live subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
}
/// <summary>Handle returned by <see cref="HeidenhainDriver.SubscribeAlarmsAsync"/>.</summary>
/// <param name="Id">The driver-local subscription number.</param>
public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => $"heidenhain-alarm-sub-{Id}";
}
@@ -26,6 +26,15 @@ public enum HeidenhainConnectionState
/// <summary>Builds the client for an endpoint (test seam).</summary>
internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
/// <summary>
/// The alarm-walk outcome for a control's <c>ALARM:</c> conditions (lmxopcua#831): <paramref name="status"/> Good
/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with <c>null</c> — never "no alarms".
/// </summary>
internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList<Lsv2Alarm>? alarms);
/// <summary>A session's controller identity, reported once per established session (lmxopcua#831 rediscovery).</summary>
internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity);
/// <summary>
/// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction
/// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache
@@ -54,6 +63,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private readonly Lsv2ClientFactory _clientFactory;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly HeidenhainAlarmSink? _alarms;
private readonly HeidenhainIdentitySink? _identity;
private readonly IReadOnlyList<HeidenhainTagDefinition> _alarmTags;
private readonly object _publication = new();
private readonly object _lifecycle = new();
private readonly Dictionary<HeidenhainPollGroup, bool> _groupGood = new();
@@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> stateChanged,
Lsv2ClientFactory clientFactory,
TimeProvider time,
ILogger logger)
ILogger logger,
HeidenhainAlarmSink? alarms = null,
HeidenhainIdentitySink? identity = null)
{
_device = device;
_options = HeidenhainOptionsParser.Normalize(options);
@@ -92,6 +106,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
_clientFactory = clientFactory;
_time = time;
_logger = logger;
_alarms = alarms;
_identity = identity;
_alarmTags = tags.Where(t => t.IsAlarmCondition).ToList();
_stateChangedUtc = time.GetUtcNow().UtcDateTime;
_groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList<HeidenhainTagDefinition>)g.ToList());
}
@@ -220,6 +237,12 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
SetState(HeidenhainConnectionState.Connected, null);
_logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
if (session.Identity is { } identity && _identity is not null)
{
try { _identity(this, identity); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} identity handler threw", HostName); }
}
await PollAsync(session, generation, ct).ConfigureAwait(false);
// Poll returns only on session loss.
Retire(generation, "LSV2 communication lost");
@@ -254,6 +277,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)),
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)),
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)),
[HeidenhainPollGroup.Alarm] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.AlarmMs)),
};
var start = _time.GetTimestamp();
var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
@@ -267,7 +291,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
while (!ct.IsCancellationRequested)
{
var elapsed = _time.GetElapsedTime(start);
foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Alarm, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
{
if (!due.TryGetValue(group, out var at) || at > elapsed) continue;
var tags = _groups[group];
@@ -291,9 +315,19 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
if (generation != _generation || !ReferenceEquals(_session, session)) return false;
if (session.CommunicationLost) return false;
var good = true;
var alarmsDelivered = false;
for (var i = 0; i < tags.Count; i++)
{
var s = samples[i];
if (tags[i].IsAlarmCondition)
{
// One alarm walk per batch serves every ALARM: condition of this control (they share the outcome).
if (!alarmsDelivered) DeliverAlarms(s.Status, s.Status == HeidenhainStatus.Good ? s.Value as IReadOnlyList<Lsv2Alarm> : null);
alarmsDelivered = true;
if (s.Status != HeidenhainStatus.Good) good = false;
continue;
}
_publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now));
if (tags[i].Address.Catalog?.IsCapability == true) continue;
// Explicitly disabled optional timers are not partial data.
@@ -341,14 +375,25 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private void Invalidate(DateTime now)
{
if (_alarmTags.Count > 0) DeliverAlarms(HeidenhainStatus.BadNoCommunication, null);
foreach (var tag in _tags)
{
if (tag.IsAlarmCondition) continue;
_publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true
? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now)
: new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now));
}
}
/// <summary>Hands an alarm-walk outcome to the projection. Called under <see cref="_publication"/> (ordered with the generation).</summary>
private void DeliverAlarms(uint status, IReadOnlyList<Lsv2Alarm>? alarms)
{
if (_alarms is null) return;
if (status == HeidenhainStatus.Good && alarms is null) status = HeidenhainStatus.BadDecodingError;
try { _alarms(this, status, status == HeidenhainStatus.Good ? alarms : null); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} alarm projection threw", HostName); }
}
private void SetState(HeidenhainConnectionState next, string? error)
{
HeidenhainConnectionState old;
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// dedup (subscriptions dedup on value AND status).</para>
/// </remarks>
public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable
{
/// <summary>Most custom (PLC/DATA) bindings one control may carry (reference limit).</summary>
public const int MaxCustomBindingsPerDevice = 256;
@@ -45,6 +45,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
private readonly object _publishLock = new();
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
/// <summary>
/// The <c>ALARM:</c> condition projection (lmxopcua#831). Lives as long as the driver — NOT re-created per
/// initialise — so its told record survives a reconnect and an in-place re-init (re-raising would un-ack).
/// </summary>
private readonly HeidenhainAlarmProjection _alarms;
/// <summary>
/// Last controller identity seen per control (<c>host:port</c>). Driver-lifetime, so a re-initialise does not
/// forget the baseline; an identity that differs on a later session raises <see cref="OnRediscoveryNeeded"/>.
/// </summary>
private readonly ConcurrentDictionary<string, Lsv2Versions> _identities = new(StringComparer.Ordinal);
private HeidenhainDriverOptions _options;
private IReadOnlyDictionary<string, HeidenhainDeviceRuntime> _byRawPath = new Dictionary<string, HeidenhainDeviceRuntime>();
private IReadOnlyList<HeidenhainDeviceRuntime> _devices = [];
@@ -75,6 +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);
}
/// <inheritdoc />
@@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
/// <inheritdoc />
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <inheritdoc />
/// <remarks>
/// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects
/// reporting a different identity (<c>R_VR</c>: model, NC/PLC software version or option string) than the
/// previous session — a software update or option install can change what the control serves (selectors,
/// PLC layout). The first session of a control only records the baseline. No extra traffic: the identity is
/// the <c>R_VR</c> every session already reads at connect.
/// </remarks>
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <summary>Parses + validates a config document (the one authority the factory and the driver share).</summary>
/// <param name="driverConfigJson">The config JSON.</param>
/// <returns>The options.</returns>
@@ -138,6 +164,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var devices = _options.Devices ?? [];
_skipped.BeginRebuild(_options.RawTags.Count);
var perDevice = new Dictionary<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
var alarmTags = new Dictionary<string, HeidenhainAlarmTag>(StringComparer.Ordinal);
foreach (var d in devices) perDevice[d] = [];
foreach (var raw in _options.RawTags)
@@ -156,8 +183,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
}
var list = perDevice[device];
if (def.Address.Kind != HeidenhainAddressKind.Standard
&& list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice)
if (def.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath
&& list.Count(t => t.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath) >= MaxCustomBindingsPerDevice)
{
_skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control");
continue;
@@ -187,17 +214,27 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var runtime = new HeidenhainDeviceRuntime(
dev, _options, tags,
(role, ct) => _credentials.ResolveAsync(_options, dev, role, ct),
Publish, OnRuntimeState, _clientFactory, _time, _logger);
Publish, OnRuntimeState, _clientFactory, _time, _logger,
(r, status, alarms) => _alarms.OnWalk(r.HostName, status, alarms),
OnIdentity);
runtimes.Add(runtime);
foreach (var t in tags)
{
byPath[t.RawPath] = runtime;
if (t.IsAlarmCondition)
{
// A condition, never a value: it never enters the value cache or a data subscription.
alarmTags[t.RawPath] = new HeidenhainAlarmTag(t.RawPath, runtime.HostName, t.Address.AlarmSelector!, t.Alarm?.Severity);
continue;
}
_cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now);
}
}
_devices = runtimes;
_byRawPath = byPath;
_alarms.Tags = alarmTags;
}
private static HeidenhainDeviceOptions? Route(IEnumerable<HeidenhainDeviceOptions> devices, string deviceName)
@@ -270,7 +307,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
for (var i = 0; i < fullReferences.Count; i++)
result[i] = _cache.TryGetValue(fullReferences[i], out var s)
? s
: new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
: new DataValueSnapshot(null, Unknown(fullReferences[i]), null, now);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(result);
}
@@ -288,7 +325,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var now = _time.GetUtcNow().UtcDateTime;
foreach (var r in sub.References)
{
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, Unknown(r), null, now);
Raise(sub, r, snapshot);
}
}
@@ -297,6 +334,10 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
return Task.FromResult<ISubscriptionHandle>(sub);
}
/// <summary>Status for a reference with no cached value: an <c>ALARM:</c> condition has no value to read.</summary>
private uint Unknown(string reference) =>
_alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown;
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
@@ -411,6 +452,60 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
return Task.CompletedTask;
}
// ---- IAlarmSource (lmxopcua#831) ----
/// <inheritdoc />
/// <remarks>
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm set).
/// Collapses to ONE live subscription (the owning actor re-subscribes on every Connected entry). Starts the
/// control loops when nothing else had (an alarm-only driver); the first walk follows within
/// <c>Polling.AlarmMs</c>.
/// </remarks>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var handle = _alarms.Subscribe(sourceNodeIds);
EnsureStarted();
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
_alarms.Unsubscribe(handle);
return Task.CompletedTask;
}
/// <inheritdoc />
/// <remarks>
/// <b>Local-only.</b> LSV2 has no acknowledge request, and the read-only allowlist (<see cref="Lsv2ReadOnlyPolicy"/>)
/// is deliberately NOT widened for one: nothing is sent to the control. This is the no-op success the host routes
/// a Part 9 Acknowledge / AddComment to, so the ack is applied (and replicated) server-side — the FOCAS and
/// MTConnect posture. The control clears an alarm itself when its cause is reset.
/// </remarks>
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(acknowledgements);
return Task.CompletedTask;
}
/// <summary>The alarm projection (test seam).</summary>
internal HeidenhainAlarmProjection AlarmProjection => _alarms;
private void OnIdentity(HeidenhainDeviceRuntime runtime, Lsv2Versions identity)
{
var previous = _identities.GetValueOrDefault(runtime.HostName);
_identities[runtime.HostName] = identity;
if (previous is null || previous == identity) return;
var reason = $"Heidenhain control {runtime.HostName} reconnected with a different identity " +
$"({Describe(previous)} → {Describe(identity)}); re-browse the device and review its tags.";
_logger.LogWarning("{Reason}", reason);
try { OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, null)); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain rediscovery handler threw"); }
static string Describe(Lsv2Versions v) => $"{v.Model} NC {v.Nc} PLC {v.Plc} options '{v.Options}'";
}
/// <inheritdoc />
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -187,9 +187,20 @@ internal sealed class HeidenhainSession : IAsyncDisposable
return result;
}
private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard
? tag.Address.Catalog!.Feature
: "Custom|" + tag.Address.Text + "|" + tag.DataType;
private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind switch
{
HeidenhainAddressKind.Standard => tag.Address.Catalog!.Feature,
HeidenhainAddressKind.Alarm => HeidenhainCatalog.AlarmsFeature,
_ => "Custom|" + tag.Address.Text + "|" + tag.DataType,
};
/// <summary>True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk).</summary>
private static bool IsCustom(HeidenhainTagDefinition tag) =>
tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath;
/// <summary>The catalog feature a non-custom tag is acquired through.</summary>
private static string FeatureOf(HeidenhainTagDefinition tag) =>
tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature;
/// <summary>The current capability string for a feature.</summary>
public string Capability(string feature)
@@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable
private async Task<Outcome> AcquireAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> wire, CancellationToken ct)
{
if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication);
var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard;
var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature;
var isCustom = IsCustom(tag);
var feature = isCustom ? tag.Address.Text : FeatureOf(tag);
var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature;
if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus);
try
@@ -251,7 +262,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
"Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)),
"Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)),
"Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)),
"Alarms" => await AlarmsAsync(ct).ConfigureAwait(false),
HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false),
_ => await TimerAsync(feature, ct).ConfigureAwait(false),
};
}
@@ -419,6 +430,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable
private static object? Leaf(HeidenhainTagDefinition tag, object? value)
{
// An ALARM: condition receives the whole (immutable copy of the) list; the projection evaluates it.
if (tag.IsAlarmCondition) return value is List<Lsv2Alarm> alarms ? alarms.ToArray() : null;
if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value;
var address = tag.Address.Catalog!.Address;
var name = address[(address.IndexOf('/') + 1)..];
@@ -90,6 +90,10 @@
<label class="form-label" for="hhSlow">Slow (ms)</label>
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarmMs" title="Cadence of the alarm-list walk behind ALARM: Part 9 conditions; only polled when the control carries an ALARM: tag">Alarm conditions (ms)</label>
<InputNumber id="hhAlarmMs" @bind-Value="_m.AlarmMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
@@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int AlarmMs { get; set; } = 2000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
@@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
AlarmMs = o.Polling.AlarmMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
@@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["AlarmMs"] = AlarmMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
@@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
/// <summary>
/// The driver's own verdict on this config's ranges (device endpoints are validated on the device form).
/// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting <c>DriverConfigModal</c>
/// shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which scans every password field of the stored
/// document) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
Access = new HeidenhainAccessOptions
{
TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
},
Polling = o.Polling, Reconnect = o.Reconnect,
});
return errors.Count == 0 ? null : string.Join(" ", errors);
@@ -12,13 +12,15 @@
<label class="form-label" for="hh-address">Address</label>
<input id="hh-address" type="text" class="form-control form-control-sm mono" list="hh-catalog"
value="@_m.Address" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? ""))"
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8" />
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" />
<datalist id="hh-catalog">
@foreach (var e in HeidenhainCatalog.Entries) { <option value="@e.Address"></option> }
</datalist>
<div class="form-text">
A fixed leaf, <span class="mono">PLC:AREA:INDEX</span> (element index into the control's advertised region; AREA one of
@string.Join(", ", HeidenhainAddress.AreaTokens)) or <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver).
@string.Join(", ", HeidenhainAddress.AreaTokens)), <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver),
or an alarm condition <span class="mono">ALARM:*</span> · <span class="mono">ALARM:OTHER</span> ·
<span class="mono">ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199]</span> (enable <em>Native alarm</em> below; acknowledgement is server-side only).
</div>
</div>
<div class="col-md-4">
@@ -28,6 +30,11 @@
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@CatalogShape()" disabled readonly />
<div class="form-text">Fixed by the namespace catalog.</div>
}
else if (_m.IsAlarmAddress)
{
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@HeidenhainTagDefinitionFactory.AlarmConditionDataType" disabled readonly />
<div class="form-text">A Part 9 condition (active / inactive), not a value.</div>
}
else
{
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
@@ -32,6 +32,21 @@ public sealed class HeidenhainTagConfigModel
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
/// <summary>
/// True when <see cref="Address"/> is an <c>ALARM:</c> condition selector (lmxopcua#831): its data type is fixed
/// (<see cref="HeidenhainTagDefinitionFactory.AlarmConditionDataType"/>) and it needs the tag's native alarm enabled.
/// </summary>
public bool IsAlarmAddress => Address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal);
/// <summary>True when the data type is dictated by the address (catalog leaf or alarm condition), not chosen.</summary>
public bool HasFixedShape => IsCatalogAddress || IsAlarmAddress;
/// <summary>The Part 9 subtype seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const string DefaultAlarmType = "AlarmCondition";
/// <summary>The severity (1..1000) seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const int DefaultAlarmSeverity = 700;
public static HeidenhainTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -39,6 +54,7 @@ public sealed class HeidenhainTagConfigModel
var typeText = TagConfigJson.GetString(o, "dataType");
DriverDataType type;
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
else if (address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) type = HeidenhainTagDefinitionFactory.AlarmConditionDataType;
else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed;
else type = DriverDataType.Int32;
return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" };
@@ -54,12 +70,28 @@ public sealed class HeidenhainTagConfigModel
TagConfigJson.Set(_bag, "dataType", entry.DataType);
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
}
else if (IsAlarmAddress)
{
TagConfigJson.Set(_bag, "dataType", HeidenhainTagDefinitionFactory.AlarmConditionDataType);
TagConfigJson.Set(_bag, "isArray", null);
}
else
{
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "isArray", null);
}
// An ALARM: selector and the cross-driver alarm object only work together (the driver refuses either alone),
// so an edit keeps them paired: picking a selector seeds a ready-to-author condition (the Galaxy picker
// convenience — every authoring path, incl. the manual-entry grid that has no alarm section, can then save it),
// and leaving the selector drops the object that could no longer be raised. An authored object is never
// overwritten.
var hasAlarm = _bag.TryGetPropertyValue("alarm", out var alarmNode) && alarmNode is JsonObject;
if (IsAlarmAddress && !hasAlarm)
_bag["alarm"] = new JsonObject { ["alarmType"] = DefaultAlarmType, ["severity"] = DefaultAlarmSeverity };
else if (!IsAlarmAddress && hasAlarm)
_bag.Remove("alarm");
return _source = TagConfigJson.Serialize(_bag);
}
@@ -132,7 +132,7 @@ public sealed class NativeAlarmModel
{
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
Core.Abstractions.DriverTypeNames.Galaxy,
Core.Abstractions.DriverTypeNames.Galaxy, Core.Abstractions.DriverTypeNames.Heidenhain,
};
/// <summary>True when the driver type can emit native alarms.</summary>
@@ -92,6 +92,21 @@ public sealed class DraftValidatorHeidenhainTests
=> Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read))
.ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode);
[Fact]
public void An_alarm_condition_tag_deploys_as_Boolean_and_any_other_data_type_is_refused()
{
// lmxopcua#831: an ALARM: selector + the alarm object is a Part 9 condition; its Tag.DataType is fixed.
var tag = T(TagAccessLevel.Read);
tag.TagConfig = """{"address":"ALARM:CLASS=7","dataType":"Boolean","alarm":{"alarmType":"AlarmCondition","severity":800}}""";
tag.DataType = "Boolean";
Validate(Driver(), Dev(), tag).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
tag.DataType = "Int32";
Validate(Driver(), Dev(), tag).ShouldContain(e => e.Code == HeidenhainDeployChecks.DataTypeCode && e.Message.Contains("Boolean"));
tag.DataType = "Boolean";
tag.AccessLevel = TagAccessLevel.ReadWrite;
Validate(Driver(), Dev(), tag).ShouldContain(e => e.Code == HeidenhainDeployChecks.WritableCode);
}
[Fact]
public void Other_drivers_are_unaffected()
=> Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite))
@@ -0,0 +1,384 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
/// <summary>
/// lmxopcua#831: controller alarms as native Part 9 conditions (<c>ALARM:</c> selectors + the <c>alarm</c> object),
/// local-only acknowledgement (nothing sent to the control), quality-only annotation of a failed walk, and the
/// identity-change rediscovery signal — against the in-process LSV2 simulator and the captured iTNC 530 alarm reply.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainAlarmTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string Alarm = """, "alarm": {"alarmType": "AlarmCondition", "severity": 800}""";
private static RawTagEntry AlarmTag(string selector, string name, bool withAlarm = true) =>
new($"Plant/H/dev1/{name}",
$$"""{"address": "ALARM:{{selector}}", "dataType": "Boolean"{{(withAlarm ? Alarm : "")}}}""", false, "dev1");
// ---- selector grammar ----
[Theory]
[InlineData("*", "*")]
[InlineData("other", "OTHER")]
[InlineData("class=7", "CLASS=7")]
[InlineData("NUMBER=-5..-1/class=7", "CLASS=7/NUMBER=-5..-1")]
[InlineData("GROUP=4/NUMBER=100..199", "GROUP=4/NUMBER=100..199")]
public void Selector_grammar_accepts_and_canonicalises(string body, string canonical)
{
HeidenhainAlarmSelector.TryParse(body, out var s, out var error).ShouldBeTrue(error);
s!.Canonical.ShouldBe(canonical);
HeidenhainAddress.TryParse("ALARM:" + body, out var a, out _).ShouldBeTrue();
a!.Kind.ShouldBe(HeidenhainAddressKind.Alarm);
a.Text.ShouldBe("ALARM:" + canonical);
}
[Theory]
[InlineData("")]
[InlineData("CLASS")]
[InlineData("CLASS=x")]
[InlineData("CLASS=70000")]
[InlineData("CLASS=-1")]
[InlineData("NUMBER=5..1")]
[InlineData("CLASS=1/CLASS=2")]
[InlineData("TYPE=1")]
[InlineData("NUMBER=3000000000")]
public void Selector_grammar_refuses(string body)
{
HeidenhainAlarmSelector.TryParse(body, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNullOrEmpty();
}
[Fact]
public void Selector_matching_and_negative_numbers()
{
HeidenhainAlarmSelector.TryParse("CLASS=7/NUMBER=-1", out var s, out _).ShouldBeTrue();
s!.Matches(7, 4, -1).ShouldBeTrue();
s.Matches(7, 4, 1).ShouldBeFalse();
s.Matches(6, 4, -1).ShouldBeFalse();
}
// ---- TagConfig pairing ----
[Fact]
public void An_alarm_selector_and_the_alarm_object_only_work_together()
{
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", AlarmTag("*", "a").TagConfig, out var def, out var error).ShouldBeTrue(error);
def!.IsAlarmCondition.ShouldBeTrue();
def.DataType.ShouldBe(DriverDataType.Boolean);
def.Alarm!.Severity.ShouldBe(800);
def.Group.ShouldBe(HeidenhainPollGroup.Alarm);
HeidenhainTagRules.ExpectedTagDataType(AlarmTag("*", "a").TagConfig).ShouldBe("Boolean");
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", AlarmTag("*", "a", withAlarm: false).TagConfig, out _, out error).ShouldBeFalse();
error!.ShouldContain("alarm");
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"Status/ExecutionState","alarm":{}}""", out _, out error).ShouldBeFalse();
error!.ShouldContain("not an ALARM: selector");
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"ALARM:*","dataType":"Int32","alarm":{}}""", out _, out _).ShouldBeFalse();
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"ALARM:*","isArray":true,"alarm":{}}""", out _, out _).ShouldBeFalse();
HeidenhainTagDefinitionFactory.Inspect(AlarmTag("*", "a").TagConfig).ShouldBeEmpty();
}
// ---- pure transition rules ----
private static HeidenhainAlarmTag Tag(string selector, int? severity = null) =>
new("Plant/H/dev1/a", "h:1", Parse(selector), severity);
private static HeidenhainAlarmSelector Parse(string body)
{
HeidenhainAlarmSelector.TryParse(body, out var s, out _).ShouldBeTrue();
return s!;
}
private static readonly IAlarmSubscriptionHandle H = new HeidenhainAlarmSubscriptionHandle(1);
[Fact]
public void Transitions_raise_retrigger_clear_and_ignore_reordering()
{
var tag = Tag("*", 900);
var a = new Lsv2Alarm(7, 4, -1, "Run test of cutout channels");
var b = new Lsv2Alarm(3, 1, 100, "Coolant low");
var all = new[] { tag.Selector };
var now = DateTime.UtcNow;
var (e1, t1) = HeidenhainAlarmRules.Transition(null, HeidenhainAlarmRules.Evaluate(tag.Selector, [a], all), tag, H, now);
e1!.Kind.ShouldBe(AlarmTransitionKind.Raise);
e1.ConditionId.ShouldBe(tag.RawPath);
e1.OpcUaSeverity.ShouldBe(900);
e1.Severity.ShouldBe(AlarmSeverity.Critical);
e1.Message.ShouldBe("-1 Run test of cutout channels (class 7, group 4)");
// unchanged → nothing
HeidenhainAlarmRules.Transition(t1, HeidenhainAlarmRules.Evaluate(tag.Selector, [a], all), tag, H, now).Event.ShouldBeNull();
// a new alarm (it also becomes the displayed one) → Retrigger
var (e2, t2) = HeidenhainAlarmRules.Transition(t1, HeidenhainAlarmRules.Evaluate(tag.Selector, [a, b], all), tag, H, now);
e2!.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
e2.Message.ShouldBe("100 Coolant low (class 3, group 1) [+1 more]");
// the same set in another wire order → nothing (deterministic display order)
HeidenhainAlarmRules.Transition(t2, HeidenhainAlarmRules.Evaluate(tag.Selector, [b, a], all), tag, H, now).Event.ShouldBeNull();
// the displayed alarm leaves while another remains → Retrigger with the next one down
var (e3, t3) = HeidenhainAlarmRules.Transition(t2, HeidenhainAlarmRules.Evaluate(tag.Selector, [a], all), tag, H, now);
e3!.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
var (e4, _) = HeidenhainAlarmRules.Transition(t3, [], tag, H, now);
e4!.Kind.ShouldBe(AlarmTransitionKind.Clear);
}
[Fact]
public void Other_is_what_no_filter_on_the_control_claims_and_severity_defaults_to_high()
{
var a = new Lsv2Alarm(7, 4, -1, "x");
var b = new Lsv2Alarm(3, 1, 100, "y");
var selectors = new[] { Parse("CLASS=7"), Parse("*"), Parse("OTHER") };
HeidenhainAlarmRules.Evaluate(Parse("OTHER"), [a, b], selectors).Select(k => k.Number).ShouldBe(new[] { 100 });
HeidenhainAlarmRules.Evaluate(Parse("*"), [a, b], selectors).Count.ShouldBe(2);
var (e, _) = HeidenhainAlarmRules.Transition(null, HeidenhainAlarmRules.Evaluate(Parse("OTHER"), [a, b], selectors),
Tag("OTHER"), H, DateTime.UtcNow);
e!.Severity.ShouldBe(AlarmSeverity.High);
e.OpcUaSeverity.ShouldBeNull();
}
// ---- end to end against the simulator ----
/// <summary>A control whose active-alarm list can be changed between walks (27 = first, 28 = next, 57 = end).</summary>
private sealed class AlarmList
{
private volatile byte[][] _records = [];
private int _cursor;
public void Set(params byte[][] records) => _records = records;
public void Install(SimulatedControl c)
{
c.Runtime[27] = () => { _cursor = 0; return Next(); };
c.Runtime[28] = Next;
}
private SimReply Next()
{
var r = _records;
return _cursor < r.Length ? SimReply.Ok("S_RI", r[_cursor++]) : SimReply.Refuse(57);
}
}
/// <summary>The captured iTNC 530 reply (Java fixture <c>hermle49.27</c>): class 7, group 4, number -1.</summary>
private static byte[] Golden() => SimReply.FromFixture(Fixtures.Load("itnc530-inspect.properties")["hermle49.27"]).Payload!;
private static byte[] Record(int cls, int group, int number, string text)
{
var t = Encoding.Latin1.GetBytes(text + "\0");
var b = new byte[8 + t.Length];
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)cls);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(2), (ushort)group);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(4), number);
t.CopyTo(b, 8);
return b;
}
private static HeidenhainDriverOptions Options(int port, params RawTagEntry[] tags) => new()
{
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }],
RawTags = tags,
TimeoutMs = 500,
Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 300, AlarmMs = 100 },
Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 200 },
};
private static HeidenhainDriver Create(HeidenhainDriverOptions o) =>
new(o, "hh-1", null, new HeidenhainCredentials(null), null, null);
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(20, Ct);
}
}
private static readonly string[] ReadOnlyCommands = ["A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP"];
[Fact]
public async Task Golden_controller_alarm_raises_and_clears_conditions_with_only_allowlisted_reads()
{
var control = new SimulatedControl();
var list = new AlarmList();
list.Install(control);
list.Set(Golden());
await using var sim = Lsv2Simulator.WithControl(control);
var any = AlarmTag("*", "Any");
var cls7 = AlarmTag("CLASS=7", "Class7");
var cls1 = AlarmTag("CLASS=1", "Class1");
var other = AlarmTag("OTHER", "Other");
await using var d = Create(Options(sim.Port, any, cls7, cls1, other));
var events = new ConcurrentQueue<AlarmEventArgs>();
d.OnAlarmEvent += (_, e) => events.Enqueue(e);
await d.InitializeAsync("{}", Ct);
(await d.ReadAsync([any.RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNotSupported); // a condition, not a value
await d.SubscribeAlarmsAsync([any.RawPath, cls7.RawPath, cls1.RawPath, other.RawPath], Ct);
await WaitUntil(() => events.Count(e => e.Kind == AlarmTransitionKind.Raise) >= 2);
await Task.Delay(400, Ct); // several more walks: an unchanged list re-raises nothing
var raises = events.Where(e => e.Kind == AlarmTransitionKind.Raise).ToList();
raises.Select(e => e.ConditionId).Order().ShouldBe(new[] { any.RawPath, cls7.RawPath }.Order());
raises.ShouldAllBe(e => e.Message == "-1 Run test of cutout channels (class 7, group 4)" && e.OpcUaSeverity == 800
&& e.SourceNodeId == $"127.0.0.1:{sim.Port}");
events.Count.ShouldBe(2);
list.Set();
await WaitUntil(() => events.Count(e => e.Kind == AlarmTransitionKind.Clear) == 2);
list.Set(Record(1, 2, 3, "Other alarm"), Golden());
await WaitUntil(() => events.Any(e => e.ConditionId == cls1.RawPath && e.Kind == AlarmTransitionKind.Raise));
await WaitUntil(() => events.Count(e => e.Kind == AlarmTransitionKind.Raise) == 5); // Any, Class7, Class1 again/new
events.ShouldNotContain(e => e.ConditionId == other.RawPath); // every alarm is claimed by a CLASS filter
// Read-only: only the allowlisted read set ever reached the wire, and every alarm walk is R_RI 27/28.
control.Violations.ShouldBeEmpty();
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(ReadOnlyCommands);
sim.Requests.Where(r => r.Command == "R_RI").Select(r => r.Selector).Distinct().Order().ShouldBe(new[] { 27, 28 });
d.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Acknowledge_is_local_only_and_never_touches_the_control()
{
var control = new SimulatedControl();
await using var sim = Lsv2Simulator.WithControl(control);
var any = AlarmTag("*", "Any");
await using var d = Create(Options(sim.Port, any));
await d.InitializeAsync("{}", Ct);
await d.AcknowledgeAsync([new AlarmAcknowledgeRequest($"127.0.0.1:{sim.Port}", any.RawPath, "seen", "op")], Ct);
await Task.Delay(200, Ct);
sim.Connections.ShouldBe(0); // no session was opened, so nothing at all was sent
sim.Requests.ShouldBeEmpty();
// And with a live session: the request log grows only by the poll's own allowlisted reads.
var list = new AlarmList();
list.Install(control);
list.Set(Golden());
await d.SubscribeAlarmsAsync([any.RawPath], Ct);
await WaitUntil(() => sim.Requests.Count(r => r.Command == "R_RI") >= 4);
await d.AcknowledgeAsync([new AlarmAcknowledgeRequest($"127.0.0.1:{sim.Port}", any.RawPath, "seen", "op")], Ct);
await Task.Delay(200, Ct);
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(ReadOnlyCommands);
control.Violations.ShouldBeEmpty();
}
[Fact]
public async Task Comms_loss_annotates_quality_once_and_recovery_never_re_raises_an_active_condition()
{
var control = new SimulatedControl();
var list = new AlarmList();
list.Install(control);
list.Set(Golden());
await using var sim = Lsv2Simulator.WithControl(control);
var any = AlarmTag("*", "Any");
await using var d = Create(Options(sim.Port, any));
var events = new ConcurrentQueue<AlarmEventArgs>();
d.OnAlarmEvent += (_, e) => events.Enqueue(e);
await d.InitializeAsync("{}", Ct);
await d.SubscribeAlarmsAsync([any.RawPath], Ct);
await WaitUntil(() => events.Any(e => e.Kind == AlarmTransitionKind.Raise));
sim.GoOffline();
await WaitUntil(() => events.Any(e => e.QualityStatusCode == HeidenhainStatus.BadNoCommunication));
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
await Task.Delay(500, Ct); // several failed reconnects: still ONE annotation
events.Count(e => e.IsQualityOnly && e.QualityStatusCode == HeidenhainStatus.BadNoCommunication).ShouldBe(1);
sim.GoOnline();
await WaitUntil(() => events.Any(e => e.IsQualityOnly && e.QualityStatusCode == HeidenhainStatus.Good), 8000);
await Task.Delay(400, Ct);
events.Count(e => e.Kind == AlarmTransitionKind.Raise).ShouldBe(1); // the same alarm is still active: no re-raise (no un-ack)
events.ShouldNotContain(e => e.Kind == AlarmTransitionKind.Retrigger || e.Kind == AlarmTransitionKind.Clear);
d.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_refused_alarm_walk_is_quality_only_and_does_not_degrade_health()
{
var control = new SimulatedControl();
control.Runtime[27] = () => SimReply.Refuse(49); // access denied
await using var sim = Lsv2Simulator.WithControl(control);
var any = AlarmTag("*", "Any");
await using var d = Create(Options(sim.Port, any));
var events = new ConcurrentQueue<AlarmEventArgs>();
d.OnAlarmEvent += (_, e) => events.Enqueue(e);
await d.InitializeAsync("{}", Ct);
await d.SubscribeAlarmsAsync([any.RawPath], Ct);
await WaitUntil(() => events.Any(e => e.QualityStatusCode == HeidenhainStatus.BadUserAccessDenied));
await Task.Delay(400, Ct);
events.ShouldAllBe(e => e.IsQualityOnly);
events.Count.ShouldBe(1);
d.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Alarm_events_are_recorded_only_while_a_handler_listens()
{
var control = new SimulatedControl();
var list = new AlarmList();
list.Install(control);
list.Set(Golden());
await using var sim = Lsv2Simulator.WithControl(control);
var any = AlarmTag("*", "Any");
await using var d = Create(Options(sim.Port, any));
await d.InitializeAsync("{}", Ct);
await d.SubscribeAlarmsAsync([any.RawPath], Ct);
await WaitUntil(() => sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 27) >= 3);
var events = new ConcurrentQueue<AlarmEventArgs>();
d.OnAlarmEvent += (_, e) => events.Enqueue(e);
await WaitUntil(() => events.Any(e => e.Kind == AlarmTransitionKind.Raise)); // nothing was "told" while detached
}
// ---- rediscovery ----
[Fact]
public async Task A_reconnect_with_a_different_identity_raises_rediscovery_once()
{
var control = new SimulatedControl();
control.Runtime[23] = () => SimulatedControl.U16(4);
await using var sim = Lsv2Simulator.WithControl(control);
var tag = TestTags.Raw("Status/ExecutionState");
await using var d = Create(Options(sim.Port, tag));
var raised = new ConcurrentQueue<RediscoveryEventArgs>();
d.OnRediscoveryNeeded += (_, e) => raised.Enqueue(e);
await d.InitializeAsync("{}", Ct);
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct);
await WaitUntil(() => d.GetHostStatuses().Single().State == HostState.Running);
var connections = sim.Connections;
// Same identity across a reconnect: baseline only, nothing raised.
sim.GoOffline();
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
sim.GoOnline();
await WaitUntil(() => sim.Connections > connections && d.GetHealth().State == DriverState.Healthy, 8000);
raised.ShouldBeEmpty();
// NC software update while it was away.
control.Version = Encoding.Latin1.GetBytes("iTNC 530\0NC 340490 08\0PLC\0OPT\0");
connections = sim.Connections;
sim.GoOffline();
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
sim.GoOnline();
await WaitUntil(() => !raised.IsEmpty, 8000);
await Task.Delay(300, Ct);
raised.Count.ShouldBe(1);
raised.Single().Reason.ShouldContain("340490 04");
raised.Single().Reason.ShouldContain("340490 08");
control.Violations.ShouldBeEmpty();
}
}
@@ -98,7 +98,7 @@ public sealed class NativeAlarmEditorModelTests
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
{
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy, DriverTypeNames.Heidenhain })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
@@ -47,6 +47,9 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<!-- #831: NativeAlarmDriverSetGuardTests discovers IAlarmSource implementers by scanning the driver DLLs
deployed here, so a driver that raises native alarms must be referenced or the guard misses it. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,164 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// lmxopcua#831 — a Heidenhain controller alarm reaches its Part 9 condition END TO END: the REAL
/// <see cref="HeidenhainDriver"/> against the in-process LSV2 simulator (replaying the captured iTNC 530 alarm
/// record), fed the raw tags exactly as the deployment artifact delivers them, raising into the REAL
/// <see cref="DriverHostActor"/>, whose projector must turn it into an Active and then an inactive condition at
/// the tag's RawPath (the FOCAS #647 lesson: a test that stops at the driver's event cannot see a routing miss).
/// </summary>
public sealed class HeidenhainNativeAlarmEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("heidenhain-alarm-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
private const string DriverId = "drv-hh";
private const string ExpectedRawPath = "Plant/Hh/m1/cnc_alarms";
/// <summary>Java fixture <c>hermle49.27</c>: class 7, group 4, number -1, "Run test of cutout channels".</summary>
private static readonly byte[] Golden =
Convert.FromHexString("00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300");
[Fact]
public async Task A_controller_alarm_activates_then_clears_the_condition_at_the_RawPath()
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var (host, publish) = SpawnHostAndApply(db, Seed(db, blob));
var rawTags = RawTagsFor(blob);
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
var control = new SimulatedControl();
var active = true;
control.Runtime[27] = () => active ? SimReply.Ok("S_RI", Golden) : SimReply.Refuse(57);
control.Runtime[28] = () => SimReply.Refuse(57);
await using var sim = Lsv2Simulator.WithControl(control);
await using var driver = new HeidenhainDriver(new HeidenhainDriverOptions
{
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "m1" }],
RawTags = rawTags,
TimeoutMs = 500,
Polling = new HeidenhainPollingOptions { AlarmMs = 100 },
}, DriverId);
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
#pragma warning disable OTOPCUA0001 // routing under test, not resilience wrapping (the FOCAS e2e precedent)
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
var raised = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5),
cancellationToken: TestContext.Current.CancellationToken);
raised.AlarmNodeId.ShouldBe(ExpectedRawPath);
raised.Realm.ShouldBe(AddressSpaceRealm.Raw);
raised.State.Active.ShouldBeTrue();
raised.State.Acknowledged.ShouldBeFalse();
raised.State.Severity.ShouldBe((ushort)800);
raised.State.Message.ShouldStartWith("-1 Run test of cutout channels");
active = false;
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(u => !u.State.Active, TimeSpan.FromSeconds(5),
cancellationToken: TestContext.Current.CancellationToken);
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
control.Violations.ShouldBeEmpty();
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_RI" });
}
private static List<RawTagEntry> RawTagsFor(byte[] blob)
{
using var artifact = DeploymentArtifact.Open(blob);
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == DriverId);
using var doc = JsonDocument.Parse(spec.DriverConfig);
return JsonSerializer.Deserialize<List<RawTagEntry>>(
doc.RootElement.GetProperty("RawTags").GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
// Enabled=false: the host spawns no driver — the test drives its own instance against the simulator.
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Hh", DriverType = "Heidenhain", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { Host = "127.0.0.1" }) },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-alarms",
DeviceId = "dev-m1",
TagGroupId = (string?)null,
Name = "cnc_alarms",
DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new
{
address = "ALARM:*",
dataType = "Boolean",
alarm = new { alarmType = "AlarmCondition", severity = 800 },
}),
},
},
});
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = Rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = blob,
});
ctx.SaveChanges();
return id;
}
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish);
}
}