Files
lmxopcua/docs/drivers/Heidenhain.md
T
Joseph Doherty 54f58c583c fix(heidenhain): refuse literal credentials and writable/mistyped tags at the save path (#803 review)
- Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain
  config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_...
  (DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level,
  Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password
  never reaches the draft DB. The driver and device modals show the reason and disable Save.
- Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit)
  refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver
  publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType).
  The tag modal and manual-entry grid fix the access level at Read and derive the data type from
  the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch.
- docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy
  enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes.

Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new
DraftValidatorHeidenhainTests cases.
2026-11-10 23:54:32 -05:00

228 lines
16 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**.
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/<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 — 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, &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` (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:<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`).
## 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
(`<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`.
- `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).