Files
lmxopcua/docs/drivers/Heidenhain.md
T
Joseph Doherty 2e537dc6ca
v2-ci / unit-tests (push) Has been cancelled
docs(heidenhain): honest provenance for alarm class/group codes (#853)
No public source exists for DNC_ERROR_CLASS / DNC_ERROR_GROUP or their numbers
(the table was written from memory). The iTNC 530 Technical Manual's PLC error
table documents the same class and group NAMES without codes; three real
iTNC 530 records corroborate 7/4, 7/3 and 1/3. DNC and LSV2 numberings are
known to differ (ProgramStatus vs R_RI 26), so the numbers stay unproven.
Docs/comments only; no behaviour change.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-13 00:36:11 -05:00

43 KiB
Raw Blame History

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.

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)
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)
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, > 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) Slow
Status ExecutionState (Int32), ProgramState (Int32) — raw native enums, unchanged; ExecutionStateName, ProgramStateName (String) — their names per pyLSV2 (below) 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 (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) see Why raw R_MB is refused
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)
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) 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.

{ "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). Both 7xxxxx records are machine-builder PET messages and both report group 3 — consistent with 3 = PLC.
  • ⚠️ "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 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) — 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).