768abdc1ab
v2-ci / unit-tests (push) Has been cancelled
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
510 lines
44 KiB
Markdown
510 lines
44 KiB
Markdown
# 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](#hardware-gated--not-yet-proven).
|
||
|
||
## Read-only boundary
|
||
|
||
Three independent layers, each sufficient on its own for its concern:
|
||
|
||
1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every
|
||
OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`).
|
||
2. **No writable nodes.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService`
|
||
create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the
|
||
manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
|
||
node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
|
||
the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
|
||
address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag
|
||
modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid
|
||
used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or
|
||
mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate
|
||
`HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI
|
||
path through the driver-dispatched `RawTagRowRules` (no call site names a driver).
|
||
3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks
|
||
command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s
|
||
public surface is pinned by a reflection test) and no configuration knob that widens it.
|
||
|
||
| Request | Permitted payload | Reply |
|
||
|---|---|---|
|
||
| `A_LG` | role ∈ {`INSPECT`, `DNC`, `DATA`} NUL, optional NUL-free Latin-1 password NUL | `T_OK` (empty) |
|
||
| `R_VR` | empty | `S_VR` |
|
||
| `R_PR` | empty | `S_PR` |
|
||
| `R_RI` | exactly a uint16be selector ∈ {22–28, 30–34, 51} | `S_RI` |
|
||
| `R_MB` | uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap | `S_MB` |
|
||
| `R_DP` | four zero bytes + NUL-free path + NUL (≤ 247 bytes total) | `S_DP` |
|
||
|
||
Everything else is refused before transmission — every `C_*` (file/PLC/parameter writes, buffer negotiation),
|
||
`A_LO`, file reads (`R_FL`, `R_DI`, …), key/scope operations, `PLCDEBUG`/`MONITOR`/`FILE` roles, response
|
||
mnemonics sent as requests. `Lsv2ReadOnlyPolicyTests.A_refused_request_never_reaches_the_socket` proves zero bytes
|
||
leave the process for a refused request, even through the internal exchange primitive. Socket close ends a
|
||
session (no logout command). **Do not broaden the allowlist to work around a controller refusal.**
|
||
|
||
## Transport
|
||
|
||
`uint32be payloadLength | ASCII command[4] | payload` — legacy buffer mode, 255-byte frame maximum, no `C_CC`
|
||
negotiation. One transaction at a time per control (a `SemaphoreSlim` in `Lsv2Client`, and a single acquisition
|
||
loop per device). Each transaction has **one monotonic deadline** (`TimeoutMs`) covering write + header + body, so a
|
||
trickling peer cannot extend it. EOF, deadline, an oversized (> 247) or unexpected reply, a malformed `T_ER`, or a
|
||
`T_OK` with a body **closes the client** — a later request can never consume leftover bytes. A complete
|
||
`T_ER`/`T_BD` refusal keeps the stream synchronised. `Close()` never waits for the transaction gate, so it always
|
||
interrupts a blocked read.
|
||
|
||
## Configuration
|
||
|
||
Driver config (`HeidenhainDriverOptions`, AdminUI **HeidenhainDriverForm**) — every field is int/bool/string (no
|
||
enums, so the numeric-enum form/factory trap cannot occur):
|
||
|
||
| Key | Default | Range / meaning |
|
||
|---|---|---|
|
||
| `TimeoutMs` | 5000 | 100–60000; connect + per-transaction deadline |
|
||
| `Access.TelemetryEnabled` | true | Off = no `R_RI` requests at all |
|
||
| `Access.DncLoginEnabled` | true | Optional DNC login; a refusal never blocks reads permitted under INSPECT |
|
||
| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists, and only on an iTNC 530 ([Control families](#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](#alarm-conditions-alarm-gitea-831)) |
|
||
| `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](#control-families)) | Slow |
|
||
| `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums, unchanged; `ExecutionStateName`, `ProgramStateName` (String) — their names per pyLSV2 ([below](#runtime-info-decoding--parity-with-pylsv2)) | `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](https://github.com/drunsinn/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](#control-families)) | see [Why raw `R_MB` is refused](#why-raw-r_mb-is-refused-plcdebug) |
|
||
| `R_DP` request | four `00` bytes + path (`/`→`\`, `"`→`'`) + NUL, no length check | same, ≤ 242 path characters | agree |
|
||
| `R_DP` value types | 2 `!h`, 3 `!l`, 5 `<d`, 8 Latin-1 (NULs stripped, right-trimmed), 11 `?`, 16 `!b`, 17 `!B`; lengths unchecked | same types; exact lengths, finite floats, Boolean `00`/`01`/`FF`, text kept verbatim | agree (ours stricter; no trimming) |
|
||
|
||
**Address notation.** pyLSV2's `read_plc_address` takes HEIDENHAIN PLC-program operands: `M<n>`, `I<n>`, `O<n>`,
|
||
`B<n>`, `S<n>` are entry/element numbers, while `W<n>`, `D<n>`, `IW<n>`, `OW<n>`, `ID<n>`, `OD<n>` are **byte**
|
||
addresses (pyLSV2 divides by 2 or 4 and silently truncates an unaligned one). No `T`/`C` operands. The correspondence
|
||
is: `M n` = `PLC:MARKER:n`, `I n` = `PLC:INPUT:n`, `O n` = `PLC:OUTPUT:n`, `B n` = `PLC:BYTE:n`, `S n` =
|
||
`PLC:STRING:n`, `W 2k` = `PLC:WORD:k`, `D 4k` = `PLC:DWORD:k`, `IW 2k` = `PLC:INPUT_WORD:k`, `OW 2k` =
|
||
`PLC:OUTPUT_WORD:k`, `ID 4k` = `PLC:INPUT_DWORD:k`, `OD 4k` = `PLC:OUTPUT_DWORD:k`. The `DATA:/PLC/memory/<op>/<n>`
|
||
paths use the same byte numbering as the PLC program. The driver does **not** accept the native operands as aliases:
|
||
a `PLC:` field in which `W8` means byte 8 but `WORD:8` means byte 16 is exactly the confusion the element-index rule
|
||
exists to avoid.
|
||
|
||
**Open question — PLC strings.** pyLSV2 returns the whole fixed-width buffer with NULs stripped, so any bytes after
|
||
the terminator stay in the text. This driver refuses anything other than NUL padding after the terminator
|
||
(`BadDecodingError`). If a real control leaves stale bytes after a shorter string, the right fix is "text up to the
|
||
first NUL", not pyLSV2's behaviour. Hardware-gated with raw `R_MB`.
|
||
|
||
### Why raw `R_MB` is refused (PLCDEBUG)
|
||
|
||
Raw `R_MB` was refused on every real iTNC 530 tried. pyLSV2 explains why: `read_plc_memory` and `read_plc_address`
|
||
both send `A_LG PLCDEBUG` before `R_MB` (pyLSV2's `const.py` itself hedges: "requires INSPECT (PLCDEBUG??)"), and
|
||
this driver never logs in as PLCDEBUG. pyLSV2 describes PLCDEBUG as "enables **write** access to PLC, requires
|
||
password", and uses the same role for `C_MC` (`set_machine_parameter`, a machine-parameter **write**). So PLCDEBUG is
|
||
a write-capable role, not a read-only one. pyLSV2's own default "safe mode" refuses the PLCDEBUG login (it allows
|
||
INSPECT/FILE/MONITOR only), and its PLC-memory demo turns safe mode off. pyLSV2 sends PLCDEBUG with an empty password,
|
||
which can only work on a control that has no PLCDEBUG password set (typically a programming station). A production
|
||
control has a password that would have to be entered here. **Owner decision:** raw PLC memory stays unavailable
|
||
unless the owner accepts a write-capable LSV2 session. `Lsv2ReadOnlyPolicy` would still refuse every write request,
|
||
but the controller would no longer be enforcing read-only. Until then, use `DATA:/PLC/memory/...` bindings, which
|
||
read the same operands under the DATA role. The decoders above are already pyLSV2-parity, so nothing else changes
|
||
if a role is ever enabled. All of this is per pyLSV2 and has not been tested on hardware.
|
||
|
||
## Control families
|
||
|
||
`R_VR`'s model field is classified once, in one place — `HeidenhainControlFamilies.Classify` (Contracts project,
|
||
`HeidenhainControlFamily`) — with pyLSV2's rule (MIT; `dat_cls.py` `VersionInfo.control`): strip spaces, compare
|
||
case-insensitively by prefix, `ITNC530` first. The result is published on the **`Controller/Family`** String leaf
|
||
(member name: `ITnc530`, `Tnc`, `Tnc7`, `Lathe`, `MillPlus`, `LegacyTnc`, `Unknown`), included in the Test-Connect
|
||
probe message (`TNC640 (Tnc) NC …`) and the `/hosts` diagnostics bag (`<host>.family`). It costs no request: it is
|
||
derived from the `R_VR` reply the session already reads. `Controller/Model` keeps the raw string.
|
||
|
||
The only code-level gate pyLSV2 applies to a request this driver makes is **DATA**: `read_data_path` returns early
|
||
on anything but an iTNC (`client.py` "does not work on non iTNC controls"). So a `DATA:` binding on any other family
|
||
— and on an `Unknown` model — is `BadNotSupported` with `Capabilities/DataLogin = Unsupported`, and **the DATA login
|
||
is never sent**. (Scope is pyLSV2's other iTNC-only gate; this driver has no scope.) Every other feature is
|
||
attempted on every family and settled by the control's own reply: `T_ER_UNKNOWN_TELE` (22) ⇒ `Unsupported`, asked
|
||
once per session, then suppressed; an access refusal ⇒ `AccessDenied`. No family pre-gates a selector — pyLSV2
|
||
records those as "observations, not guarantees" (`docs/protocol.rst`), and a pre-gate would hide data a newer NC
|
||
version serves.
|
||
|
||
| Family (`Controller/Family`) | Models (`R_VR` prefix) | Evidence | What the reference says | Driver behaviour |
|
||
|---|---|---|---|---|
|
||
| `ITnc530` | `iTNC530` / `iTNC 530` | **Hardware-proven** — four Hermle iTNC 530 (NC 340490 03/04, 606420 03; `evidence/heidenhain-live-2026-11-11.md`) | pyLSV2 `MILL_OLD`; DATA + scope iTNC-only; tool (51), alarms (27/28) and timers (30–34) served | All features attempted; DATA allowed (opt-in) |
|
||
| `Tnc` | `TNC640`, `TNC620`, `TNC320`, `TNC128` | Per pyLSV2, hardware-unproven | `MILL_NEW`. TNC 640 NC 340590 10 SP2: 27/28/51 and 30–34 → `T_ER_UNKNOWN_TELE`; 25/26 need DNC; 24 serves the program under INSPECT with block number 0 | `Tool/*`, `Alarms/*`, `ALARM:` conditions and `Timers/*` read `Unsupported` after one refused request; without DNC `Overrides/*` and `Status/ProgramState` are `AccessDenied` and `Program/Line` reads the control's raw 0. DATA `Unsupported`, not sent |
|
||
| `Tnc7` | `TNC7` | Per pyLSV2, hardware-unproven | `TNC7` — "behaves just a bit different again"; only keyboard/key functions are flagged (not used here) | Reply-driven (no selector observations recorded); DATA `Unsupported` |
|
||
| `Lathe` | `CNCPILOT640`, `MANUALPLUS` | Per pyLSV2, hardware-unproven | `LATHE_NEW`; protocol table: no tool (51), no error messages (27/28) | Those features settle `Unsupported` from the reply; DATA `Unsupported` |
|
||
| `MillPlus` | `MILLPLUS` (e.g. `MillPlusIT V600`) | Per pyLSV2, hardware-unproven | `MILLPLUS`; "not officially supported by any other Heidenhain tool" | Reply-driven; DATA `Unsupported` |
|
||
| `LegacyTnc` | `TNC426`, `TNC430` | **No reference** (pyLSV2 reports these `UNKNOWN`) | — | Named for the leaf only; reply-driven; DATA `Unsupported` |
|
||
| `Unknown` | anything else | — | pyLSV2 `UNKNOWN` | Reply-driven; DATA `Unsupported` |
|
||
|
||
Decoding is family-independent per pyLSV2: `R_VR` is 4 or 5 NUL-separated fields on every family (`client.py`
|
||
`_read_version`), `R_PR` is 120 or 124 bytes with one layout (`misc.py` `decode_system_parameters`), and the `R_RI`
|
||
decoders for 22–28/51 carry no family branch. The simulator exercises every family in both `R_VR` shapes, both
|
||
`R_PR` lengths and the read-only wire set (`Simulator/ControlProfiles.cs`, `HeidenhainControlFamilyTests`) — that is
|
||
evidence for the classification and the gates, **not** for any non-iTNC control's real replies.
|
||
|
||
**Open, needs real hardware or a request this driver deliberately does not send:**
|
||
|
||
- **Buffer size.** pyLSV2 negotiates 512–4096-byte telegrams with `C_CC` from `R_PR`'s max block length; `C_CC` is
|
||
a configuration command, outside the allowlist. We stay at the un-negotiated legacy frame on every family (pyLSV2's own default
|
||
is 256 bytes, so a control is expected to fit replies to it); a reply over 255 bytes is a transport error
|
||
(`Oversized LSV2 response`), never a truncated value.
|
||
- **Encrypted passwords.** pyLSV2's `R_PR` exposes a password-encryption key and notes TNC 640 sends some login
|
||
passwords encrypted (scope). Whether a password-protected DNC/DATA login on a `Tnc`/`Tnc7` needs that is unknown;
|
||
INSPECT and DNC with an empty password are unaffected.
|
||
- **Which family sends the 5-field `R_VR`**, and whether `R_RI 20/21` (TNC 640 axis config/physical names, outside
|
||
our selector allowlist) would be a better axes source there.
|
||
- **Selector availability per NC version** on `Tnc`/`Tnc7`/`Lathe`/`MillPlus` (the table above is one TNC 640
|
||
observation plus pyLSV2's test-suite gating), the `R_RI 22` axis encoding, override/tool units, and whether
|
||
`R_MB` is granted under INSPECT on any non-iTNC family.
|
||
|
||
## Runtime-info decoding — parity with pyLSV2
|
||
|
||
Every `R_RI` decoder was compared byte-for-byte with pyLSV2 (MIT, commit `97a3d691`: `client.py` `axes_location`,
|
||
`execution_state`, `program_status`, `program_stack`, `override_state`, `get_error_messages`, `spindle_tool_status`;
|
||
`misc.py` decoders; `const.py` `ParRRI`, `ExecState`, `PgmState`, `LSV2StatusCode`) and the captured iTNC 530
|
||
replies were run through pyLSV2 itself — our decoders produce the same values (`HeidenhainPyLsv2ParityTests`).
|
||
**No decode disagreement was found**, so no published value changed.
|
||
|
||
| Selector | Layout (ours = pyLSV2) | Notes |
|
||
|---|---|---|
|
||
| 22 axes | byte 0 opaque, count byte, `count` value strings, `count` names | We are stricter: unique non-blank names, finite values, ≤ 64 axes, no trailing bytes |
|
||
| 23 / 26 | uint16be | Names: `Manual, Mdi, PassReferences, SingleStep, Automatic, Undefined` / `Started, Stopped, Finished, Cancelled, Interrupted, Error, ErrorCleared, Idle, Undefined`; any other code ⇒ `Unknown` (pyLSV2 raises) |
|
||
| 24 | uint32be line, main, current | Strict on NUL padding. pyLSV2 notes a TNC 640 serves 24 without DNC but with line 0 |
|
||
| 25 | 3 × uint32be hundredths of percent: feed, spindle, rapid | — |
|
||
| 27 / 28 | uint16be class, uint16be group, int32be number, text | Only 57 ends the walk (pyLSV2 too). Trailing blanks in the text are **kept** (pyLSV2 strips them — see [Hardware-gated](#hardware-gated--not-yet-proven)) |
|
||
| 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](#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.
|
||
|
||
```jsonc
|
||
{ "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 N` reads 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 LSV2 `R_RI 23`, but its `ProgramStatus` (0 IDLE,
|
||
1 RUNNING, …) does **not** match LSV2 `R_RI 26` (0 Started … 7 Idle, which real controls confirm). So the class/group
|
||
numbers stay **hardware-unproven** except where a record above shows them; the Safety branch (class 5) has never
|
||
been observed.
|
||
|
||
| Class | 1 Warning · 2 FeedHold · 3 ProgramHold · 4 ProgramAbort · **5 EmergencyStop** · 6 Reset · 7 Info · 8 Error · 9 Note |
|
||
|---|---|
|
||
| **Group** | **1 Operating** · **2 Programming** · 3 Plc · **4 General** · **5 Remote** · 6 Python |
|
||
|
||
The names are labels only — they drive no selection and no severity. They set the condition's Part 9
|
||
`ConditionClassId`/`ConditionClassName`, and only where a code says *where the alarm comes from*:
|
||
|
||
| Controller alarm | Part 9 class |
|
||
|---|---|
|
||
| class 5 (EmergencyStop), any group | `SafetyConditionClassType` |
|
||
| group 1 Operating / 2 Programming (machining and NC-program errors) | `ProcessConditionClassType` |
|
||
| group 4 General / 5 Remote (the control's own system and remote-interface messages) | `SystemConditionClassType` |
|
||
| group 3 Plc (machine-builder text: process, maintenance or safety alike), 6 Python, 0 / unknown | `BaseConditionClassType` ("not modelled") |
|
||
|
||
Nothing maps to `MaintenanceConditionClassType` — no code is evidence of it. A condition's class is that of its
|
||
**displayed** alarm, set on every Raise/Retrigger; a Clear or a quality-only event states none, so the node keeps the
|
||
class of the alarm it last showed (and a rebuild carries it). The condition message is unchanged.
|
||
|
||
- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the
|
||
`Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored
|
||
tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no
|
||
driver-level enable flag.
|
||
- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector
|
||
agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The
|
||
tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data
|
||
subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds
|
||
`{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one.
|
||
- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is
|
||
deterministic (ascending class, group, number, text), never wire order. Message:
|
||
`-1 Run test of cutout channels (class 7, group 4) [+N more]`.
|
||
- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed
|
||
alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a
|
||
non-displayed member leaves → nothing; nothing matches → `Clear`.
|
||
- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No
|
||
class→severity table is invented (the class names above set the condition CLASS, never its severity).
|
||
- **Unclaimed alarms are reported, not dropped silently (Gitea #839).** On a control with no `*`/`OTHER` selector, an
|
||
alarm no `CLASS/GROUP/NUMBER` filter matches is shown by no condition. Each such alarm *entering* that set counts once
|
||
(a standing alarm is not re-counted per walk) on the OTel counter `otopcua.driver.alarms.unmatched`
|
||
(`driver_type=Heidenhain`, `driver_instance`), and `/hosts` diagnostics carry `<host>.alarms.unmatched` (unclaimed on
|
||
the last good walk) and `<host>.alarms.unmatchedTotal` (since the driver started). One Warning per control at most
|
||
per minute names up to three of them and how many more were suppressed since the previous line. A failed walk changes
|
||
nothing. Add a selector, or an `ALARM:OTHER` catch-all, to surface them.
|
||
- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the
|
||
driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its
|
||
first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept,
|
||
`NativeAlarmProjector` #647).
|
||
- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 →
|
||
`BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss
|
||
(`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only
|
||
event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down
|
||
session degrades health (the existing rule) — a refusal never does.
|
||
- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened:
|
||
`AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and
|
||
pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are
|
||
refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset;
|
||
an operator ack never reaches it.
|
||
- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop
|
||
every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists
|
||
(`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged).
|
||
- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the
|
||
alarm object), CSV (`Alarm.AlarmType` / `Alarm.Severity` columns) and — since #839 — the **browse picker**, whose
|
||
`AlarmConditions/` folder offers `AnyAlarm` (`ALARM:*`), `OtherAlarms` (`ALARM:OTHER`), `ByClass/Class<n>_<Name>`
|
||
(`ALARM:CLASS=n`, classes 1–9) and `ByGroup/Group<n>_<Name>` (`ALARM:GROUP=n`, groups 1–6) as `IsAlarm` Boolean leaves;
|
||
a commit seeds the paired `alarm` object (severity 700), so the committed tag passes the factory unchanged. Ranges,
|
||
`NUMBER` and combined filters are still typed by hand. Discovery still contacts no control.
|
||
- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`).
|
||
|
||
## Quality
|
||
|
||
| Condition | Value status / capability |
|
||
|---|---|
|
||
| Refusal 22/24 (`T_ER_UNKNOWN_TELE` / `T_ER_WRONG_PARA`) | `BadNotSupported` / `Unsupported` — suppressed for the session |
|
||
| Refusal 23/49 | `BadUserAccessDenied` / `AccessDenied` — suppressed for the session, except a provisional denial while the optional DNC login is temporarily failing (retried) |
|
||
| Refusal 52/72 (`T_ER_BAD_MEMADR` / `T_ER_OUT_OF_RANGE`) | `BadOutOfRange` / `InvalidData` — retried next poll |
|
||
| Refusal 58 (`T_ER_ACCESS_TIMEOUT`) | `BadTimeout` / `Unknown` — retried next poll |
|
||
| Refusal 56 (`T_ER_OPERATING_MODE`) | `BadInvalidState` / `Unknown` — retried next poll |
|
||
| Refusal 57 (`T_ER_NO_NEXT_ERROR`) outside the alarm walk | `BadNoData` / `Unknown` — retried (inside the walk it is the end of the list) |
|
||
| Other refusal | `BadUnexpectedError` / `Unknown` — retried next poll |
|
||
| Malformed complete record (length, terminator, NaN, duplicate axis, …) | `BadDecodingError` / `InvalidData` |
|
||
| Binding type mismatch | `BadTypeMismatch` |
|
||
| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) |
|
||
| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` |
|
||
| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up |
|
||
| Not yet acquired | `BadWaitingForInitialData` |
|
||
| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` |
|
||
|
||
There is no cross-poll Good cache: each batch reads the wire; within a batch a shared selector (program,
|
||
overrides, axes, tool, the whole alarm walk) and aliased custom addresses are read once.
|
||
|
||
## Lifecycle, health and comms loss
|
||
|
||
- **Construction and `InitializeAsync` open nothing.** Acquisition starts on the first `SubscribeAsync` /
|
||
`ReadAsync`, so the `/raw` browse (universal discovery browser, static catalog, `RediscoverPolicy = Once`) never
|
||
opens a session to a production control.
|
||
- One background loop per control: connect → INSPECT login → `R_VR` (required) → optional DNC/DATA logins →
|
||
`R_PR` when PLC bindings exist → poll the Status/Custom/Slow groups. Each established session is a new
|
||
**generation**; a batch returning after its session was retired can never overwrite the outage invalidation.
|
||
- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change,
|
||
so a control returning with identical readings still re-publishes Good.
|
||
- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`, clamped to 100 ms–10 min).
|
||
The backoff resets only after a session **published a poll batch** — a control that accepts login + version and
|
||
then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect
|
||
failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off);
|
||
JSON-`null` config sections are normalised to defaults by the parser.
|
||
- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
|
||
receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
|
||
disposed loop is a no-op.
|
||
- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control
|
||
reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous
|
||
session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason —
|
||
the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first
|
||
session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic.
|
||
- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with
|
||
`LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and
|
||
decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one
|
||
selector). The reference's poll-group "partial data" aggregate is on the `/hosts` diagnostics bag
|
||
(`<host:port>.data` = `Good`/`Partial`/`Lost`, plus `.state` and `.model`). Re-initialise tears down every
|
||
session first (#630).
|
||
|
||
## Test-Connect probe
|
||
|
||
`HeidenhainDriverProbe` (registered on admin and driver nodes, with the node's `ISecretResolver`) opens a real
|
||
session to the first device: INSPECT login + `R_VR`. A bare TCP listener does not read as healthy.
|
||
|
||
## Tests
|
||
|
||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests` — in-process **LSV2 simulator** (`Simulator/`): a
|
||
scripted raw-socket peer for framing tests and a `SimulatedControl` model that records every request and flags
|
||
any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed
|
||
as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three).
|
||
- `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`.
|
||
- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured
|
||
iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal,
|
||
handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set.
|
||
- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition.
|
||
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`,
|
||
`AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single
|
||
credential notice) + the fleet
|
||
parity suites.
|
||
- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional
|
||
`HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
|
||
driver end-to-end status poll. Record its output as .NET commissioning evidence.
|
||
|
||
## Hardware-gated — not yet proven
|
||
|
||
No real control was available for this port. Still open:
|
||
|
||
- **.NET commissioning evidence** on a real control (the LiveIntegration gate) — browse/types/arrays through a
|
||
real OPC UA client, OPC write refusal, network interruption + recovery quality, bounded traffic under load.
|
||
- Controller families/versions other than iTNC 530 (TNC 640/620/320/128, TNC7, CNC PILOT/MANUALplus, MillPlus,
|
||
TNC 426/430) — see the [Control families](#control-families) matrix; everything there except iTNC 530 is per
|
||
pyLSV2 only. `R_DP` on non-iTNC families is gated off (pyLSV2 restricts `read_data_path` to iTNC).
|
||
- Successful raw **`R_MB`** decoding and its Boolean encoding. It was denied on all four tested controls, and per
|
||
pyLSV2 that is because it needs the write-capable PLCDEBUG role
|
||
([why](#why-raw-r_mb-is-refused-plcdebug)) — which this read-only driver will not use. The `FF`-true bit,
|
||
`INPUT_DWORD`/`OUTPUT_DWORD` and PLC-string trailing bytes are per pyLSV2 or `R_DP` evidence, not `R_MB` captures.
|
||
- Physical units of axis positions / tool geometry vs. the HMI — the wire carries no unit and pyLSV2 assumes none, so
|
||
only a control can settle it. Timer semantics (selectors 30–34) are now **per pyLSV2** (uint32be seconds,
|
||
lifetime counters) but still unproven by this driver on hardware.
|
||
- Execution / program-state **names** are per pyLSV2 (its `PgmState` table comes from a public forum thread); the
|
||
2026-11-11 iTNC 530 values (running = 4 Automatic + 0 Started; idle = 7 Idle) are consistent with them.
|
||
- DNC/DATA permission matrices per control; sustained fleet/load operation.
|
||
- Whether any control pads `R_RI 22/24/27` strings with trailing blanks. pyLSV2 strips them (`ba_to_ustr`); this
|
||
driver keeps them, because an alarm text is part of a condition's identity and the captured records carry none.
|
||
|
||
## Deliberate exclusions
|
||
|
||
Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE
|
||
roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH,
|
||
PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm
|
||
conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)).
|