feat(heidenhain): PLC/DATA parity with pyLSV2 — FF bits, INPUT/OUTPUT_DWORD, PLCDEBUG write-up
- PLC bit areas (MARKER/INPUT/OUTPUT/COUNTER/TIMER) accept 00/01/FF: real iTNC 530s return FF for the same operands over R_DP, and pyLSV2 decodes them as non-zero = true. 02..FE stay BadDecodingError. - New PLC:INPUT_DWORD / PLC:OUTPUT_DWORD areas (pyLSV2 MemoryType 13/12): signed int32le, element index x 4 over the input/output-word byte pools. - R_PR offsets agree with pyLSV2. Its count units (BYTE x2, WORD/IW as word counts) conflict with the captured controls, so we keep the captures and pin them in a test. - Docs: pyLSV2 correspondence table, native-operand mapping (no aliases), and why raw R_MB is refused (pyLSV2 logs in as the write-capable PLCDEBUG role). That stays an owner decision; Lsv2ReadOnlyPolicy is unchanged. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -141,9 +141,10 @@ the next populated advertised base is a defensive cap. Bit and STRING counts are
|
||||
|
||||
| Area | Native | Allowed `dataType` |
|
||||
|---|---|---|
|
||||
| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean (0/1 only) | Boolean |
|
||||
| `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
|
||||
@@ -156,6 +157,59 @@ 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 models containing `iTNC` | 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.
|
||||
|
||||
## 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
|
||||
@@ -334,7 +388,12 @@ 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).
|
||||
- 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)). The `FF`-true bit, `INPUT_DWORD`/`OUTPUT_DWORD` and PLC-string trailing
|
||||
bytes are per pyLSV2 or `R_DP` evidence, not `R_MB` captures.
|
||||
- `R_DP` on non-iTNC families: pyLSV2 restricts `read_data_path` to iTNC. This driver's DATA gate is the model string
|
||||
containing `iTNC`.
|
||||
- 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user