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