Merge feat/830-heidenhain-followups

This commit is contained in:
Joseph Doherty
2026-11-11 01:55:10 -05:00
33 changed files with 1802 additions and 82 deletions
+89 -9
View File
@@ -1,7 +1,7 @@
# Heidenhain LSV2 driver (read-only)
`DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) ·
Gitea **#803**.
Gitea **#803** (native alarms + rediscovery: **#831**; authoring UX: **#830**).
A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No
Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the
@@ -21,9 +21,12 @@ Three independent layers, each sufficient on its own for its concern:
manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the
save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older
(one rule, `HeidenhainTagRules`).
address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag
modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid
used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or
mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate
`HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI
path through the driver-dispatched `RawTagRowRules` (no call site names a driver).
3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks
command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s
public surface is pinned by a reflection test) and no configuration knob that widens it.
@@ -66,6 +69,7 @@ enums, so the numeric-enum form/factory trap cannot occur):
| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) |
| `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) |
| `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 |
| `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) |
| `Polling.MaxAlarms` | 64 | 1–256 |
| `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 |
| `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded |
@@ -90,8 +94,9 @@ Host at their own machine. Anything outside the namespace is refused by **every*
(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time**
(`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path**
(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the
driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the
deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value.
driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed
forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate
`HeidenhainCredentialPersisted`. Messages never echo the value.
Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset
variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the
@@ -147,6 +152,70 @@ truncates or wraps — otherwise `BadTypeMismatch`.
At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`).
## Alarm conditions (`ALARM:`, Gitea #831)
The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal
57 ends it). Besides the `Alarms/*` typed arrays (unchanged), the list can drive **native OPC UA Part 9 conditions**:
a raw tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object is
exactly one condition, `ConditionId` = the tag's RawPath (the key the host routes and fans on — raw device folder +
every referencing equipment folder, per the v3 native-alarm model). Same opt-in-per-tag shape as FOCAS #647 and
MTConnect #626.
```jsonc
{ "address": "ALARM:CLASS=7", "dataType": "Boolean",
"alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
```
| Address | Condition is active while… |
|---|---|
| `ALARM:*` | any alarm is active on the control (summary) |
| `ALARM:OTHER` | an alarm is active that no **filter** selector on the same control claims |
| `ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>]` (any subset, any order, each key once) | an alarm matches every named field |
`<r>` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP
are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the
reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or
a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the
driver.
- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the
`Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored
tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no
driver-level enable flag.
- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector
agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The
tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data
subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds
`{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one.
- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is
deterministic (ascending class, group, number, text), never wire order. Message:
`-1 Run test of cutout channels (class 7, group 4) [+N more]`.
- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed
alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a
non-displayed member leaves → nothing; nothing matches → `Clear`.
- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No
class→severity table is invented.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the
driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its
first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept,
`NativeAlarmProjector` #647).
- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 →
`BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss
(`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only
event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down
session degrades health (the existing rule) — a refusal never does.
- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened:
`AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and
pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are
refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset;
an operator ack never reaches it.
- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop
every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists
(`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged).
- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the
alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves.
- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`).
## Quality
| Condition | Value status / capability |
@@ -183,6 +252,11 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re
- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
disposed loop is a no-op.
- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control
reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous
session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason —
the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first
session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic.
- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with
`LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and
decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one
@@ -202,7 +276,13 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no
any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed
as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three).
- `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`.
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet
- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured
iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal,
handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set.
- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition.
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`,
`AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single
credential notice) + the fleet
parity suites.
- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional
`HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
@@ -223,5 +303,5 @@ No real control was available for this port. Still open:
Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE
roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH,
PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are
published as typed arrays).
PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm
conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)).