Files
lmxopcua/docs/drivers/Heidenhain.md
T
Joseph Doherty ba9015fac8 feat(heidenhain): classify the control family from R_VR; DATA gated to iTNC 530 (per pyLSV2)
- HeidenhainControlFamily + HeidenhainControlFamilies.Classify (Contracts): one authority, ported from
  pyLSV2 (MIT) dat_cls.py VersionInfo.control (strip spaces, case-insensitive prefix, ITNC530 first).
  Families: ITnc530, Tnc (640/620/320/128), Tnc7, Lathe (CNC PILOT 640 / MANUALplus 620), MillPlus,
  LegacyTnc (426/430, not a pyLSV2 class), Unknown.
- New additive read-only leaf Controller/Family (String, Identity feature, derived from the same R_VR
  reply: no extra request). Lsv2Versions.Family is computed, so record equality and the #831
  identity-change rediscovery signal are unchanged. Probe message + /hosts diagnostics (<host>.family).
- DATA gate now goes through SupportsDataPath(family) (pyLSV2 client.py read_data_path is iTNC-only):
  non-iTNC and Unknown => Capabilities/DataLogin Unsupported, DATA login never sent. Replaces the
  "model contains itnc" substring check (same result on the real iTNC530 controls).
- No per-family selector pre-gates: pyLSV2's TNC 640 observations (27/28/51/30-34 unknown telegram,
  25/26 need DNC) are already handled by the reply-driven refusal/suppression path; tests pin it.
- Lsv2ReadOnlyPolicy, HeidenhainTagWritable and the no-IWritable rule are untouched.
- Simulator: per-family ControlProfiles (strings from the pyLSV2 README tables / protocol.rst), both
  R_VR field counts, 124-byte R_PR; HeidenhainControlFamilyTests (classification, gates, decoding,
  wire log inside the read-only set, refused selectors asked once).
- docs/drivers/Heidenhain.md: Control families matrix (iTNC 530 hardware-proven; the rest per pyLSV2,
  hardware-unproven) + open questions (C_CC buffer size, encrypted passwords, R_RI 20/21).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-12 23:52:06 -05:00

