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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user