Files
lmxopcua/docs/drivers/Heidenhain.md
T

510 lines
44 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, unchanged; `ExecutionStateName`, `ProgramStateName` (String) — their names per pyLSV2 ([below](#runtime-info-decoding--parity-with-pylsv2)) | `R_RI 23`, `26` (one read serves raw + name) | 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; no unit on the wire (pyLSV2 assumes none either) | `R_RI 22` | Status |
| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `AxisName` (String, `X`/`Y`/`Z`/`Unknown` per pyLSV2), `Length`, `Radius` (Float64, no unit on the wire) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0; `AxisName` still Good) | 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 uint32 **seconds** per pyLSV2 — lifetime counters, hardware-unproven here) | `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: `00` false, `01`/`FF` true (anything else ⇒ `BadDecodingError`) | Boolean |
| `BYTE` / `WORD` / `DWORD` | signed int8 / int16le / int32le | Int16 (BYTE, WORD only), Int32, Int64 |
| `INPUT_WORD`, `OUTPUT_WORD` | uint16le | Int32, Int64 |
| `INPUT_DWORD`, `OUTPUT_DWORD` | signed int32le over the input/output-word pool (per pyLSV2, hardware-unproven) | 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`).
**PLC bit `FF`.** Accepting `FF` for a raw `R_MB` bit (since 2026-11-12) follows the evidence, not a capture of `R_MB` itself: the same one-byte
operands read through `R_DP` return `FF` for a set input/output on a real iTNC 530 (`I.999`/`O.999` in
`Fixtures/plc-count-units.properties`), and pyLSV2 decodes these areas as "any non-zero is true". `02`–`FE` stay
malformed. Previously an `FF` raw bit read `BadDecodingError`.
### pyLSV2 correspondence (per pyLSV2, hardware-unproven)
[pyLSV2](https://github.com/drunsinn/pyLSV2) (MIT) is the other open implementation of these requests. Checked
2026-11-12; where it disagrees with a real-control capture, the capture wins.
| Item | pyLSV2 | This driver | Verdict |
|---|---|---|---|
| `R_PR` field offsets (`misc.py:decode_system_parameters`, `!14L8B8L…`, 120 or 124 bytes) | (base, count) pairs at 0/8/16/24/32/40/48, string length byte at 56, input words 64, output words 72 | same; trailing version/buffer/key fields unused | agree |
| `R_PR` numeric count **units** (`client.py:read_plc_memory`) | BYTE max = count×2, WORD/INPUT_WORD/OUTPUT_WORD max = count, DWORD and I/O DWORD max = count/4 | every numeric count is a byte capacity | **conflict — capture wins**: the three iTNC 530 controls refuse `B10000`/`W10000`/`IW1000`, and each base + count is exactly the next advertised base |
| `R_MB` address | `start + first_element × width` | `base + INDEX × width` | agree |
| `R_MB` count byte | `elements × width`, ≤ ⌊255/width⌋ − 1 elements per telegram | one element per request (`width` bytes, ≤ 247) | agree (STRING widths above 247 are refused rather than sent) |
| Signedness / endianness | BYTE `<b`, WORD `<h`, DWORD `<l`, I/O WORD `<H`, I/O DWORD `<l`, bits `?` | same; bits `00`/`01`/`FF` only | agree (bits stricter) |
| `INPUT_DWORD` / `OUTPUT_DWORD` (`MemoryType` 13/12) | supported | **added** 2026-11-12 | fixed |
| `STRING` read | `max_string_length` bytes, Latin-1, all NULs stripped and trailing whitespace trimmed | `width` bytes, text up to the NUL, only NUL padding allowed after it | divergent — see open question below |
| Roles | `R_MB` after a **`PLCDEBUG`** login; `R_DP` after `DATA` (iTNC only) | `R_MB` under INSPECT (+ optional DNC/DATA); `R_DP` under DATA, gated to the `ITnc530` control family ([Control families](#control-families)) | see [Why raw `R_MB` is refused](#why-raw-r_mb-is-refused-plcdebug) |
| `R_DP` request | four `00` bytes + path (`/`→`\`, `"`→`'`) + NUL, no length check | same, ≤ 242 path characters | agree |
| `R_DP` value types | 2 `!h`, 3 `!l`, 5 `<d`, 8 Latin-1 (NULs stripped, right-trimmed), 11 `?`, 16 `!b`, 17 `!B`; lengths unchecked | same types; exact lengths, finite floats, Boolean `00`/`01`/`FF`, text kept verbatim | agree (ours stricter; no trimming) |
**Address notation.** pyLSV2's `read_plc_address` takes HEIDENHAIN PLC-program operands: `M<n>`, `I<n>`, `O<n>`,
`B<n>`, `S<n>` are entry/element numbers, while `W<n>`, `D<n>`, `IW<n>`, `OW<n>`, `ID<n>`, `OD<n>` are **byte**
addresses (pyLSV2 divides by 2 or 4 and silently truncates an unaligned one). No `T`/`C` operands. The correspondence
is: `M n` = `PLC:MARKER:n`, `I n` = `PLC:INPUT:n`, `O n` = `PLC:OUTPUT:n`, `B n` = `PLC:BYTE:n`, `S n` =
`PLC:STRING:n`, `W 2k` = `PLC:WORD:k`, `D 4k` = `PLC:DWORD:k`, `IW 2k` = `PLC:INPUT_WORD:k`, `OW 2k` =
`PLC:OUTPUT_WORD:k`, `ID 4k` = `PLC:INPUT_DWORD:k`, `OD 4k` = `PLC:OUTPUT_DWORD:k`. The `DATA:/PLC/memory/<op>/<n>`
paths use the same byte numbering as the PLC program. The driver does **not** accept the native operands as aliases:
a `PLC:` field in which `W8` means byte 8 but `WORD:8` means byte 16 is exactly the confusion the element-index rule
exists to avoid.
**Open question — PLC strings.** pyLSV2 returns the whole fixed-width buffer with NULs stripped, so any bytes after
the terminator stay in the text. This driver refuses anything other than NUL padding after the terminator
(`BadDecodingError`). If a real control leaves stale bytes after a shorter string, the right fix is "text up to the
first NUL", not pyLSV2's behaviour. Hardware-gated with raw `R_MB`.
### Why raw `R_MB` is refused (PLCDEBUG)
Raw `R_MB` was refused on every real iTNC 530 tried. pyLSV2 explains why: `read_plc_memory` and `read_plc_address`
both send `A_LG PLCDEBUG` before `R_MB` (pyLSV2's `const.py` itself hedges: "requires INSPECT (PLCDEBUG??)"), and
this driver never logs in as PLCDEBUG. pyLSV2 describes PLCDEBUG as "enables **write** access to PLC, requires
password", and uses the same role for `C_MC` (`set_machine_parameter`, a machine-parameter **write**). So PLCDEBUG is
a write-capable role, not a read-only one. pyLSV2's own default "safe mode" refuses the PLCDEBUG login (it allows
INSPECT/FILE/MONITOR only), and its PLC-memory demo turns safe mode off. pyLSV2 sends PLCDEBUG with an empty password,
which can only work on a control that has no PLCDEBUG password set (typically a programming station). A production
control has a password that would have to be entered here. **Owner decision:** raw PLC memory stays unavailable
unless the owner accepts a write-capable LSV2 session. `Lsv2ReadOnlyPolicy` would still refuse every write request,
but the controller would no longer be enforcing read-only. Until then, use `DATA:/PLC/memory/...` bindings, which
read the same operands under the DATA role. The decoders above are already pyLSV2-parity, so nothing else changes
if a role is ever enabled. All of this is per pyLSV2 and has not been tested on hardware.
## 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.
## Runtime-info decoding — parity with pyLSV2
Every `R_RI` decoder was compared byte-for-byte with pyLSV2 (MIT, commit `97a3d691`: `client.py` `axes_location`,
`execution_state`, `program_status`, `program_stack`, `override_state`, `get_error_messages`, `spindle_tool_status`;
`misc.py` decoders; `const.py` `ParRRI`, `ExecState`, `PgmState`, `LSV2StatusCode`) and the captured iTNC 530
replies were run through pyLSV2 itself — our decoders produce the same values (`HeidenhainPyLsv2ParityTests`).
**No decode disagreement was found**, so no published value changed.
| Selector | Layout (ours = pyLSV2) | Notes |
|---|---|---|
| 22 axes | byte 0 opaque, count byte, `count` value strings, `count` names | We are stricter: unique non-blank names, finite values, ≤ 64 axes, no trailing bytes |
| 23 / 26 | uint16be | Names: `Manual, Mdi, PassReferences, SingleStep, Automatic, Undefined` / `Started, Stopped, Finished, Cancelled, Interrupted, Error, ErrorCleared, Idle, Undefined`; any other code ⇒ `Unknown` (pyLSV2 raises) |
| 24 | uint32be line, main, current | Strict on NUL padding. pyLSV2 notes a TNC 640 serves 24 without DNC but with line 0 |
| 25 | 3 × uint32be hundredths of percent: feed, spindle, rapid | — |
| 27 / 28 | uint16be class, uint16be group, int32be number, text | Only 57 ends the walk (pyLSV2 too). Trailing blanks in the text are **kept** (pyLSV2 strips them — see [Hardware-gated](#hardware-gated--not-yet-proven)) |
| 51 | uint32be number, uint16be index, uint16be axis (0 X, 1 Y, 2 Z), 8 or 24 bytes; geometry float64 **little**-endian | Live-proven (L 138.1506 on Hermle70) |
| 30–34 | uint32be, seconds | pyLSV2: NC uptime (+1/s), machine uptime, machine runtime (grows only while a program runs), spindle 0 / 1 runtime (0 if unused); observed there on iTNC 530 340490 02/04 and 606421 01, refused `T_ER_UNKNOWN_TELE` on a TNC 640. `TimersEnabled` stays **false** by default |
Refusal codes now map to the most specific status (table under [Quality](#quality)) and every refusal log line and
Test-Connect message names the code (`T_ER_NO_PRIV`, …) from pyLSV2's `LSV2StatusCode` table
(`Lsv2RefusalCodes`). Stability is unchanged — only 22/24/23/49 (and a credential problem) suppress a feature for
the session; every newly-named code is retried, as `BadUnexpectedError` was.
**Not adopted (outside the read-only allowlist):** pyLSV2 also reads `R_RI 20/21` (axis configuration / physical
names, layouts only partly known), `R_DT` (control clock, DIAGNOSTICS login) and the scope/file/key functions. None is
added; the allowlist is unchanged.
## 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, #853).** LSV2 documents neither field (pyLSV2 and the
Java port keep them raw — pyLSV2's `decode_error_message` is class/group/number/text only). The names in
`HeidenhainAlarmClasses` were originally written as the DNC interface's `DNC_ERROR_CLASS` / `DNC_ERROR_GROUP` enums
**from memory**; a 2026-11-13 search found **no public source for those enums or their numeric values**. What the
public sources do establish:
- **Names (vendor-documented, numbers not).** The iTNC 530 HSCI Technical Manual's PLC error table (`.PET`) has a
**Class** column — ERROR (collective), FEED HOLD, PGM HOLD, PGM ABORT, EMERG. STOP, RESET — and a **Group** column —
GENERAL, OPERATING, PROGRAMMING, PLC — plus a separate E/W/I message-type column. That is the same class set as our
2–6 (and in the same order) and the same group set as our 1–4; WARNING/INFO/NOTE look like the message type folded
into the LSV2 class code, which is unconfirmed.
- **Observed records (real iTNC 530, read-only).** class 7 / group 4 "Run test of cutout channels" (power-up self-test);
class 7 / group 3 "701550 Cabin unlocked! B301" (Hermle70, Hermle29); class 1 / group 3 "700520 Tool life exceeded"
(Hermle49). Independently, pyLSV2 issue #52 (another user's iTNC 530) logged class 3 / group 3
"010 Machine guard is open !" — a guard-open PLC message holding the program, which fits 3 = PGM HOLD **by meaning**,
not just by numbering. All three machine-builder PET messages report group 3 — consistent with 3 = PLC.
- **Verification route (read-only):** HEIDENHAIN's own TNCcmd `RUNINFO F` / `RUNINFO N` reads the same list and
displays the class and group (pyLSV2 issue #13). Running it against a control and comparing its labels with our raw
codes would check the table against vendor software instead of memory.
- ⚠️ **"LSV2 shares the DNC numbering" is not a safe general assumption.** The DNC COM interface's `ExecutionMode`
(0 MANUAL, 1 MDI, 2 RPF, 3 SINGLESTEP, 4 AUTOMATIC) matches LSV2 `R_RI 23`, but its `ProgramStatus` (0 IDLE,
1 RUNNING, …) does **not** match LSV2 `R_RI 26` (0 Started … 7 Idle, which real controls confirm). So the class/group
numbers stay **hardware-unproven** except where a record above shows them; the Safety branch (class 5) has never
been observed.
| 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 (`T_ER_UNKNOWN_TELE` / `T_ER_WRONG_PARA`) | `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) |
| Refusal 52/72 (`T_ER_BAD_MEMADR` / `T_ER_OUT_OF_RANGE`) | `BadOutOfRange` / `InvalidData` — retried next poll |
| Refusal 58 (`T_ER_ACCESS_TIMEOUT`) | `BadTimeout` / `Unknown` — retried next poll |
| Refusal 56 (`T_ER_OPERATING_MODE`) | `BadInvalidState` / `Unknown` — retried next poll |
| Refusal 57 (`T_ER_NO_NEXT_ERROR`) outside the alarm walk | `BadNoData` / `Unknown` — retried (inside the walk it is the end of the list) |
| 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. `R_DP` on non-iTNC families is gated off (pyLSV2 restricts `read_data_path` to iTNC).
- Successful raw **`R_MB`** decoding and its Boolean encoding. It was denied on all four tested controls, and per
pyLSV2 that is because it needs the write-capable PLCDEBUG role
([why](#why-raw-r_mb-is-refused-plcdebug)) — which this read-only driver will not use. The `FF`-true bit,
`INPUT_DWORD`/`OUTPUT_DWORD` and PLC-string trailing bytes are per pyLSV2 or `R_DP` evidence, not `R_MB` captures.
- Physical units of axis positions / tool geometry vs. the HMI — the wire carries no unit and pyLSV2 assumes none, so
only a control can settle it. Timer semantics (selectors 30–34) are now **per pyLSV2** (uint32be seconds,
lifetime counters) but still unproven by this driver on hardware.
- Execution / program-state **names** are per pyLSV2 (its `PgmState` table comes from a public forum thread); the
2026-11-11 iTNC 530 values (running = 4 Automatic + 0 Started; idle = 7 Idle) are consistent with them.
- DNC/DATA permission matrices per control; sustained fleet/load operation.
- Whether any control pads `R_RI 22/24/27` strings with trailing blanks. pyLSV2 strips them (`ba_to_ustr`); this
driver keeps them, because an alarm text is part of a condition's identity and the captured records carry none.
## 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)).