# Heidenhain LSV2 driver (read-only) `DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · Gitea **#803**. 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): the modals default it from the address, the save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older (one rule, `HeidenhainTagRules`). 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 (iTNC only) | | `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | | `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | | `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/` (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//apikey` | | `env:OTOPCUA_HEIDENHAIN_` (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 — 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 connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2 login payload cannot carry (NUL, non-Latin-1, > 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": "
", "dataType": "", "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` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved) | 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::` + `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:` + `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`). ## 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. - **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 (`.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`. - `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + 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 320/430/640) — unproven even in the Java reference. - 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 native alarms as Part 9 conditions (alarms are published as typed arrays).