Merge feat/830-heidenhain-followups

This commit is contained in:
Joseph Doherty
2026-11-11 01:55:10 -05:00
33 changed files with 1802 additions and 82 deletions
+10 -1
View File
@@ -61,7 +61,9 @@ design treated it as an implementation detail.
⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but
implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download
or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT,
MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an
MTConnect and MQTT raise natively and are unaffected. Heidenhain (#831) raises on the event-driven shape this
recommends: a reconnect whose `R_VR` identity (model / NC / PLC version / options) differs from the previous session —
no timer, no extra request. If that coverage matters later, reach for an
event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a
deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`,
`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
@@ -329,6 +331,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
+89 -9
View File
@@ -1,7 +1,7 @@
# Heidenhain LSV2 driver (read-only)
`DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) ·
Gitea **#803**.
Gitea **#803** (native alarms + rediscovery: **#831**; authoring UX: **#830**).
A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No
Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the
@@ -21,9 +21,12 @@ Three independent layers, each sufficient on its own for its concern:
manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the
save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older
(one rule, `HeidenhainTagRules`).
address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag
modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid
used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or
mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate
`HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI
path through the driver-dispatched `RawTagRowRules` (no call site names a driver).
3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks
command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s
public surface is pinned by a reflection test) and no configuration knob that widens it.
@@ -66,6 +69,7 @@ enums, so the numeric-enum form/factory trap cannot occur):
| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) |
| `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) |
| `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 |
| `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) |
| `Polling.MaxAlarms` | 64 | 1–256 |
| `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 |
| `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded |
@@ -90,8 +94,9 @@ Host at their own machine. Anything outside the namespace is refused by **every*
(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time**
(`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path**
(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the
driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the
deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value.
driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed
forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate
`HeidenhainCredentialPersisted`. Messages never echo the value.
Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset
variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the
@@ -147,6 +152,70 @@ truncates or wraps — otherwise `BadTypeMismatch`.
At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`).
## Alarm conditions (`ALARM:`, Gitea #831)
The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal
57 ends it). Besides the `Alarms/*` typed arrays (unchanged), the list can drive **native OPC UA Part 9 conditions**:
a raw tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object is
exactly one condition, `ConditionId` = the tag's RawPath (the key the host routes and fans on — raw device folder +
every referencing equipment folder, per the v3 native-alarm model). Same opt-in-per-tag shape as FOCAS #647 and
MTConnect #626.
```jsonc
{ "address": "ALARM:CLASS=7", "dataType": "Boolean",
"alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
```
| Address | Condition is active while… |
|---|---|
| `ALARM:*` | any alarm is active on the control (summary) |
| `ALARM:OTHER` | an alarm is active that no **filter** selector on the same control claims |
| `ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>]` (any subset, any order, each key once) | an alarm matches every named field |
`<r>` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP
are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the
reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or
a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the
driver.
- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the
`Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored
tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no
driver-level enable flag.
- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector
agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The
tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data
subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds
`{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one.
- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is
deterministic (ascending class, group, number, text), never wire order. Message:
`-1 Run test of cutout channels (class 7, group 4) [+N more]`.
- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed
alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a
non-displayed member leaves → nothing; nothing matches → `Clear`.
- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No
class→severity table is invented.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the
driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its
first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept,
`NativeAlarmProjector` #647).
- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 →
`BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss
(`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only
event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down
session degrades health (the existing rule) — a refusal never does.
- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened:
`AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and
pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are
refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset;
an operator ack never reaches it.
- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop
every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists
(`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged).
- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the
alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves.
- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`).
## Quality
| Condition | Value status / capability |
@@ -183,6 +252,11 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re
- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
disposed loop is a no-op.
- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control
reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous
session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason —
the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first
session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic.
- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with
`LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and
decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one
@@ -202,7 +276,13 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no
any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed
as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three).
- `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`.
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet
- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured
iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal,
handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set.
- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition.
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`,
`AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single
credential notice) + the fleet
parity suites.
- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional
`HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
@@ -223,5 +303,5 @@ No real control was available for this port. Still open:
Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE
roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH,
PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are
published as typed arrays).
PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm
conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)).
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server), MTConnect
/// (CONDITION data items).
/// (CONDITION data items), AbCip (ALMD/ALMA), Heidenhain (LSV2 controller alarm list, #831).
/// </summary>
public interface IAlarmSource
{
@@ -30,6 +30,12 @@ public enum HeidenhainAddressKind
/// <summary>A data-path value read via <c>R_DP</c> (DATA role).</summary>
DataPath,
/// <summary>
/// An <c>ALARM:</c> selector — a native Part 9 condition driven from the alarm list (<c>R_RI 27/28</c>), never a
/// value (lmxopcua#831). Requires the cross-driver <c>alarm</c> object on the tag.
/// </summary>
Alarm,
}
/// <summary>A parsed, validated Heidenhain tag address.</summary>
@@ -39,13 +45,15 @@ public enum HeidenhainAddressKind
/// <param name="Area">The PLC area, for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="Index">The PLC <b>element</b> index (not an absolute address), for <see cref="HeidenhainAddressKind.Plc"/>.</param>
/// <param name="DataPath">The data path as authored, for <see cref="HeidenhainAddressKind.DataPath"/>.</param>
/// <param name="AlarmSelector">The alarm selector, for <see cref="HeidenhainAddressKind.Alarm"/>.</param>
public sealed record HeidenhainAddress(
HeidenhainAddressKind Kind,
string Text,
HeidenhainCatalogEntry? Catalog = null,
HeidenhainPlcArea? Area = null,
long Index = 0,
string? DataPath = null)
string? DataPath = null,
HeidenhainAlarmSelector? AlarmSelector = null)
{
/// <summary>Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).</summary>
public const int MaxPayload = 247;
@@ -117,6 +125,14 @@ public sealed record HeidenhainAddress(
return false;
}
if (t.StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal))
{
if (!HeidenhainAlarmSelector.TryParse(t[HeidenhainAlarmSelector.Prefix.Length..], out var selector, out error)) return false;
address = new HeidenhainAddress(HeidenhainAddressKind.Alarm, HeidenhainAlarmSelector.Prefix + selector.Canonical,
AlarmSelector: selector);
return true;
}
if (t.StartsWith(DataPrefix, StringComparison.Ordinal))
{
var path = t[DataPrefix.Length..];
@@ -157,7 +173,7 @@ public sealed record HeidenhainAddress(
return true;
}
error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding.";
error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding, a DATA:/path binding or an ALARM: selector.";
return false;
}
@@ -0,0 +1,191 @@
using System.Globalization;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>Which controller alarms an <c>ALARM:</c> address selects.</summary>
public enum HeidenhainAlarmSelectorKind
{
/// <summary><c>ALARM:*</c> — any active alarm on the tag's control (a device summary condition).</summary>
Any,
/// <summary>
/// <c>ALARM:OTHER</c> — active alarms that no <see cref="Filter"/> selector on the same control matches (the
/// "unmatched" bucket). <see cref="Any"/> and other <see cref="Other"/> tags do not claim an alarm.
/// </summary>
Other,
/// <summary><c>ALARM:CLASS=7</c>, <c>ALARM:CLASS=7/GROUP=4</c>, <c>ALARM:NUMBER=100..199</c>, …</summary>
Filter,
}
/// <summary>An inclusive integer range; <see cref="Low"/> == <see cref="High"/> for a single value.</summary>
/// <param name="Low">Lowest matched value.</param>
/// <param name="High">Highest matched value (inclusive).</param>
public readonly record struct HeidenhainAlarmRange(long Low, long High)
{
/// <summary>Whether <paramref name="value"/> lies in the range.</summary>
/// <param name="value">The value.</param>
/// <returns>True when matched.</returns>
public bool Contains(long value) => value >= Low && value <= High;
/// <inheritdoc />
public override string ToString() => Low == High
? Low.ToString(CultureInfo.InvariantCulture)
: $"{Low.ToString(CultureInfo.InvariantCulture)}..{High.ToString(CultureInfo.InvariantCulture)}";
}
/// <summary>
/// The selector half of an <c>ALARM:</c> Heidenhain address (lmxopcua#831): which entries of the control's
/// active-alarm list (<c>R_RI 27</c> then <c>28</c> — class, group, number, text) drive the ONE Part 9 condition
/// the raw tag is materialised as.
/// </summary>
/// <remarks>
/// <para>Grammar of the text after <c>ALARM:</c> (keys case-insensitive, no whitespace):</para>
/// <list type="table">
/// <listheader><term>Form</term><description>Matches</description></listheader>
/// <item><term><c>*</c></term><description>any active alarm on the control</description></item>
/// <item><term><c>OTHER</c></term><description>alarms no filter selector on the control matches</description></item>
/// <item><term><c>CLASS=&lt;r&gt;</c>, <c>GROUP=&lt;r&gt;</c>, <c>NUMBER=&lt;r&gt;</c>, joined by <c>/</c></term>
/// <description>every named field in its range (each key at most once)</description></item>
/// </list>
/// <para>A range <c>&lt;r&gt;</c> is <c>n</c> or <c>lo..hi</c> (inclusive; <c>..</c> rather than <c>-</c> because
/// alarm numbers can be negative — a real iTNC 530 reports <c>-1</c>). CLASS and GROUP are the wire's uint16
/// fields (0–65535); NUMBER is int32.</para>
/// <para><b>No semantic names.</b> The meaning of the class/group codes is not documented in the reference
/// (pyLSV2 / the Java port keep them raw), so — like FOCAS's unconfirmed 30i type table — nothing is guessed:
/// observe the raw values on <c>Alarms/Classes</c> / <c>Alarms/Groups</c> or in a condition message, then filter.</para>
/// <para><b>One authority.</b> Backs the AdminUI editor, the deploy inspector and the driver's table build.</para>
/// </remarks>
/// <param name="Kind">The selector kind.</param>
/// <param name="Class">The class range, or null for any class (filter only).</param>
/// <param name="Group">The group range, or null for any group (filter only).</param>
/// <param name="Number">The number range, or null for any number (filter only).</param>
public sealed record HeidenhainAlarmSelector(
HeidenhainAlarmSelectorKind Kind,
HeidenhainAlarmRange? Class = null,
HeidenhainAlarmRange? Group = null,
HeidenhainAlarmRange? Number = null)
{
/// <summary>Address prefix of an alarm condition.</summary>
public const string Prefix = "ALARM:";
/// <summary>The <c>*</c> body.</summary>
public const string AnyToken = "*";
/// <summary>The <c>OTHER</c> body.</summary>
public const string OtherToken = "OTHER";
/// <summary>The canonical text after <c>ALARM:</c>.</summary>
public string Canonical => Kind switch
{
HeidenhainAlarmSelectorKind.Any => AnyToken,
HeidenhainAlarmSelectorKind.Other => OtherToken,
_ => string.Join("/", new[]
{
Class is { } c ? $"CLASS={c}" : null,
Group is { } g ? $"GROUP={g}" : null,
Number is { } n ? $"NUMBER={n}" : null,
}.Where(s => s is not null)),
};
/// <summary>
/// Whether this selector matches one alarm on its own terms. <see cref="HeidenhainAlarmSelectorKind.Other"/>
/// answers false here — "unmatched" depends on the other selectors on the control, which the caller evaluates.
/// </summary>
/// <param name="errorClass">The alarm's class.</param>
/// <param name="group">The alarm's group.</param>
/// <param name="number">The alarm's number.</param>
/// <returns>True when selected.</returns>
public bool Matches(int errorClass, int group, int number) => Kind switch
{
HeidenhainAlarmSelectorKind.Any => true,
HeidenhainAlarmSelectorKind.Other => false,
_ => (Class is not { } c || c.Contains(errorClass))
&& (Group is not { } g || g.Contains(group))
&& (Number is not { } n || n.Contains(number)),
};
/// <summary>Parses the text after <c>ALARM:</c>. Never throws.</summary>
/// <param name="body">The selector text.</param>
/// <param name="selector">The selector on success.</param>
/// <param name="error">Why it was rejected.</param>
/// <returns>True when valid.</returns>
public static bool TryParse(string? body, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAlarmSelector? selector, out string? error)
{
selector = null;
error = null;
var t = body?.Trim() ?? "";
if (t.Length == 0)
{
error = Grammar("an alarm selector is required");
return false;
}
if (t == AnyToken)
{
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Any);
return true;
}
if (string.Equals(t, OtherToken, StringComparison.OrdinalIgnoreCase))
{
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Other);
return true;
}
HeidenhainAlarmRange? cls = null, grp = null, num = null;
foreach (var segment in t.Split('/'))
{
var eq = segment.IndexOf('=');
if (eq <= 0)
{
error = Grammar($"'{segment}' is not KEY=VALUE");
return false;
}
var key = segment[..eq].ToUpperInvariant();
var value = segment[(eq + 1)..];
var (min, max) = key == "NUMBER" ? ((long)int.MinValue, (long)int.MaxValue) : (0L, 65535L);
if (key is not ("CLASS" or "GROUP" or "NUMBER"))
{
error = Grammar($"unknown key '{segment[..eq]}'");
return false;
}
if (!TryParseRange(value, min, max, out var range))
{
error = Grammar($"{key} value '{value}' must be an integer {min}–{max} or lo..hi with lo ≤ hi");
return false;
}
ref var slot = ref key == "CLASS" ? ref cls : ref key == "GROUP" ? ref grp : ref num;
if (slot is not null)
{
error = Grammar($"{key} appears twice");
return false;
}
slot = range;
}
selector = new HeidenhainAlarmSelector(HeidenhainAlarmSelectorKind.Filter, cls, grp, num);
return true;
}
private static bool TryParseRange(string text, long min, long max, out HeidenhainAlarmRange range)
{
range = default;
var dots = text.IndexOf("..", StringComparison.Ordinal);
var loText = dots < 0 ? text : text[..dots];
var hiText = dots < 0 ? text : text[(dots + 2)..];
if (!long.TryParse(loText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var lo)
|| !long.TryParse(hiText, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var hi))
return false;
if (lo < min || hi > max || lo > hi) return false;
range = new HeidenhainAlarmRange(lo, hi);
return true;
}
private static string Grammar(string why) =>
$"Invalid alarm selector: {why}. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
}
@@ -13,6 +13,9 @@ public enum HeidenhainPollGroup
/// <summary>Identity, tool, alarms, timers and capability strings (<c>Polling.SlowMs</c>).</summary>
Slow,
/// <summary>The alarm-list walk behind <c>ALARM:</c> Part 9 conditions (<c>Polling.AlarmMs</c>, lmxopcua#831).</summary>
Alarm,
}
/// <summary>One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.</summary>
@@ -50,6 +53,9 @@ public static class HeidenhainCatalog
/// <summary>Feature id reported for the optional DNC login.</summary>
public const string DncLoginFeature = "DncLogin";
/// <summary>Feature id of the alarm-list walk (<c>R_RI 27/28</c>) — shared by the <c>Alarms/*</c> leaves and <c>ALARM:</c> conditions.</summary>
public const string AlarmsFeature = "Alarms";
/// <summary>Feature id reported for the opt-in DATA login.</summary>
public const string DataLoginFeature = "DataLogin";
@@ -107,6 +107,13 @@ public sealed class HeidenhainPollingOptions
/// <summary>Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.</summary>
public int SlowMs { get; init; } = 10000;
/// <summary>
/// Alarm-condition group: the alarm-list walk that drives authored <c>ALARM:</c> Part 9 conditions
/// (lmxopcua#831). Default 2000 ms (the FOCAS alarm-poll precedent). Only polled for a control that carries an
/// <c>ALARM:</c> tag; the <c>Alarms/*</c> typed arrays stay on <see cref="SlowMs"/>.
/// </summary>
public int AlarmMs { get; init; } = 2000;
/// <summary>Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.</summary>
public int MaxAlarms { get; init; } = 64;
@@ -110,7 +110,7 @@ public static class HeidenhainOptionsParser
var errors = new List<string>();
if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
var p = o.Polling;
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs), ("AlarmMs", p.AlarmMs) })
if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
var r = o.Reconnect;
@@ -9,15 +9,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <param name="Address">The parsed address.</param>
/// <param name="DataType">The OPC data type the driver publishes.</param>
/// <param name="IsArray">Whether the value is a 1-D array.</param>
/// <param name="Alarm">
/// The cross-driver <c>alarm</c> object, present exactly when <see cref="Address"/> is an <c>ALARM:</c> selector
/// (lmxopcua#831); the tag is then a Part 9 condition, never a value.
/// </param>
public sealed record HeidenhainTagDefinition(
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray,
HeidenhainTagAlarm? Alarm = null)
{
/// <summary>The poll cadence this tag rides.</summary>
public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
? Address.Catalog!.Group
: HeidenhainPollGroup.Custom;
public HeidenhainPollGroup Group => Address.Kind switch
{
HeidenhainAddressKind.Standard => Address.Catalog!.Group,
HeidenhainAddressKind.Alarm => HeidenhainPollGroup.Alarm,
_ => HeidenhainPollGroup.Custom,
};
/// <summary>True for an <c>ALARM:</c> condition tag.</summary>
public bool IsAlarmCondition => Address.Kind == HeidenhainAddressKind.Alarm;
}
/// <summary>The driver's view of a tag's cross-driver <c>alarm</c> object.</summary>
/// <param name="Severity">The authored integer <c>alarm.severity</c>, or null when absent / not an integer.</param>
public sealed record HeidenhainTagAlarm(int? Severity);
/// <summary>
/// The single authority on a Heidenhain raw tag's <c>TagConfig</c>: consumed by the driver's table build,
/// the deploy-time gate (<see cref="Inspect"/>) and the AdminUI typed editor's <c>Validate()</c>, so the
@@ -29,6 +44,10 @@ public sealed record HeidenhainTagDefinition(
/// <c>{"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}</c>. Array leaves
/// (<c>Axes/*</c>, <c>Alarms/Classes|Groups|Numbers|Messages</c>) must carry <c>"isArray": true</c> so the
/// node's rank matches what the driver publishes; every other address must not.</para>
/// <para><b>Alarm conditions</b> (lmxopcua#831): <c>{"address": "ALARM:CLASS=7", "dataType": "Boolean",
/// "alarm": {...}}</c>. An <c>ALARM:</c> address and the <c>alarm</c> object only work together — either alone is
/// refused (a condition nothing can raise, or a selector with no value to read). The data type is fixed at
/// <c>Boolean</c>.</para>
/// <para><b>Never a write flag.</b> There is no <c>writable</c> key: the driver is read-only and the deploy
/// gate refuses a ReadWrite access level on any Heidenhain tag.</para>
/// <para><b>Data types are parsed by NAME only.</b> A digits-only value (a numerically-serialized enum) is
@@ -59,6 +78,7 @@ public static class HeidenhainTagDefinitionFactory
string? dataTypeText;
bool dataTypePresent;
bool? isArray;
HeidenhainTagAlarm? alarm;
try
{
using var doc = JsonDocument.Parse(tagConfig);
@@ -95,6 +115,15 @@ public static class HeidenhainTagDefinitionFactory
}
}
alarm = null;
// Exact-case, exactly as the host's TagConfigIntent reads it: the condition exists iff the host sees it.
if (root.TryGetProperty("alarm", out var a) && a.ValueKind == JsonValueKind.Object)
{
int? severity = a.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.Number
&& sev.TryGetInt32(out var sv) ? sv : null;
alarm = new HeidenhainTagAlarm(severity);
}
if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True)
{
error = "Heidenhain tags are read-only; remove \"writable\": true.";
@@ -121,9 +150,34 @@ public static class HeidenhainTagDefinitionFactory
declared = parsed;
}
if (address.Kind == HeidenhainAddressKind.Alarm && alarm is null)
{
error = $"'{address.Text}' is an alarm selector — enable the tag's native alarm (the 'alarm' object); " +
"an ALARM: tag is a Part 9 condition and has no value to read.";
return false;
}
if (address.Kind != HeidenhainAddressKind.Alarm && alarm is not null)
{
error = $"The tag carries an 'alarm' object but '{address.Text}' is not an ALARM: selector — nothing could " +
"ever raise the condition. Use ALARM:*, ALARM:OTHER or ALARM:CLASS=<n>[/GROUP=<n>][/NUMBER=<n>|<lo>..<hi>].";
return false;
}
DriverDataType type;
bool array;
if (address.Kind == HeidenhainAddressKind.Standard)
if (address.Kind == HeidenhainAddressKind.Alarm)
{
if (declared is { } d && d != AlarmConditionDataType)
{
error = $"An ALARM: condition tag is {AlarmConditionDataType}; dataType {d} does not match.";
return false;
}
type = AlarmConditionDataType;
array = false;
}
else if (address.Kind == HeidenhainAddressKind.Standard)
{
var entry = address.Catalog!;
if (declared is { } d && d != entry.DataType)
@@ -172,10 +226,13 @@ public static class HeidenhainTagDefinitionFactory
return false;
}
definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array);
definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array, alarm);
return true;
}
/// <summary>The fixed <c>Tag.DataType</c> of an <c>ALARM:</c> condition tag (active / inactive).</summary>
public const DriverDataType AlarmConditionDataType = DriverDataType.Boolean;
/// <summary>
/// Deploy-time inspection: empty when <see cref="TryFromTagConfig"/> accepts the blob (never stricter),
/// otherwise one warning explaining the rejection. Never throws.
@@ -0,0 +1,287 @@
using System.Text;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for.</summary>
/// <param name="RawPath">The tag's RawPath — the ConditionId, the only key the host routes on.</param>
/// <param name="HostName">The control (<c>host:port</c>) whose alarm list the tag selects from.</param>
/// <param name="Selector">Which alarms on that control drive the condition.</param>
/// <param name="AuthoredSeverity">The authored <c>alarm.severity</c>, or null.</param>
internal sealed record HeidenhainAlarmTag(string RawPath, string HostName, HeidenhainAlarmSelector Selector, int? AuthoredSeverity);
/// <summary>
/// A controller alarm's identity: class, group, number AND text. The number alone is not an identity — a real
/// iTNC 530 reports <c>-1</c> for (at least) "Run test of cutout channels" — so two different texts under one
/// number are two alarms.
/// </summary>
internal readonly record struct HeidenhainAlarmKey(int Class, int Group, int Number, string Message)
{
public static HeidenhainAlarmKey Of(Lsv2Alarm a) => new(a.ErrorClass, a.Group, a.Number, (a.Message ?? "").Trim());
}
/// <summary>What one condition last told the server — the diff baseline (re-subscribe / reconnect never re-raise).</summary>
internal sealed record HeidenhainToldCondition(
bool Active,
IReadOnlySet<HeidenhainAlarmKey> Keys,
HeidenhainAlarmKey? Winner,
string Message,
AlarmSeverity Severity,
int? OpcUaSeverity);
/// <summary>
/// Pure selection + transition rules for Heidenhain alarm conditions (lmxopcua#831) — the FOCAS #647 semantics
/// applied to the LSV2 alarm list.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><b>Overlap is intended</b>: one alarm drives every selector that matches it (<c>*</c> and
/// <c>CLASS=7</c>). <c>OTHER</c> is what no FILTER selector on the same control claims.</item>
/// <item><b>Displayed alarm is deterministic</b> — ascending (class, group, number, text), never wire order,
/// so a reordered unchanged list cannot re-raise.</item>
/// <item><b>Transitions</b>: inactive → active = <c>Raise</c>; a key not in the told set appears, or the
/// displayed alarm leaves while others remain = <c>Retrigger</c> (un-acknowledges: a statement the operator
/// has not seen); a non-displayed member leaves = nothing; nothing matches = <c>Clear</c>.</item>
/// <item><b>Severity</b>: an authored integer <c>alarm.severity</c> is sent exactly (<c>OpcUaSeverity</c>);
/// otherwise the bucket is High and no exact value is sent. Class semantics are undocumented, so no
/// class→severity table is invented.</item>
/// </list>
/// </remarks>
internal static class HeidenhainAlarmRules
{
/// <summary>The <see cref="AlarmEventArgs.AlarmType"/> every Heidenhain condition event carries.</summary>
public const string AlarmType = "HeidenhainAlarm";
/// <summary>The alarms one condition matches, in display order (distinct by key).</summary>
public static IReadOnlyList<HeidenhainAlarmKey> Evaluate(
HeidenhainAlarmSelector selector, IReadOnlyList<Lsv2Alarm> alarms, IReadOnlyCollection<HeidenhainAlarmSelector> deviceSelectors)
{
var filters = selector.Kind == HeidenhainAlarmSelectorKind.Other
? deviceSelectors.Where(s => s.Kind == HeidenhainAlarmSelectorKind.Filter).ToList()
: null;
return alarms
.Where(a => filters is null
? selector.Matches(a.ErrorClass, a.Group, a.Number)
: !filters.Any(f => f.Matches(a.ErrorClass, a.Group, a.Number)))
.Select(HeidenhainAlarmKey.Of)
.Distinct()
.OrderBy(k => k.Class).ThenBy(k => k.Group).ThenBy(k => k.Number)
.ThenBy(k => k.Message, StringComparer.Ordinal)
.ToList();
}
/// <summary>The event moving the server from <paramref name="told"/> to <paramref name="matched"/>, and the new record.</summary>
public static (AlarmEventArgs? Event, HeidenhainToldCondition Told) Transition(
HeidenhainToldCondition? told, IReadOnlyList<HeidenhainAlarmKey> matched, HeidenhainAlarmTag tag,
IAlarmSubscriptionHandle handle, DateTime nowUtc)
{
var keys = matched.ToHashSet();
if (matched.Count == 0)
{
if (told is not { Active: true })
return (null, told ?? new HeidenhainToldCondition(false, keys, null, "", AlarmSeverity.Low, null));
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Clear, $"Cleared: {told.Message}", told.Severity, told.OpcUaSeverity),
told with { Active = false, Keys = keys, Winner = null });
}
var winner = matched[0];
var (bucket, exact) = tag.AuthoredSeverity is { } s ? (BucketOf(s), (int?)s) : (AlarmSeverity.High, null);
var message = MessageOf(matched);
var raised = new HeidenhainToldCondition(true, keys, winner, message, bucket, exact);
if (told is not { Active: true })
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Raise, message, bucket, exact), raised);
if (keys.Any(k => !told.Keys.Contains(k)) || told.Winner != winner)
return (Build(tag, handle, nowUtc, AlarmTransitionKind.Retrigger, message, bucket, exact), raised);
return (null, told with { Keys = keys });
}
/// <summary>The nearest bucket of an exact 1..1000 severity.</summary>
public static AlarmSeverity BucketOf(int severity) => severity switch
{
< 400 => AlarmSeverity.Low,
< 600 => AlarmSeverity.Medium,
< 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// <summary><c>-1 Run test of cutout channels (class 7, group 4) [+2 more]</c>.</summary>
public static string MessageOf(IReadOnlyList<HeidenhainAlarmKey> matched)
{
var w = matched[0];
var b = new StringBuilder();
b.Append(w.Number);
if (w.Message.Length > 0) b.Append(' ').Append(w.Message);
b.Append(" (class ").Append(w.Class).Append(", group ").Append(w.Group).Append(')');
if (matched.Count > 1) b.Append(" [+").Append(matched.Count - 1).Append(" more]");
return b.ToString();
}
public static AlarmEventArgs Build(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, DateTime nowUtc,
AlarmTransitionKind kind, string message, AlarmSeverity bucket, int? exact, uint? quality = null) =>
new(handle,
SourceNodeId: tag.HostName,
ConditionId: tag.RawPath,
AlarmType: AlarmType,
Message: message,
Severity: bucket,
SourceTimestampUtc: nowUtc,
Kind: kind,
OpcUaSeverity: exact,
QualityStatusCode: quality);
}
/// <summary>
/// Drives one Part 9 condition per subscribed <c>ALARM:</c> raw tag from the alarm walks the device loops already
/// perform (lmxopcua#831). There is NO extra connection and NO extra request type: the walk is the same
/// allowlisted <c>R_RI 27/28</c> the <c>Alarms/*</c> arrays use, issued by the control's single acquisition loop on
/// the <c>Polling.AlarmMs</c> cadence.
/// </summary>
/// <remarks>
/// <para><b>Lives as long as the driver</b> (not re-created on re-initialise), so the told record survives a
/// reconnect, a same-set re-subscribe and an in-place re-init — re-raising there would un-acknowledge. A changed
/// subscribed set prunes only the RawPaths that left it.</para>
/// <para><b>A failed walk is not "no alarms".</b> A refusal, a list overflow, a decode error or comms loss keeps
/// every condition's Active/Acked state and annotates its quality once (quality-only event, no <c>/alerts</c>
/// row); the next good walk lifts it to Good before diffing. Only a down session degrades driver health (the
/// runtime's rule) — a refusal never does, or the #527 watchdog would reconnect-loop a production control.</para>
/// <para><b>Read-only acknowledgement.</b> LSV2 has no acknowledge request in the allowlist, and none is added:
/// <see cref="HeidenhainDriver.AcknowledgeAsync"/> is the no-op success the host routes a Part 9 Acknowledge to
/// (the FOCAS / MTConnect posture) — the ack is applied server-side only; the control clears an alarm when its
/// cause is reset. Confirm / shelve / enable are refused by the host for every native condition.</para>
/// <para>Recorded only while a handler is attached (the host detaches while it reconnects), so the re-subscribe
/// that follows still sees the difference. Delivery runs under <see cref="_lock"/>; the only production handler
/// is <c>DriverInstanceActor</c>'s non-blocking <c>Tell</c>.</para>
/// </remarks>
internal sealed class HeidenhainAlarmProjection(object sender, Func<EventHandler<AlarmEventArgs>?> handlers, ILogger logger)
{
private readonly Lock _lock = new();
private readonly Dictionary<string, HeidenhainToldCondition> _told = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _qualityTold = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _failing = new(StringComparer.Ordinal);
private IReadOnlyDictionary<string, HeidenhainAlarmTag> _tags = new Dictionary<string, HeidenhainAlarmTag>();
private Subscription? _current;
private long _nextId;
/// <summary>The authored alarm tags (RawPath → tag). Swapped whole on every table build.</summary>
public IReadOnlyDictionary<string, HeidenhainAlarmTag> Tags
{
get => Volatile.Read(ref _tags);
set => Volatile.Write(ref _tags, value);
}
/// <summary>The live subscription (test seam).</summary>
internal Subscription? Current => Volatile.Read(ref _current);
public IAlarmSubscriptionHandle Subscribe(IReadOnlyList<string> sourceNodeIds)
{
ArgumentNullException.ThrowIfNull(sourceNodeIds);
var refs = sourceNodeIds.Where(r => !string.IsNullOrWhiteSpace(r)).ToHashSet(StringComparer.Ordinal);
var sub = new Subscription(new HeidenhainAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
lock (_lock)
{
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone);
// A (re)subscribe follows a host-side reconnect, where the host re-annotates its conditions; forget which
// ones this projection marked, so a still-failing control re-asserts its quality on the next walk.
_qualityTold.Clear();
Volatile.Write(ref _current, sub);
}
return sub.Handle;
}
public void Unsubscribe(IAlarmSubscriptionHandle handle)
{
lock (_lock)
{
if (Volatile.Read(ref _current) is { } live && ReferenceEquals(live.Handle, handle)) Volatile.Write(ref _current, null);
}
}
/// <summary>One alarm-walk outcome of <paramref name="hostName"/> (Good + list, or a non-Good status).</summary>
public void OnWalk(string hostName, uint status, IReadOnlyList<Lsv2Alarm>? alarms)
{
var now = DateTime.UtcNow;
var tags = Tags;
lock (_lock)
{
if (Volatile.Read(ref _current) is not { } sub) return;
var deviceTags = tags.Values.Where(t => t.HostName == hostName).ToList();
var subscribed = deviceTags.Where(t => sub.References.Contains(t.RawPath)).ToList();
if (subscribed.Count == 0) return;
if (status != HeidenhainStatus.Good || alarms is null)
{
if (!_failing.TryGetValue(hostName, out var was) || was != status)
{
_failing[hostName] = status;
logger.LogWarning(
"Heidenhain {Host}: alarm list unavailable ({Status}); its {Count} alarm condition(s) keep their state and are marked with that quality",
hostName, HeidenhainStatus.State(status), subscribed.Count);
}
}
else if (_failing.Remove(hostName))
{
logger.LogInformation("Heidenhain {Host}: alarm list readable again", hostName);
}
var h = handlers();
if (h is null) return; // nobody listening: record nothing, the re-subscribe diffs again
if (status != HeidenhainStatus.Good || alarms is null)
{
foreach (var tag in subscribed)
{
if (_qualityTold.TryGetValue(tag.RawPath, out var q) && q == status) continue;
_qualityTold[tag.RawPath] = status;
Deliver(h, QualityOnly(tag, sub.Handle, status, now));
}
return;
}
var selectors = deviceTags.Select(t => t.Selector).ToList();
foreach (var tag in subscribed)
{
// Lift a non-Good this projection put there BEFORE the diff, so recovery + a change reads Good then the transition.
if (_qualityTold.Remove(tag.RawPath)) Deliver(h, QualityOnly(tag, sub.Handle, HeidenhainStatus.Good, now));
_told.TryGetValue(tag.RawPath, out var told);
var (evt, nowTold) = HeidenhainAlarmRules.Transition(
told, HeidenhainAlarmRules.Evaluate(tag.Selector, alarms, selectors), tag, sub.Handle, now);
_told[tag.RawPath] = nowTold;
if (evt is not null) Deliver(h, evt);
}
}
}
private AlarmEventArgs QualityOnly(HeidenhainAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
{
_told.TryGetValue(tag.RawPath, out var told);
return HeidenhainAlarmRules.Build(tag, handle, now, AlarmTransitionKind.Unspecified, told?.Message ?? "",
told?.Severity ?? AlarmSeverity.Low, told?.OpcUaSeverity, quality);
}
private void Deliver(EventHandler<AlarmEventArgs> h, AlarmEventArgs evt)
{
foreach (var registration in h.GetInvocationList())
{
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(sender, evt); }
catch (Exception ex)
{
logger.LogWarning(ex, "Heidenhain alarm subscriber threw for {ConditionId}; delivery continues", evt.ConditionId);
}
}
}
/// <summary>The live subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
internal sealed record Subscription(HeidenhainAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
}
/// <summary>Handle returned by <see cref="HeidenhainDriver.SubscribeAlarmsAsync"/>.</summary>
/// <param name="Id">The driver-local subscription number.</param>
public sealed record HeidenhainAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => $"heidenhain-alarm-sub-{Id}";
}
@@ -26,6 +26,15 @@ public enum HeidenhainConnectionState
/// <summary>Builds the client for an endpoint (test seam).</summary>
internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
/// <summary>
/// The alarm-walk outcome for a control's <c>ALARM:</c> conditions (lmxopcua#831): <paramref name="status"/> Good
/// with the complete list, or a non-Good status (refusal, overflow, comms loss) with <c>null</c> — never "no alarms".
/// </summary>
internal delegate void HeidenhainAlarmSink(HeidenhainDeviceRuntime runtime, uint status, IReadOnlyList<Lsv2Alarm>? alarms);
/// <summary>A session's controller identity, reported once per established session (lmxopcua#831 rediscovery).</summary>
internal delegate void HeidenhainIdentitySink(HeidenhainDeviceRuntime runtime, Lsv2Versions identity);
/// <summary>
/// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction
/// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache
@@ -54,6 +63,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private readonly Lsv2ClientFactory _clientFactory;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly HeidenhainAlarmSink? _alarms;
private readonly HeidenhainIdentitySink? _identity;
private readonly IReadOnlyList<HeidenhainTagDefinition> _alarmTags;
private readonly object _publication = new();
private readonly object _lifecycle = new();
private readonly Dictionary<HeidenhainPollGroup, bool> _groupGood = new();
@@ -81,7 +93,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
Action<HeidenhainDeviceRuntime, HeidenhainConnectionState, HeidenhainConnectionState> stateChanged,
Lsv2ClientFactory clientFactory,
TimeProvider time,
ILogger logger)
ILogger logger,
HeidenhainAlarmSink? alarms = null,
HeidenhainIdentitySink? identity = null)
{
_device = device;
_options = HeidenhainOptionsParser.Normalize(options);
@@ -92,6 +106,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
_clientFactory = clientFactory;
_time = time;
_logger = logger;
_alarms = alarms;
_identity = identity;
_alarmTags = tags.Where(t => t.IsAlarmCondition).ToList();
_stateChangedUtc = time.GetUtcNow().UtcDateTime;
_groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList<HeidenhainTagDefinition>)g.ToList());
}
@@ -220,6 +237,12 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
SetState(HeidenhainConnectionState.Connected, null);
_logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
if (session.Identity is { } identity && _identity is not null)
{
try { _identity(this, identity); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} identity handler threw", HostName); }
}
await PollAsync(session, generation, ct).ConfigureAwait(false);
// Poll returns only on session loss.
Retire(generation, "LSV2 communication lost");
@@ -254,6 +277,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
[HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)),
[HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)),
[HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)),
[HeidenhainPollGroup.Alarm] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.AlarmMs)),
};
var start = _time.GetTimestamp();
var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
@@ -267,7 +291,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
while (!ct.IsCancellationRequested)
{
var elapsed = _time.GetElapsedTime(start);
foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Alarm, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
{
if (!due.TryGetValue(group, out var at) || at > elapsed) continue;
var tags = _groups[group];
@@ -291,9 +315,19 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
if (generation != _generation || !ReferenceEquals(_session, session)) return false;
if (session.CommunicationLost) return false;
var good = true;
var alarmsDelivered = false;
for (var i = 0; i < tags.Count; i++)
{
var s = samples[i];
if (tags[i].IsAlarmCondition)
{
// One alarm walk per batch serves every ALARM: condition of this control (they share the outcome).
if (!alarmsDelivered) DeliverAlarms(s.Status, s.Status == HeidenhainStatus.Good ? s.Value as IReadOnlyList<Lsv2Alarm> : null);
alarmsDelivered = true;
if (s.Status != HeidenhainStatus.Good) good = false;
continue;
}
_publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now));
if (tags[i].Address.Catalog?.IsCapability == true) continue;
// Explicitly disabled optional timers are not partial data.
@@ -341,14 +375,25 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
private void Invalidate(DateTime now)
{
if (_alarmTags.Count > 0) DeliverAlarms(HeidenhainStatus.BadNoCommunication, null);
foreach (var tag in _tags)
{
if (tag.IsAlarmCondition) continue;
_publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true
? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now)
: new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now));
}
}
/// <summary>Hands an alarm-walk outcome to the projection. Called under <see cref="_publication"/> (ordered with the generation).</summary>
private void DeliverAlarms(uint status, IReadOnlyList<Lsv2Alarm>? alarms)
{
if (_alarms is null) return;
if (status == HeidenhainStatus.Good && alarms is null) status = HeidenhainStatus.BadDecodingError;
try { _alarms(this, status, status == HeidenhainStatus.Good ? alarms : null); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain {Host} alarm projection threw", HostName); }
}
private void SetState(HeidenhainConnectionState next, string? error)
{
HeidenhainConnectionState old;
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// dedup (subscriptions dedup on value AND status).</para>
/// </remarks>
public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAlarmSource, IRediscoverable, IAsyncDisposable
{
/// <summary>Most custom (PLC/DATA) bindings one control may carry (reference limit).</summary>
public const int MaxCustomBindingsPerDevice = 256;
@@ -45,6 +45,18 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
private readonly object _publishLock = new();
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, Subscription> _subscriptions = new();
/// <summary>
/// The <c>ALARM:</c> condition projection (lmxopcua#831). Lives as long as the driver — NOT re-created per
/// initialise — so its told record survives a reconnect and an in-place re-init (re-raising would un-ack).
/// </summary>
private readonly HeidenhainAlarmProjection _alarms;
/// <summary>
/// Last controller identity seen per control (<c>host:port</c>). Driver-lifetime, so a re-initialise does not
/// forget the baseline; an identity that differs on a later session raises <see cref="OnRediscoveryNeeded"/>.
/// </summary>
private readonly ConcurrentDictionary<string, Lsv2Versions> _identities = new(StringComparer.Ordinal);
private HeidenhainDriverOptions _options;
private IReadOnlyDictionary<string, HeidenhainDeviceRuntime> _byRawPath = new Dictionary<string, HeidenhainDeviceRuntime>();
private IReadOnlyList<HeidenhainDeviceRuntime> _devices = [];
@@ -75,6 +87,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
_credentials = credentials;
_clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t));
_time = time ?? TimeProvider.System;
_alarms = new HeidenhainAlarmProjection(this, () => OnAlarmEvent, _logger);
}
/// <inheritdoc />
@@ -89,6 +102,19 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
/// <inheritdoc />
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <inheritdoc />
/// <remarks>
/// Raised (advisory — the host shows a "re-browse" chip; nothing is re-materialised) when a control reconnects
/// reporting a different identity (<c>R_VR</c>: model, NC/PLC software version or option string) than the
/// previous session — a software update or option install can change what the control serves (selectors,
/// PLC layout). The first session of a control only records the baseline. No extra traffic: the identity is
/// the <c>R_VR</c> every session already reads at connect.
/// </remarks>
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <summary>Parses + validates a config document (the one authority the factory and the driver share).</summary>
/// <param name="driverConfigJson">The config JSON.</param>
/// <returns>The options.</returns>
@@ -138,6 +164,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var devices = _options.Devices ?? [];
_skipped.BeginRebuild(_options.RawTags.Count);
var perDevice = new Dictionary<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
var alarmTags = new Dictionary<string, HeidenhainAlarmTag>(StringComparer.Ordinal);
foreach (var d in devices) perDevice[d] = [];
foreach (var raw in _options.RawTags)
@@ -156,8 +183,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
}
var list = perDevice[device];
if (def.Address.Kind != HeidenhainAddressKind.Standard
&& list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice)
if (def.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath
&& list.Count(t => t.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath) >= MaxCustomBindingsPerDevice)
{
_skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control");
continue;
@@ -187,17 +214,27 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var runtime = new HeidenhainDeviceRuntime(
dev, _options, tags,
(role, ct) => _credentials.ResolveAsync(_options, dev, role, ct),
Publish, OnRuntimeState, _clientFactory, _time, _logger);
Publish, OnRuntimeState, _clientFactory, _time, _logger,
(r, status, alarms) => _alarms.OnWalk(r.HostName, status, alarms),
OnIdentity);
runtimes.Add(runtime);
foreach (var t in tags)
{
byPath[t.RawPath] = runtime;
if (t.IsAlarmCondition)
{
// A condition, never a value: it never enters the value cache or a data subscription.
alarmTags[t.RawPath] = new HeidenhainAlarmTag(t.RawPath, runtime.HostName, t.Address.AlarmSelector!, t.Alarm?.Severity);
continue;
}
_cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now);
}
}
_devices = runtimes;
_byRawPath = byPath;
_alarms.Tags = alarmTags;
}
private static HeidenhainDeviceOptions? Route(IEnumerable<HeidenhainDeviceOptions> devices, string deviceName)
@@ -270,7 +307,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
for (var i = 0; i < fullReferences.Count; i++)
result[i] = _cache.TryGetValue(fullReferences[i], out var s)
? s
: new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
: new DataValueSnapshot(null, Unknown(fullReferences[i]), null, now);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(result);
}
@@ -288,7 +325,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
var now = _time.GetUtcNow().UtcDateTime;
foreach (var r in sub.References)
{
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, Unknown(r), null, now);
Raise(sub, r, snapshot);
}
}
@@ -297,6 +334,10 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
return Task.FromResult<ISubscriptionHandle>(sub);
}
/// <summary>Status for a reference with no cached value: an <c>ALARM:</c> condition has no value to read.</summary>
private uint Unknown(string reference) =>
_alarms.Tags.ContainsKey(reference) ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.BadNodeIdUnknown;
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
@@ -411,6 +452,60 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
return Task.CompletedTask;
}
// ---- IAlarmSource (lmxopcua#831) ----
/// <inheritdoc />
/// <remarks>
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm set).
/// Collapses to ONE live subscription (the owning actor re-subscribes on every Connected entry). Starts the
/// control loops when nothing else had (an alarm-only driver); the first walk follows within
/// <c>Polling.AlarmMs</c>.
/// </remarks>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var handle = _alarms.Subscribe(sourceNodeIds);
EnsureStarted();
return Task.FromResult(handle);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
_alarms.Unsubscribe(handle);
return Task.CompletedTask;
}
/// <inheritdoc />
/// <remarks>
/// <b>Local-only.</b> LSV2 has no acknowledge request, and the read-only allowlist (<see cref="Lsv2ReadOnlyPolicy"/>)
/// is deliberately NOT widened for one: nothing is sent to the control. This is the no-op success the host routes
/// a Part 9 Acknowledge / AddComment to, so the ack is applied (and replicated) server-side — the FOCAS and
/// MTConnect posture. The control clears an alarm itself when its cause is reset.
/// </remarks>
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(acknowledgements);
return Task.CompletedTask;
}
/// <summary>The alarm projection (test seam).</summary>
internal HeidenhainAlarmProjection AlarmProjection => _alarms;
private void OnIdentity(HeidenhainDeviceRuntime runtime, Lsv2Versions identity)
{
var previous = _identities.GetValueOrDefault(runtime.HostName);
_identities[runtime.HostName] = identity;
if (previous is null || previous == identity) return;
var reason = $"Heidenhain control {runtime.HostName} reconnected with a different identity " +
$"({Describe(previous)} → {Describe(identity)}); re-browse the device and review its tags.";
_logger.LogWarning("{Reason}", reason);
try { OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, null)); }
catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain rediscovery handler threw"); }
static string Describe(Lsv2Versions v) => $"{v.Model} NC {v.Nc} PLC {v.Plc} options '{v.Options}'";
}
/// <inheritdoc />
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -187,9 +187,20 @@ internal sealed class HeidenhainSession : IAsyncDisposable
return result;
}
private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard
? tag.Address.Catalog!.Feature
: "Custom|" + tag.Address.Text + "|" + tag.DataType;
private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind switch
{
HeidenhainAddressKind.Standard => tag.Address.Catalog!.Feature,
HeidenhainAddressKind.Alarm => HeidenhainCatalog.AlarmsFeature,
_ => "Custom|" + tag.Address.Text + "|" + tag.DataType,
};
/// <summary>True for a PLC/DATA binding (as opposed to a catalog feature or the alarm walk).</summary>
private static bool IsCustom(HeidenhainTagDefinition tag) =>
tag.Address.Kind is HeidenhainAddressKind.Plc or HeidenhainAddressKind.DataPath;
/// <summary>The catalog feature a non-custom tag is acquired through.</summary>
private static string FeatureOf(HeidenhainTagDefinition tag) =>
tag.Address.Kind == HeidenhainAddressKind.Alarm ? HeidenhainCatalog.AlarmsFeature : tag.Address.Catalog!.Feature;
/// <summary>The current capability string for a feature.</summary>
public string Capability(string feature)
@@ -223,8 +234,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable
private async Task<Outcome> AcquireAsync(HeidenhainTagDefinition tag, Dictionary<string, Raw> wire, CancellationToken ct)
{
if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication);
var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard;
var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature;
var isCustom = IsCustom(tag);
var feature = isCustom ? tag.Address.Text : FeatureOf(tag);
var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature;
if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus);
try
@@ -251,7 +262,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
"Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)),
"Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)),
"Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)),
"Alarms" => await AlarmsAsync(ct).ConfigureAwait(false),
HeidenhainCatalog.AlarmsFeature => await AlarmsAsync(ct).ConfigureAwait(false),
_ => await TimerAsync(feature, ct).ConfigureAwait(false),
};
}
@@ -419,6 +430,8 @@ internal sealed class HeidenhainSession : IAsyncDisposable
private static object? Leaf(HeidenhainTagDefinition tag, object? value)
{
// An ALARM: condition receives the whole (immutable copy of the) list; the projection evaluates it.
if (tag.IsAlarmCondition) return value is List<Lsv2Alarm> alarms ? alarms.ToArray() : null;
if (tag.Address.Kind != HeidenhainAddressKind.Standard) return value;
var address = tag.Address.Catalog!.Address;
var name = address[(address.IndexOf('/') + 1)..];
@@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel
return TagConfigJson.Serialize(_bag);
}
/// <summary>
/// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830):
/// the hosting <c>DeviceModal</c> shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which covers every
/// password field) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
errors.Add($"{name} password must be blank, secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME> — never a literal.");
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port });
return errors.Count == 0 ? null : string.Join(" ", errors);
}
@@ -90,6 +90,10 @@
<label class="form-label" for="hhSlow">Slow (ms)</label>
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarmMs" title="Cadence of the alarm-list walk behind ALARM: Part 9 conditions; only polled when the control carries an ALARM: tag">Alarm conditions (ms)</label>
<InputNumber id="hhAlarmMs" @bind-Value="_m.AlarmMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
@@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int AlarmMs { get; set; } = 2000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
@@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
AlarmMs = o.Polling.AlarmMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
@@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["AlarmMs"] = AlarmMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
@@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
/// <summary>
/// The driver's own verdict on this config's ranges (device endpoints are validated on the device form).
/// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting <c>DriverConfigModal</c>
/// shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which scans every password field of the stored
/// document) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
Access = new HeidenhainAccessOptions
{
TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
},
Polling = o.Polling, Reconnect = o.Reconnect,
});
return errors.Count == 0 ? null : string.Join(" ", errors);
@@ -49,13 +49,23 @@
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
@if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated)
{
@* lmxopcua#830: the driver dictates the type for this address — show it, locked,
rather than offer a choice the commit would replace. *@
<input class="form-control form-control-sm mono" value="@dictated" disabled readonly
title="Set by the address — the driver publishes this type" />
}
else
{
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
@@ -246,7 +256,13 @@
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
if (configError is not null) { row.Error = configError; anyClientError = true; }
if (configError is not null) { row.Error = configError; anyClientError = true; continue; }
// The dictated fields are locked to the driver's values (applied on every config edit), so this
// re-application changes nothing the operator could see; the row rule then runs exactly as the
// server's will — a mismatch is SHOWN, never silently rewritten.
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var rowError = RawTagRowRules.Problem(DriverType, row.AccessLevel, row.DataType, row.TagConfig);
if (rowError is not null) { row.Error = rowError; anyClientError = true; }
}
if (anyClientError)
{
@@ -260,7 +276,6 @@
var failed = 0;
foreach (var row in _rows)
{
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var input = new RawTagInput(
row.Name,
row.DataType,
@@ -46,12 +46,21 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-dtype">Data type</label>
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
@if (RawTagRowDefaults.DictatedDataType(DriverType, _form.TagConfig) is { } dictated)
{
@* lmxopcua#830: locked to what the driver publishes for this address. *@
<input id="raw-tag-dtype" class="form-control form-control-sm mono" value="@dictated" disabled readonly />
<div class="form-text">Set by the address.</div>
}
else
{
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
}
</div>
</div>
<div class="row">
@@ -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"
@@ -246,6 +246,13 @@ public static class RawTagCsvMapper
return Invalid(rowNumber, cells, $"TagConfig: {configError}");
}
// The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit
// (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK.
if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError)
{
return Invalid(rowNumber, cells, rowError);
}
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
return new RawCsvReviewRow
@@ -1,22 +1,27 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, or the binding's
/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the
/// server (<c>RawTreeService</c>) and the deploy gate would refuse.
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, the binding's declared
/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both
/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have
/// one silently replaced on commit. The rules themselves live in <see cref="RawTagRowRules"/>.
/// </summary>
public static class RawTagRowDefaults
{
/// <summary>True for a driver whose tags can never be writable.</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed at Read.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase);
public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType);
/// <summary>The data type the driver dictates for this TagConfig (lock the field to it), or null.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
/// <returns>The dictated type, or null when the operator chooses.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
RawTagRowRules.DictatedDataType(driverType, tagConfig);
/// <summary>Applies the driver-dictated access level and data type for a row, when the driver dictates them.</summary>
/// <param name="driverType">The driver type.</param>
@@ -25,8 +30,7 @@ public static class RawTagRowDefaults
/// <param name="setDataType">Sets the row's <c>Tag.DataType</c>.</param>
public static void Apply(string? driverType, string? tagConfig, Action<TagAccessLevel> setAccess, Action<string> setDataType)
{
if (!IsReadOnlyDriver(driverType)) return;
setAccess(TagAccessLevel.Read);
if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType);
if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read);
if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType);
}
}
@@ -0,0 +1,66 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and
/// <c>Tag.DataType</c>, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path
/// (<see cref="RawTreeService"/> create / update / import), the CSV import's staged review grid
/// (<see cref="RawTagCsvMapper"/>), and the manual-entry grid + tag modal (through <see cref="RawTagRowDefaults"/>,
/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused.
/// </summary>
/// <remarks>
/// A driver with row rules registers its functions in <see cref="Rules"/>; the call sites never name a driver.
/// The deploy gate applies the same driver functions (e.g. <c>HeidenhainDeployChecks</c> →
/// <see cref="HeidenhainTagRules"/>), so authoring and deploy cannot disagree.
/// </remarks>
public static class RawTagRowRules
{
/// <summary>One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null.</summary>
/// <param name="readWrite">Whether the row is ReadWrite.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig);
/// <summary>One driver's dictated data type for a TagConfig, or null when the operator chooses.</summary>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? DataTypeRule(string? tagConfig);
private static readonly Dictionary<string, (RowRule Row, DataTypeRule DataType, bool ReadOnly)> Rules =
new(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true),
};
/// <summary>The driver types that carry row rules (test seam / documentation).</summary>
public static IReadOnlyCollection<string> DriverTypesWithRules => Rules.Keys;
/// <summary>Why a row may not be saved for this driver, or null when it may.</summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="accessLevel">The row's access level.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's (normalized) TagConfig.</param>
/// <returns>The refusal, or null.</returns>
public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) =>
driverType is not null && Rules.TryGetValue(driverType, out var r)
? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig)
: null;
/// <summary>True for a driver whose tags can never be writable (the access level is fixed at Read).</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly;
/// <summary>
/// The <c>Tag.DataType</c> the driver dictates for this TagConfig (so a form must lock the field to it), or
/// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The TagConfig.</param>
/// <returns>The dictated type name, or null.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null;
}
@@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
/// <summary>
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
/// and carry the data type the driver publishes — the same rule the deploy gate applies.
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — <see cref="RawTagRowRules"/>, the
/// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies.
/// </summary>
private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
: null;
RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig);
/// <summary>
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
@@ -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();
}
}
@@ -151,13 +151,26 @@ public sealed class HeidenhainDriverTests
sim.GoOffline();
// EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once.
await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication));
rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost");
// The invalidation is ONE pass over the device's tags (in authored order, capability last), delivered to this
// thread's recorder as it runs — so wait for the WHOLE pass to land (the capability's CommunicationLost is
// its last publication) before asserting on any of it. Asserting the capability the instant the five value
// tags read Bad raced the pass's final publish (1/40 on 5bbe1e3c: "Supported").
await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication)
&& rec.Last(tags[5].RawPath)?.Value is "CommunicationLost");
rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good);
// ...and it is one pass: no remote value published Good again after its outage invalidation.
foreach (var t in tags.Take(5))
{
var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList();
seq.Skip(seq.LastIndexOf(HeidenhainStatus.BadNoCommunication)).ShouldAllBe(s => s == HeidenhainStatus.BadNoCommunication);
}
(await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication);
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
// Degraded holds through every reconnect attempt (Backoff AND Connecting carry the last error), but the host
// status reads Stopped only in Backoff and Unknown while a reconnect attempt is in flight — so wait for the
// Backoff phase rather than sample whichever phase the loop happens to be in.
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded && d.GetHostStatuses().Single().State == HostState.Stopped);
d.GetHealth().LastError.ShouldNotBeNullOrEmpty();
d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
hostEvents.ShouldContain(e => e.NewState == HostState.Stopped);
var frozen = d.GetHealth().LastSuccessfulRead;
await Task.Delay(500, Ct);
@@ -53,7 +53,10 @@ public sealed class HeidenhainAuthoringTests
{
var m = HeidenhainDriverFormModel.FromJson("{}");
m.DncPassword = "hunter2";
m.Validate()!.ShouldNotContain("hunter2");
// #830: the credential refusal is the hosting modal's (the save gate), reported once — not the form's too.
var refusal = DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, m.ToJson());
refusal.ShouldNotBeNull();
refusal.ShouldNotContain("hunter2");
m.DncPassword = null;
m.StatusMs = 10;
m.Validate().ShouldNotBeNull();
@@ -66,7 +69,7 @@ public sealed class HeidenhainAuthoringTests
d.Port.ShouldBe(19000);
d.Validate().ShouldBeNull();
d.DataPassword = "plain";
d.Validate().ShouldNotBeNull();
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, d.ToJson()).ShouldNotBeNull(); // #830: the modal's gate
d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA";
var json = d.ToJson();
JsonNode.Parse(json)!["Note"]!.GetValue<string>().ShouldBe("keep");
@@ -0,0 +1,176 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// lmxopcua#830 authoring UX: (a) the CSV review grid previews the SAME row rule the commit applies, (b) the
/// driver-dictated data type is a lock, never a silent rewrite, (c) a credential refusal is reported once.
/// Plus the #831 ALARM: authoring seams (editor seeding, native-alarm editor visibility).
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainAuthoringUxTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string Header = "Name,DataType,AccessLevel,TagConfigJson";
private static string Csv(params string[] rows) => Header + "\n" + string.Join("\n", rows);
private static string Json(string json) => "\"" + json.Replace("\"", "\"\"") + "\"";
// ---- (a) CSV preview == commit ----
[Fact]
public void Csv_preview_flags_ReadWrite_and_data_type_mismatch_rows_like_the_commit()
{
var exec = Json("""{"address":"Status/ExecutionState"}""");
var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, Csv(
$"ok,Int32,Read,{exec}",
$"rw,Int32,ReadWrite,{exec}",
$"dt,Double,Read,{exec}",
$"feed,Int32,Read,{Json("""{"address":"Overrides/Feed"}""")}"));
result.FatalError.ShouldBeNull();
var rows = result.Rows;
rows[0].Ok.ShouldBeTrue(rows[0].Error);
rows[1].Error!.ShouldContain("read-only");
rows[2].Error!.ShouldContain("Int32");
rows[3].Error!.ShouldContain("Double"); // a Float64 leaf publishes Double — picked as Int32 it used to preview OK
result.CanCommit.ShouldBeFalse();
}
[Fact]
public async Task Csv_preview_and_ImportTags_agree_row_for_row()
{
var exec = Json("""{"address":"Status/ExecutionState"}""");
var csv = Csv($"ok,Int32,Read,{exec}", $"rw,Int32,ReadWrite,{exec}", $"dt,String,Read,{exec}",
$"plc,Int64,Read,{Json("""{"address":"PLC:DWORD:0","dataType":"Int64"}""")}");
var preview = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, csv);
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct);
string deviceId;
using (var db = RawTreeTestDb.CreateNamed(name)) deviceId = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId).DeviceId;
// Feed EVERY parsed row (the invalid ones too, rebuilt from their cells) to the commit and compare verdicts.
foreach (var row in preview.Rows)
{
var dataType = row.Cells[1];
var access = Enum.Parse<TagAccessLevel>(row.Cells[2]);
var input = new RawTagInput(row.Cells[0], dataType, access, false, null, row.Cells[3]);
var outcome = await svc.ImportTagsAsync(deviceId, [new RawTagImportRow(null, input)], Ct);
(outcome.Errors.Count == 0).ShouldBe(row.Ok, $"{row.Name}: preview {row.Error ?? "OK"} vs commit {string.Join("; ", outcome.Errors)}");
}
}
[Fact]
public void Row_rules_are_driver_dispatched_not_named_at_call_sites()
{
RawTagRowRules.DriverTypesWithRules.ShouldContain(DriverTypeNames.Heidenhain);
RawTagRowRules.Problem(DriverTypeNames.Modbus, TagAccessLevel.ReadWrite, "Float", "{}").ShouldBeNull();
RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.ReadWrite, "Int32", """{"address":"Status/ExecutionState"}""")
.ShouldBe(HeidenhainTagRules.DescribeRowProblem(true, "Int32", """{"address":"Status/ExecutionState"}"""));
RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.Heidenhain).ShouldBeTrue();
RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.S7).ShouldBeFalse();
}
// ---- (b) dictated data type is a lock ----
[Theory]
[InlineData("""{"address":"Axes/Positions","isArray":true}""", "Double")]
[InlineData("""{"address":"PLC:WORD:3","dataType":"Int16"}""", "Int16")]
[InlineData("""{"address":"ALARM:*","alarm":{}}""", "Boolean")]
public void The_dictated_data_type_is_what_the_grid_and_modal_lock_to(string config, string expected)
{
RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, config).ShouldBe(expected);
// ...and the locked value is exactly what the save path accepts — nothing to rewrite on commit.
RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.Read, expected, config).ShouldBeNull();
}
[Fact]
public void An_unresolved_config_or_another_driver_leaves_the_data_type_to_the_operator()
{
RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, "{}").ShouldBeNull();
RawTagRowDefaults.DictatedDataType(DriverTypeNames.Modbus, """{"address":"Axes/Positions","isArray":true}""").ShouldBeNull();
}
// ---- (c) credential error shown once ----
[Fact]
public void A_literal_password_is_reported_by_the_modal_save_gate_only_not_also_by_the_form()
{
var driver = HeidenhainDriverFormModel.FromJson("{}");
driver.InspectPassword = "hunter2";
var json = driver.ToJson();
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, json).ShouldNotBeNull(); // the modal's one notice + Save disabled
driver.Validate().ShouldBeNull(); // the form no longer repeats it
driver.StatusMs = 10;
driver.Validate()!.ShouldContain("StatusMs"); // non-credential problems still show in the form
var device = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7"}""");
device.DncPassword = "hunter2";
DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, device.ToJson()).ShouldNotBeNull();
device.Validate().ShouldBeNull();
HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host still required in the form
}
// ---- #831 authoring seams ----
[Fact]
public void Picking_an_alarm_selector_seeds_the_alarm_object_and_leaving_it_drops_it()
{
var m = HeidenhainTagConfigModel.FromJson("{}");
m.Address = "ALARM:CLASS=7";
var json = m.ToJson();
m.Validate().ShouldBeNull();
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
NativeAlarmModel.FromJson(json).IsAlarm.ShouldBeTrue();
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out _).ShouldBeTrue();
def!.Alarm!.Severity.ShouldBe(HeidenhainTagConfigModel.DefaultAlarmSeverity);
// An authored severity is never overwritten by a later edit.
var edited = NativeAlarmModel.Edit(json, a => a.Severity = 950);
var again = HeidenhainTagConfigModel.FromJson(edited);
again.Address = "ALARM:*";
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", again.ToJson(), out def, out _).ShouldBeTrue();
def!.Alarm!.Severity.ShouldBe(950);
again.Address = "Status/ExecutionState";
var plain = again.ToJson();
NativeAlarmModel.FromJson(plain).IsAlarm.ShouldBeFalse();
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, plain).ShouldBeNull();
}
[Fact]
public void Heidenhain_offers_the_native_alarm_editor_and_a_csv_alarm_row_imports()
{
NativeAlarmModel.SupportsNativeAlarms(DriverTypeNames.Heidenhain).ShouldBeTrue();
var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain,
"Name,DataType,AccessLevel,TagConfigJson,Alarm.AlarmType,Alarm.Severity\n" +
$"Any,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},AlarmCondition,800\n" +
$"NoAlarm,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},,\n");
result.Rows[0].Ok.ShouldBeTrue(result.Rows[0].Error);
result.Rows[1].Ok.ShouldBeFalse(); // a selector without the alarm object is refused in the preview
}
[Fact]
public void The_driver_form_round_trips_the_alarm_cadence()
{
var m = HeidenhainDriverFormModel.FromJson("{}");
m.AlarmMs.ShouldBe(2000);
m.AlarmMs = 500;
var o = HeidenhainOptionsParser.Parse(m.ToJson());
o.Polling.AlarmMs.ShouldBe(500);
m.AlarmMs = 50;
m.Validate()!.ShouldContain("AlarmMs");
}
}
@@ -98,7 +98,7 @@ public sealed class NativeAlarmEditorModelTests
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
{
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy, DriverTypeNames.Heidenhain })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
@@ -47,6 +47,9 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<!-- #831: NativeAlarmDriverSetGuardTests discovers IAlarmSource implementers by scanning the driver DLLs
deployed here, so a driver that raises native alarms must be referenced or the guard misses it. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,164 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// lmxopcua#831 — a Heidenhain controller alarm reaches its Part 9 condition END TO END: the REAL
/// <see cref="HeidenhainDriver"/> against the in-process LSV2 simulator (replaying the captured iTNC 530 alarm
/// record), fed the raw tags exactly as the deployment artifact delivers them, raising into the REAL
/// <see cref="DriverHostActor"/>, whose projector must turn it into an Active and then an inactive condition at
/// the tag's RawPath (the FOCAS #647 lesson: a test that stops at the driver's event cannot see a routing miss).
/// </summary>
public sealed class HeidenhainNativeAlarmEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("heidenhain-alarm-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
private const string DriverId = "drv-hh";
private const string ExpectedRawPath = "Plant/Hh/m1/cnc_alarms";
/// <summary>Java fixture <c>hermle49.27</c>: class 7, group 4, number -1, "Run test of cutout channels".</summary>
private static readonly byte[] Golden =
Convert.FromHexString("00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300");
[Fact]
public async Task A_controller_alarm_activates_then_clears_the_condition_at_the_RawPath()
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var (host, publish) = SpawnHostAndApply(db, Seed(db, blob));
var rawTags = RawTagsFor(blob);
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
var control = new SimulatedControl();
var active = true;
control.Runtime[27] = () => active ? SimReply.Ok("S_RI", Golden) : SimReply.Refuse(57);
control.Runtime[28] = () => SimReply.Refuse(57);
await using var sim = Lsv2Simulator.WithControl(control);
await using var driver = new HeidenhainDriver(new HeidenhainDriverOptions
{
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "m1" }],
RawTags = rawTags,
TimeoutMs = 500,
Polling = new HeidenhainPollingOptions { AlarmMs = 100 },
}, DriverId);
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
#pragma warning disable OTOPCUA0001 // routing under test, not resilience wrapping (the FOCAS e2e precedent)
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
var raised = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5),
cancellationToken: TestContext.Current.CancellationToken);
raised.AlarmNodeId.ShouldBe(ExpectedRawPath);
raised.Realm.ShouldBe(AddressSpaceRealm.Raw);
raised.State.Active.ShouldBeTrue();
raised.State.Acknowledged.ShouldBeFalse();
raised.State.Severity.ShouldBe((ushort)800);
raised.State.Message.ShouldStartWith("-1 Run test of cutout channels");
active = false;
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(u => !u.State.Active, TimeSpan.FromSeconds(5),
cancellationToken: TestContext.Current.CancellationToken);
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
control.Violations.ShouldBeEmpty();
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_RI" });
}
private static List<RawTagEntry> RawTagsFor(byte[] blob)
{
using var artifact = DeploymentArtifact.Open(blob);
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == DriverId);
using var doc = JsonDocument.Parse(spec.DriverConfig);
return JsonSerializer.Deserialize<List<RawTagEntry>>(
doc.RootElement.GetProperty("RawTags").GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
// Enabled=false: the host spawns no driver — the test drives its own instance against the simulator.
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Hh", DriverType = "Heidenhain", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { Host = "127.0.0.1" }) },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-alarms",
DeviceId = "dev-m1",
TagGroupId = (string?)null,
Name = "cnc_alarms",
DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new
{
address = "ALARM:*",
dataType = "Boolean",
alarm = new { alarmType = "AlarmCondition", severity = 800 },
}),
},
},
});
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = Rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = blob,
});
ctx.SaveChanges();
return id;
}
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish);
}
}