398 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Heidenhain LSV2 driver (read-only)
`DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) ·
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
ignitionoee v0.20.3 Java driver (commit `d1dda0a5`, `modules/drivers/gateway/.../driver/heidenhain`, wire reference
`docs/reference/heidenhain-lsv2.md`), whose protocol layouts are in turn derived from pyLSV2 commit `97a3d691`.
**The Java validation evidence (four real iTNC 530 controls, 2026-11-03) is reference evidence, not proof of this
port** — see [Hardware-gated](#hardware-gated--not-yet-proven).
## Read-only boundary
Three independent layers, each sufficient on its own for its concern:
1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every
OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`).
2. **No writable nodes.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService`
create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the
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; `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.
| Request | Permitted payload | Reply |
|---|---|---|
| `A_LG` | role ∈ {`INSPECT`, `DNC`, `DATA`} NUL, optional NUL-free Latin-1 password NUL | `T_OK` (empty) |
| `R_VR` | empty | `S_VR` |
| `R_PR` | empty | `S_PR` |
| `R_RI` | exactly a uint16be selector ∈ {22–28, 30–34, 51} | `S_RI` |
| `R_MB` | uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap | `S_MB` |
| `R_DP` | four zero bytes + NUL-free path + NUL (≤ 247 bytes total) | `S_DP` |
Everything else is refused before transmission — every `C_*` (file/PLC/parameter writes, buffer negotiation),
`A_LO`, file reads (`R_FL`, `R_DI`, …), key/scope operations, `PLCDEBUG`/`MONITOR`/`FILE` roles, response
mnemonics sent as requests. `Lsv2ReadOnlyPolicyTests.A_refused_request_never_reaches_the_socket` proves zero bytes
leave the process for a refused request, even through the internal exchange primitive. Socket close ends a
session (no logout command). **Do not broaden the allowlist to work around a controller refusal.**
## Transport
`uint32be payloadLength | ASCII command[4] | payload` — legacy buffer mode, 255-byte frame maximum, no `C_CC`
negotiation. One transaction at a time per control (a `SemaphoreSlim` in `Lsv2Client`, and a single acquisition
loop per device). Each transaction has **one monotonic deadline** (`TimeoutMs`) covering write + header + body, so a
trickling peer cannot extend it. EOF, deadline, an oversized (> 247) or unexpected reply, a malformed `T_ER`, or a
`T_OK` with a body **closes the client** — a later request can never consume leftover bytes. A complete
`T_ER`/`T_BD` refusal keeps the stream synchronised. `Close()` never waits for the transaction gate, so it always
interrupts a blocked read.
## Configuration
Driver config (`HeidenhainDriverOptions`, AdminUI **HeidenhainDriverForm**) — every field is int/bool/string (no
enums, so the numeric-enum form/factory trap cannot occur):
| Key | Default | Range / meaning |
|---|---|---|
| `TimeoutMs` | 5000 | 100–60000; connect + per-transaction deadline |
| `Access.TelemetryEnabled` | true | Off = no `R_RI` requests at all |
| `Access.DncLoginEnabled` | true | Optional DNC login; a refusal never blocks reads permitted under INSPECT |
| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists, and only on an iTNC 530 ([Control families](#control-families)) |
| `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 |
| `ProbeTimeoutSeconds` | 10 | Test Connect, 1–60 |
Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**): `Host` (required, no default),
`Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated
only once it carries tags, so an unconfigured default `Device1` does not fault the driver.
**Credentials — namespaced references only.** A password field is one of:
| Form | Resolved from |
|---|---|
| blank | no password |
| `secret:heidenhain/<name>` (e.g. `secret:heidenhain/line3/inspect`; `[A-Za-z0-9._-]` segments, no `..`) | the node's encrypted secret store (`ISecretResolver`), same slash-scoped naming as `galaxy/<inst>/apikey` |
| `env:OTOPCUA_HEIDENHAIN_<NAME>` (exact upper-case prefix, `NAME` = `[A-Z0-9_]+`) | the process environment |
**Why namespaced:** the reference is resolved on the node (for Test Connect: on the admin node) and sent in
**cleartext** in `A_LG` to whatever Host the author configured. An unscoped reference would let anyone who can edit
a driver exfiltrate `env:ConnectionStrings__ConfigDb`, `env:ServerHistorian__ApiKey` or any other secret by pointing
Host at their own machine. Anything outside the namespace is refused by **every** layer: the parser
(`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 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. Since #815 the parser and resolver are
the shared `CredentialReferences` / `DriverCredentialPolicy` (`Core.Abstractions`) that Galaxy and OpcUaClient use
too, and Test Connect / live browse also refuse an out-of-namespace reference before any probe or browser runs; a
prefix in another casing (`SECRET:`) is refused, and `${secret:heidenhain/<name>}` is accepted as a spelling of
`secret:heidenhain/<name>`.
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
connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2
login payload cannot carry (NUL, non-Latin-1, &gt; 247 bytes) — never fails the session: the login is not sent, the
reads that need that role read `BadConfigurationError` (capability `ConfigurationError`), and INSPECT telemetry
continues. Plain LSV2 is unencrypted — reach the control over an externally managed secure network; there is no
SSH subsystem.
## Addresses (TagConfig)
`{"address": "<address>", "dataType": "<DriverDataType name>", "isArray": true|false}` — parsed by
`HeidenhainTagDefinitionFactory`, the single authority shared by the driver, the deploy inspector
(`EquipmentTagConfigInspector`) and the AdminUI editor/validator.
**Fixed namespace** (device-relative; browse-committable from the `/raw` picker without contacting the control):
| Folder | Leaves (type) | Source | Group |
|---|---|---|---|
| `Controller` | `Model`, `NcVersion`, `PlcVersion`, `Options`, `Family` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved). `Family` is classified locally from `Model` — no extra request ([Control families](#control-families)) | Slow |
| `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums | `R_RI 23`, `26` | Status |
| `Program` | `Main`, `Current` (String), `Line` (Int64) | `R_RI 24` | Status |
| `Overrides` | `Feed`, `Spindle`, `Rapid` (Float64, percent) | `R_RI 25` (uint32 hundredths) | Status |
| `Axes` | `Names` (String[]), `Positions` (Float64[]) — aligned, controller units | `R_RI 22` | Status |
| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `Length`, `Radius` (Float64) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0) | Slow |
| `Alarms` | `Classes`, `Groups`, `Numbers` (Int32[]), `Messages` (String[]), `Count` (Int32) | `R_RI 27` then `28`; only refusal 57 ends the list | Slow |
| `Timers` | `NcUptime`, `MachineUptime`, `MachineRuntime`, `Spindle0Runtime`, `Spindle1Runtime` (Int64, raw) | `R_RI 30–34`, **disabled by default** | Slow |
| `Capabilities` | one String per feature + `PlcLayout`, `DncLogin`, `DataLogin` | local, never a wire read | Slow |
Array leaves require `"isArray": true` (variable length); the typed editor and browse commit write it. Capability
states: `Unknown`, `Supported`, `Disabled`, `AccessDenied`, `Unsupported`, `InvalidData`, `CommunicationLost` —
evidence about the last read, not a licence assertion.
**PLC bindings** `PLC:<AREA>:<INDEX>` + `dataType`, read via `R_MB`. `INDEX` is an **element** index (1–10 digits,
≤ uint32), multiplied by the native width and added to the region base the control advertises in `R_PR`.
Numeric `R_PR` counts are **byte capacities** (element count = ⌊bytes / width⌋; BYTE/WORD/DWORD share one pool);
the next populated advertised base is a defensive cap. Bit and STRING counts are entries.
| Area | Native | Allowed `dataType` |
|---|---|---|
| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean (0/1 only) | Boolean |
| `BYTE` / `WORD` / `DWORD` | signed int8 / int16le / int32le | Int16 (BYTE, WORD only), Int32, Int64 |
| `INPUT_WORD`, `OUTPUT_WORD` | uint16le | Int32, Int64 |
| `STRING` | advertised fixed width, terminated Latin-1 (too wide ⇒ error, never truncated) | String |
Example: `PLC:WORD:4999` → `R_MB` at `base + 4999×2`. Note that `DATA:/PLC/memory/W/9998` uses a native **byte
offset** — the two address spaces differ (see the wire reference).
**DATA bindings** `DATA:<path>` + `dataType` ∈ Boolean/Int16/Int32/Int64/Float64/String, read via `R_DP` under the
opt-in DATA role (`/`→`\`, `"`→`'` normalisation; NUL-free Latin-1; ≤ 242 chars). Value types 2/3/5/8/11/16/17 are
decoded strictly; Boolean accepts `00` and `01`/**`FF`** (FF observed on real iTNC 530 I/O). Coercion never
truncates or wraps — otherwise `BadTypeMismatch`.
At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`).
## Control families
`R_VR`'s model field is classified once, in one place — `HeidenhainControlFamilies.Classify` (Contracts project,
`HeidenhainControlFamily`) — with pyLSV2's rule (MIT; `dat_cls.py` `VersionInfo.control`): strip spaces, compare
case-insensitively by prefix, `ITNC530` first. The result is published on the **`Controller/Family`** String leaf
(member name: `ITnc530`, `Tnc`, `Tnc7`, `Lathe`, `MillPlus`, `LegacyTnc`, `Unknown`), included in the Test-Connect
probe message (`TNC640 (Tnc) NC …`) and the `/hosts` diagnostics bag (`<host>.family`). It costs no request: it is
derived from the `R_VR` reply the session already reads. `Controller/Model` keeps the raw string.
The only code-level gate pyLSV2 applies to a request this driver makes is **DATA**: `read_data_path` returns early
on anything but an iTNC (`client.py` "does not work on non iTNC controls"). So a `DATA:` binding on any other family
— and on an `Unknown` model — is `BadNotSupported` with `Capabilities/DataLogin = Unsupported`, and **the DATA login
is never sent**. (Scope is pyLSV2's other iTNC-only gate; this driver has no scope.) Every other feature is
attempted on every family and settled by the control's own reply: `T_ER_UNKNOWN_TELE` (22) ⇒ `Unsupported`, asked
once per session, then suppressed; an access refusal ⇒ `AccessDenied`. No family pre-gates a selector — pyLSV2
records those as "observations, not guarantees" (`docs/protocol.rst`), and a pre-gate would hide data a newer NC
version serves.
| Family (`Controller/Family`) | Models (`R_VR` prefix) | Evidence | What the reference says | Driver behaviour |
|---|---|---|---|---|
| `ITnc530` | `iTNC530` / `iTNC 530` | **Hardware-proven** — four Hermle iTNC 530 (NC 340490 03/04, 606420 03; `evidence/heidenhain-live-2026-11-11.md`) | pyLSV2 `MILL_OLD`; DATA + scope iTNC-only; tool (51), alarms (27/28) and timers (30–34) served | All features attempted; DATA allowed (opt-in) |
| `Tnc` | `TNC640`, `TNC620`, `TNC320`, `TNC128` | Per pyLSV2, hardware-unproven | `MILL_NEW`. TNC 640 NC 340590 10 SP2: 27/28/51 and 30–34 → `T_ER_UNKNOWN_TELE`; 25/26 need DNC; 24 serves the program under INSPECT with block number 0 | `Tool/*`, `Alarms/*`, `ALARM:` conditions and `Timers/*` read `Unsupported` after one refused request; without DNC `Overrides/*` and `Status/ProgramState` are `AccessDenied` and `Program/Line` reads the control's raw 0. DATA `Unsupported`, not sent |
| `Tnc7` | `TNC7` | Per pyLSV2, hardware-unproven | `TNC7` — "behaves just a bit different again"; only keyboard/key functions are flagged (not used here) | Reply-driven (no selector observations recorded); DATA `Unsupported` |
| `Lathe` | `CNCPILOT640`, `MANUALPLUS` | Per pyLSV2, hardware-unproven | `LATHE_NEW`; protocol table: no tool (51), no error messages (27/28) | Those features settle `Unsupported` from the reply; DATA `Unsupported` |
| `MillPlus` | `MILLPLUS` (e.g. `MillPlusIT V600`) | Per pyLSV2, hardware-unproven | `MILLPLUS`; "not officially supported by any other Heidenhain tool" | Reply-driven; DATA `Unsupported` |
| `LegacyTnc` | `TNC426`, `TNC430` | **No reference** (pyLSV2 reports these `UNKNOWN`) | — | Named for the leaf only; reply-driven; DATA `Unsupported` |
| `Unknown` | anything else | — | pyLSV2 `UNKNOWN` | Reply-driven; DATA `Unsupported` |
Decoding is family-independent per pyLSV2: `R_VR` is 4 or 5 NUL-separated fields on every family (`client.py`
`_read_version`), `R_PR` is 120 or 124 bytes with one layout (`misc.py` `decode_system_parameters`), and the `R_RI`
decoders for 22–28/51 carry no family branch. The simulator exercises every family in both `R_VR` shapes, both
`R_PR` lengths and the read-only wire set (`Simulator/ControlProfiles.cs`, `HeidenhainControlFamilyTests`) — that is
evidence for the classification and the gates, **not** for any non-iTNC control's real replies.
**Open, needs real hardware or a request this driver deliberately does not send:**
- **Buffer size.** pyLSV2 negotiates 512–4096-byte telegrams with `C_CC` from `R_PR`'s max block length; `C_CC` is
a configuration command, outside the allowlist. We stay at the un-negotiated legacy frame on every family (pyLSV2's own default
is 256 bytes, so a control is expected to fit replies to it); a reply over 255 bytes is a transport error
(`Oversized LSV2 response`), never a truncated value.
- **Encrypted passwords.** pyLSV2's `R_PR` exposes a password-encryption key and notes TNC 640 sends some login
passwords encrypted (scope). Whether a password-protected DNC/DATA login on a `Tnc`/`Tnc7` needs that is unknown;
INSPECT and DNC with an empty password are unaffected.
- **Which family sends the 5-field `R_VR`**, and whether `R_RI 20/21` (TNC 640 axis config/physical names, outside
our selector allowlist) would be a better axes source there.
- **Selector availability per NC version** on `Tnc`/`Tnc7`/`Lathe`/`MillPlus` (the table above is one TNC 640
observation plus pyLSV2's test-suite gating), the `R_RI 22` axis encoding, override/tool units, and whether
`R_MB` is granted under INSPECT on any non-iTNC family.
## 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. Selectors always filter on the **raw codes**; observe `Alarms/Classes` /
`Alarms/Groups` or a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy
inspector and the driver.
**Class / group names and the Part 9 condition class (Gitea #839).** LSV2 documents neither field (pyLSV2 and the Java
port keep them raw). `HeidenhainAlarmClasses` carries HEIDENHAIN's own names from the **DNC interface** (RemoTools SDK
`DNC_ERROR_CLASS` / `DNC_ERROR_GROUP`), which describe the same control error list. That the LSV2 codes share that
numbering is corroborated by the one real record we hold — the captured iTNC 530 "Run test of cutout channels" is class 7
`INFO`, group 4 `GENERAL`, a power-up self-test notice — and is otherwise **hardware-unproven** (the commissioning gate
should confirm it on a live control).
| Class | 1 Warning · 2 FeedHold · 3 ProgramHold · 4 ProgramAbort · **5 EmergencyStop** · 6 Reset · 7 Info · 8 Error · 9 Note |
|---|---|
| **Group** | **1 Operating** · **2 Programming** · 3 Plc · **4 General** · **5 Remote** · 6 Python |
The names are labels only — they drive no selection and no severity. They set the condition's Part 9
`ConditionClassId`/`ConditionClassName`, and only where a code says *where the alarm comes from*:
| Controller alarm | Part 9 class |
|---|---|
| class 5 (EmergencyStop), any group | `SafetyConditionClassType` |
| group 1 Operating / 2 Programming (machining and NC-program errors) | `ProcessConditionClassType` |
| group 4 General / 5 Remote (the control's own system and remote-interface messages) | `SystemConditionClassType` |
| group 3 Plc (machine-builder text: process, maintenance or safety alike), 6 Python, 0 / unknown | `BaseConditionClassType` ("not modelled") |
Nothing maps to `MaintenanceConditionClassType` — no code is evidence of it. A condition's class is that of its
**displayed** alarm, set on every Raise/Retrigger; a Clear or a quality-only event states none, so the node keeps the
class of the alarm it last showed (and a rebuild carries it). The condition message is unchanged.
- **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 (the class names above set the condition CLASS, never its severity).
- **Unclaimed alarms are reported, not dropped silently (Gitea #839).** On a control with no `*`/`OTHER` selector, an
alarm no `CLASS/GROUP/NUMBER` filter matches is shown by no condition. Each such alarm *entering* that set counts once
(a standing alarm is not re-counted per walk) on the OTel counter `otopcua.driver.alarms.unmatched`
(`driver_type=Heidenhain`, `driver_instance`), and `/hosts` diagnostics carry `<host>.alarms.unmatched` (unclaimed on
the last good walk) and `<host>.alarms.unmatchedTotal` (since the driver started). One Warning per control at most
per minute names up to three of them and how many more were suppressed since the previous line. A failed walk changes
nothing. Add a selector, or an `ALARM:OTHER` catch-all, to surface them.
- **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), CSV (`Alarm.AlarmType` / `Alarm.Severity` columns) and — since #839 — the **browse picker**, whose
`AlarmConditions/` folder offers `AnyAlarm` (`ALARM:*`), `OtherAlarms` (`ALARM:OTHER`), `ByClass/Class<n>_<Name>`
(`ALARM:CLASS=n`, classes 1–9) and `ByGroup/Group<n>_<Name>` (`ALARM:GROUP=n`, groups 1–6) as `IsAlarm` Boolean leaves;
a commit seeds the paired `alarm` object (severity 700), so the committed tag passes the factory unchanged. Ranges,
`NUMBER` and combined filters are still typed by hand. Discovery still contacts no control.
- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`).
## Quality
| Condition | Value status / capability |
|---|---|
| Refusal 22/24 | `BadNotSupported` / `Unsupported` — suppressed for the session |
| Refusal 23/49 | `BadUserAccessDenied` / `AccessDenied` — suppressed for the session, except a provisional denial while the optional DNC login is temporarily failing (retried) |
| Other refusal | `BadUnexpectedError` / `Unknown` — retried next poll |
| Malformed complete record (length, terminator, NaN, duplicate axis, …) | `BadDecodingError` / `InvalidData` |
| Binding type mismatch | `BadTypeMismatch` |
| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) |
| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` |
| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up |
| Not yet acquired | `BadWaitingForInitialData` |
| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` |
There is no cross-poll Good cache: each batch reads the wire; within a batch a shared selector (program,
overrides, axes, tool, the whole alarm walk) and aliased custom addresses are read once.
## Lifecycle, health and comms loss
- **Construction and `InitializeAsync` open nothing.** Acquisition starts on the first `SubscribeAsync` /
`ReadAsync`, so the `/raw` browse (universal discovery browser, static catalog, `RediscoverPolicy = Once`) never
opens a session to a production control.
- One background loop per control: connect → INSPECT login → `R_VR` (required) → optional DNC/DATA logins →
`R_PR` when PLC bindings exist → poll the Status/Custom/Slow groups. Each established session is a new
**generation**; a batch returning after its session was retired can never overwrite the outage invalidation.
- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change,
so a control returning with identical readings still re-publishes Good.
- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`, clamped to 100 ms–10 min).
The backoff resets only after a session **published a poll batch** — a control that accepts login + version and
then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect
failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off);
JSON-`null` config sections are normalised to defaults by the parser.
- 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
selector). The reference's poll-group "partial data" aggregate is on the `/hosts` diagnostics bag
(`<host:port>.data` = `Good`/`Partial`/`Lost`, plus `.state` and `.model`). Re-initialise tears down every
session first (#630).
## Test-Connect probe
`HeidenhainDriverProbe` (registered on admin and driver nodes, with the node's `ISecretResolver`) opens a real
session to the first device: INSPECT login + `R_VR`. A bare TCP listener does not read as healthy.
## Tests
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests` — in-process **LSV2 simulator** (`Simulator/`): a
scripted raw-socket peer for framing tests and a `SimulatedControl` model that records every request and flags
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`.
- `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
driver end-to-end status poll. Record its output as .NET commissioning evidence.
## Hardware-gated — not yet proven
No real control was available for this port. Still open:
- **.NET commissioning evidence** on a real control (the LiveIntegration gate) — browse/types/arrays through a
real OPC UA client, OPC write refusal, network interruption + recovery quality, bounded traffic under load.
- Controller families/versions other than iTNC 530 (TNC 640/620/320/128, TNC7, CNC PILOT/MANUALplus, MillPlus,
TNC 426/430) — see the [Control families](#control-families) matrix; everything there except iTNC 530 is per
pyLSV2 only.
- Successful raw **`R_MB`** decoding and its Boolean encoding (denied on all four tested controls).
- Physical units of axis positions / tool geometry vs. the HMI; experimental timer semantics (selectors 30–34).
- DNC/DATA permission matrices per control; sustained fleet/load operation.
## Deliberate exclusions
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 any controller-side alarm acknowledgement (native alarm
conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)).