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:
@@ -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&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=<r></c>, <c>GROUP=<r></c>, <c>NUMBER=<r></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><r></c> is <c>n</c> or <c>lo..hi</c> (inclusive; <c>..</c> rather than <c>-</c> because
|
||||
/// alarm numbers can be negative — a real iTNC 530 reports <c>-1</c>). CLASS and GROUP are the wire's uint16
|
||||
/// fields (0–65535); NUMBER is int32.</para>
|
||||
/// <para><b>No semantic names.</b> The meaning of the class/group codes is not documented in the reference
|
||||
/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed:
|
||||
/// observe the raw values on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message, then filter.</para>
|
||||
/// <para><b>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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+63
-6
@@ -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)..];
|
||||
|
||||
+4
@@ -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" />
|
||||
|
||||
+15
-2
@@ -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);
|
||||
|
||||
+9
-2
@@ -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>
|
||||
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user