Files
lmxopcua/docs/drivers/Heidenhain.md
T
Joseph Doherty ddc1f29e3a feat(heidenhain): Part 9 condition class from alarm class/group, ALARM: selectors in the browse picker, unclaimed alarms counted + logged (#839)
- Core: optional AlarmEventArgs.ConditionClass (AlarmConditionClass Base/Process/Maintenance/System/Safety)
  carried by NativeAlarmProjector on AlarmConditionSnapshot and written to ConditionClassId/Name by
  OtOpcUaNodeManager (kept across class-less events, carried across a #641 rebuild).
- Heidenhain: HeidenhainAlarmClasses — HEIDENHAIN DNC-interface class/group names (corroborated by the
  captured iTNC 530 record); class 5 => Safety, group 1/2 => Process, 4/5 => System, else Base.
- DiscoverAsync offers AlarmConditions/ selector leaves (IsAlarm Boolean); browse-commit seeds the alarm object.
- Alarms no selector claims: otopcua.driver.alarms.unmatched counter, /hosts diagnostics, rate-limited Warning.

Refs #839
2026-11-11 03:41:14 -05:00

27 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 (iTNC only)
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 (String) R_VR (4 or 5 fields; SPLC skipped; empty options preserved) Slow
Status ExecutionState (Int32), ProgramState (Int32) — raw native enums R_RI 23, 26 Status
Program Main, Current (String), Line (Int64) R_RI 24 Status
Overrides Feed, Spindle, Rapid (Float64, percent) R_RI 25 (uint32 hundredths) Status
Axes Names (String[]), Positions (Float64[]) — aligned, controller units R_RI 22 Status
Tool Number (Int64), Index, AxisCode (Int32), Length, Radius (Float64) R_RI 51 (8-byte reply ⇒ geometry BadNotSupported, never 0) Slow
Alarms Classes, Groups, Numbers (Int32[]), Messages (String[]), Count (Int32) R_RI 27 then 28; only refusal 57 ends the list Slow
Timers NcUptime, MachineUptime, MachineRuntime, Spindle0Runtime, Spindle1Runtime (Int64, raw) R_RI 30–34, disabled by default Slow
Capabilities one String per feature + PlcLayout, DncLogin, DataLogin local, never a wire read Slow

Array leaves require "isArray": true (variable length); the typed editor and browse commit write it. Capability states: Unknown, Supported, Disabled, AccessDenied, Unsupported, InvalidData, CommunicationLost — evidence about the last read, not a licence assertion.

PLC bindings PLC:<AREA>:<INDEX> + dataType, read via R_MB. INDEX is an element index (1–10 digits, ≤ uint32), multiplied by the native width and added to the region base the control advertises in R_PR. Numeric R_PR counts are byte capacities (element count = ⌊bytes / width⌋; BYTE/WORD/DWORD share one pool); the next populated advertised base is a defensive cap. Bit and STRING counts are entries.

Area Native Allowed dataType
MARKER, INPUT, OUTPUT, COUNTER, TIMER one byte per Boolean (0/1 only) Boolean
BYTE / WORD / DWORD signed int8 / int16le / int32le Int16 (BYTE, WORD only), Int32, Int64
INPUT_WORD, OUTPUT_WORD uint16le Int32, Int64
STRING advertised fixed width, terminated Latin-1 (too wide ⇒ error, never truncated) String

Example: PLC:WORD:4999 → R_MB at base + 4999×2. Note that DATA:/PLC/memory/W/9998 uses a native byte offset — the two address spaces differ (see the wire reference).

DATA bindings DATA:<path> + dataType ∈ Boolean/Int16/Int32/Int64/Float64/String, read via R_DP under the opt-in DATA role (/→\, "→' normalisation; NUL-free Latin-1; ≤ 242 chars). Value types 2/3/5/8/11/16/17 are decoded strictly; Boolean accepts 00 and 01/FF (FF observed on real iTNC 530 I/O). Coercion never truncates or wraps — otherwise BadTypeMismatch.

At most 256 PLC/DATA bindings per control (excess are skipped and reported on /hosts).

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). LSV2 documents neither field (pyLSV2 and the Java port keep them raw). HeidenhainAlarmClasses carries HEIDENHAIN's own names from the DNC interface (RemoTools SDK DNC_ERROR_CLASS / DNC_ERROR_GROUP), which describe the same control error list. That the LSV2 codes share that numbering is corroborated by the one real record we hold — the captured iTNC 530 "Run test of cutout channels" is class 7 INFO, group 4 GENERAL, a power-up self-test notice — and is otherwise hardware-unproven (the commissioning gate should confirm it on a live control).

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 BadNotSupported / Unsupported — suppressed for the session
Refusal 23/49 BadUserAccessDenied / AccessDenied — suppressed for the session, except a provisional denial while the optional DNC login is temporarily failing (retried)
Other refusal BadUnexpectedError / Unknown — retried next poll
Malformed complete record (length, terminator, NaN, duplicate axis, …) BadDecodingError / InvalidData
Binding type mismatch BadTypeMismatch
Out-of-region binding, alarm overflow BadOutOfRange / InvalidData (never a truncated list)
Disabled feature (telemetry off, timers off, DATA off) BadNotSupported / Disabled
Unusable optional DNC/DATA credential, or a request the read-only policy refuses BadConfigurationError / ConfigurationError — that role's reads only; never sent; the session stays up
Not yet acquired BadWaitingForInitialData
Transport lost every remote value of that control BadNoCommunication at once; capabilities CommunicationLost

There is no cross-poll Good cache: each batch reads the wire; within a batch a shared selector (program, overrides, axes, tool, the whole alarm walk) and aliased custom addresses are read once.

Lifecycle, health and comms loss

  • Construction and InitializeAsync open nothing. Acquisition starts on the first SubscribeAsync / ReadAsync, so the /raw browse (universal discovery browser, static catalog, RediscoverPolicy = Once) never opens a session to a production control.
  • One background loop per control: connect → INSPECT login → R_VR (required) → optional DNC/DATA logins → R_PR when PLC bindings exist → poll the Status/Custom/Slow groups. Each established session is a new generation; a batch returning after its session was retired can never overwrite the outage invalidation.
  • Reads are cache-only (ReadAsync never touches the wire); subscriptions push on (value, status) change, so a control returning with identical readings still re-publishes Good.
  • Comms loss → all-value invalidation → bounded exponential reconnect (Reconnect.*, clamped to 100 ms–10 min). The backoff resets only after a session published a poll batch — a control that accepts login + version and then drops is backed off, not re-dialled every InitialDelayMs. Unlike the Java base, an initial connect failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off); JSON-null config sections are normalised to defaults by the parser.
  • Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never receives an older value after a newer one. A re-init disposes every control loop first; a late start of a disposed loop is a no-op.
  • 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 320/430/640) — unproven even in the Java reference.
  • Successful raw R_MB decoding and its Boolean encoding (denied on all four tested controls).
  • Physical units of axis positions / tool geometry vs. the HMI; experimental timer semantics (selectors 30–34).
  • DNC/DATA permission matrices per control; sustained fleet/load operation.

Deliberate exclusions

Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm conditions are acknowledged server-side only — see Alarm conditions).