Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
44 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
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_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).