The ignitionoee CR capture (6d2aec40: 7 h read-only sample of 34 production controls on the Java LSV2 driver) and the 2026-11-11 iTNC 530 run did not change the driver's code: ProgramState 0 = cutting (11,865 of 11,869 motion pairs), 7 = idle; TNC 426/430 BadNotSupported; overrides BadUserAccessDenied under INSPECT; capabilities Unknown until first poll; alarm classes 1/7/8 seen, class 5 not. Owner accepted the driver on that basis; remaining limits stay documented in Heidenhain.md §Hardware-gated.
45 KiB
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:
- No write capability.
HeidenhainDriverdoes not implementIWritable;DriverInstanceActorrefuses every OPC write before it reaches the driver (HeidenhainDriverEndToEndTests). - No writable nodes. A
ReadWriteaccess level is refused at authoring —RawTreeServicecreate/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 gateHeidenhainTagWritable, so no node is ever advertised writable. A"writable": trueTagConfig key is rejected by the tag reader and dropped by the typed editor. The same paths tieTag.DataTypeto what the driver publishes (the catalog type for a fixed address —Float64⇒Double— or the binding's declared type;Booleanfor anALARM: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 gateHeidenhainTagDataTypeMismatchcatches anything older. One rule (HeidenhainTagRules), reached by every AdminUI path through the driver-dispatchedRawTagRowRules(no call site names a driver). - 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_CCfromR_PR's max block length;C_CCis 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_PRexposes a password-encryption key and notes TNC 640 sends some login passwords encrypted (scope). Whether a password-protected DNC/DATA login on aTnc/Tnc7needs that is unknown; INSPECT and DNC with an empty password are unaffected. - Which family sends the 5-field
R_VR, and whetherR_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), theR_RI 22axis encoding, override/tool units, and whetherR_MBis 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). Independently, pyLSV2 issue #52 (another user's iTNC 530) logged class 3 / group 3 "010 Machine guard is open !" — a guard-open PLC message holding the program, which fits 3 = PGM HOLD by meaning, not just by numbering. All three machine-builder PET messages report group 3 — consistent with 3 = PLC.
- Verification route (read-only): HEIDENHAIN's own TNCcmd
RUNINFO F/RUNINFO Nreads the same list and displays the class and group (pyLSV2 issue #13). Running it against a control and comparing its labels with our raw codes would check the table against vendor software instead of memory. - ⚠️ "LSV2 shares the DNC numbering" is not a safe general assumption. The DNC COM interface's
ExecutionMode(0 MANUAL, 1 MDI, 2 RPF, 3 SINGLESTEP, 4 AUTOMATIC) matches LSV2R_RI 23, but itsProgramStatus(0 IDLE, 1 RUNNING, …) does not match LSV2R_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.Enabledbecame 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): anALARM:address without thealarmobject, or analarmobject on any other address, is refused. The tag's data type is fixed atBoolean; it is never a value (ReadAsyncanswersBadNotSupported), never in a data subscription, never writable (theHeidenhainTagWritablegate still applies). The typed editor seeds{"alarmType":"AlarmCondition","severity":700}when you pick anALARM:selector and drops it when you leave one. - Identity = (class, group, number, text) — the number alone is not unique (
-1above). 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.severityis 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
*/OTHERselector, an alarm noCLASS/GROUP/NUMBERfilter 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 counterotopcua.driver.alarms.unmatched(driver_type=Heidenhain,driver_instance), and/hostsdiagnostics 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 anALARM:OTHERcatch-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/alertsrow, 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:
AcknowledgeAsyncis 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 refusedBadNotSupportedby 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/28the arrays use, on the control's single acquisition loop everyPolling.AlarmMs, and only for a control that carries anALARM:tag. No new command type exists (HeidenhainAlarmTestsasserts the wire log is a subset of the read set andLsv2ReadOnlyPolicyis unchanged). - Authoring: the
/rawtag modal (Native alarmsection + the typed editor), manual entry (the editor seeds the alarm object), CSV (Alarm.AlarmType/Alarm.Severitycolumns) and — since #839 — the browse picker, whoseAlarmConditions/folder offersAnyAlarm(ALARM:*),OtherAlarms(ALARM:OTHER),ByClass/Class<n>_<Name>(ALARM:CLASS=n, classes 1–9) andByGroup/Group<n>_<Name>(ALARM:GROUP=n, groups 1–6) asIsAlarmBoolean leaves; a commit seeds the pairedalarmobject (severity 700), so the committed tag passes the factory unchanged. Ranges,NUMBERand combined filters are still typed by hand. Discovery still contacts no control. - E2E:
Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests(real driver → simulator → realDriverHostActor).
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
InitializeAsyncopen nothing. Acquisition starts on the firstSubscribeAsync/ReadAsync, so the/rawbrowse (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_PRwhen 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 (
ReadAsyncnever 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 everyInitialDelayMs. Unlike the Java base, an initial connect failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off); JSON-nullconfig 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 readsR_VRat 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 raisesOnRediscoveryNeededonce 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:
Degradedonly while a control's session is down (connect failing / lost), withLastSuccessfulReadfrozen — 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/hostsdiagnostics bag (<host:port>.data=Good/Partial/Lost, plus.stateand.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 aSimulatedControlmodel 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 insideDriverInstanceActor.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 → realDriverHostActorcondition.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 unlessHEIDENHAIN_LSV2_HOSTis set; optionalHEIDENHAIN_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
Accepted 2026-11-13 (#829, #853 closed). The owner accepted the driver because the hardware evidence did not
change its code. That evidence is the 2026-11-11 iTNC 530 run (evidence/heidenhain-live-2026-11-11.md) plus the
ignitionoee CR capture of 2026-11-13 (6d2aec40, docs/recipes/evidence/heidenhain-runstate-sample-2026-11-13/): a
7 h read-only sample of 34 production controls on the Java LSV2 driver this one was ported from. It showed
ProgramState 0 coinciding with axis motion in 11,865 of 11,869 sample pairs and 7 = idle. TNC 426/430 (NC 280476)
answered BadNotSupported for status, program, axes and alarms. Overrides were BadUserAccessDenied under INSPECT,
capabilities read Unknown until their first poll, and comms loss recovered on reconnect. Alarm classes 1/7/8 were
seen; class 5 was not. The items below remain known limits, not acceptance blockers.
No real control was available for this port at first. 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_DPon non-iTNC families is gated off (pyLSV2 restrictsread_data_pathto iTNC). - Successful raw
R_MBdecoding 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. TheFF-true bit,INPUT_DWORD/OUTPUT_DWORDand PLC-string trailing bytes are per pyLSV2 orR_DPevidence, notR_MBcaptures. - 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
PgmStatetable 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/27strings 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).