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

16 KiB
Raw Blame History

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.

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, > 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).