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:
Joseph Doherty
2026-11-12 23:53:16 -05:00
parent d9b2381235
commit 50933d57fc
7 changed files with 271 additions and 9 deletions
+4
View File
@@ -353,6 +353,10 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`.
`BadConfigurationError` on that role's reads only
(`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence;
`Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`.
Raw `R_MB` is refused on real iTNC 530s because (per pyLSV2) it needs the **PLCDEBUG** login, a write-capable role
(pyLSV2 also uses it for `C_MC` machine-parameter writes). Enabling it is an owner decision. Do not add it to work
around the refusal; `DATA:/PLC/memory/…` reads the same operands. PLC/DATA decoding is pyLSV2-parity, except
pyLSV2's R_PR count units, which the real captures contradict (§"pyLSV2 correspondence").
- **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=<r>[/GROUP=<r>][/NUMBER=<r>]`, `<r>` = `n` |
`lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save
path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*`
+61 -2
View File
@@ -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.
@@ -17,6 +17,15 @@ public enum HeidenhainPlcArea
String,
InputWord,
OutputWord,
/// <summary>
/// Signed int32le view over the INPUT_WORD region (element index × 4 + the input-word base) — per pyLSV2 (MIT)
/// const.py:MemoryType.INPUT_DWORD / client.py:read_plc_memory, hardware-unproven.
/// </summary>
InputDword,
/// <summary>Signed int32le view over the OUTPUT_WORD region — per pyLSV2 (MIT) const.py:MemoryType.OUTPUT_DWORD, hardware-unproven.</summary>
OutputDword,
}
/// <summary>What an address resolves to.</summary>
@@ -85,6 +94,8 @@ public sealed record HeidenhainAddress(
["STRING"] = HeidenhainPlcArea.String,
["INPUT_WORD"] = HeidenhainPlcArea.InputWord,
["OUTPUT_WORD"] = HeidenhainPlcArea.OutputWord,
["INPUT_DWORD"] = HeidenhainPlcArea.InputDword,
["OUTPUT_DWORD"] = HeidenhainPlcArea.OutputDword,
};
/// <summary>The wire spelling of a PLC area (<c>MARKER</c>, <c>INPUT_WORD</c>, …).</summary>
@@ -101,11 +112,15 @@ public sealed record HeidenhainAddress(
public static bool IsBitArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Marker or HeidenhainPlcArea.Input
or HeidenhainPlcArea.Output or HeidenhainPlcArea.Counter or HeidenhainPlcArea.Timer;
/// <summary>True for the integer areas (BYTE/WORD/DWORD signed; INPUT_WORD/OUTPUT_WORD unsigned 16-bit).</summary>
/// <summary>
/// True for the integer areas (BYTE/WORD/DWORD and INPUT_DWORD/OUTPUT_DWORD signed; INPUT_WORD/OUTPUT_WORD
/// unsigned 16-bit).
/// </summary>
/// <param name="area">The area.</param>
/// <returns>Whether it is an integer area.</returns>
public static bool IsIntegerArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word
or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord;
or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord
or HeidenhainPlcArea.InputDword or HeidenhainPlcArea.OutputDword;
/// <summary>
/// Parses an address. Never throws; <paramref name="error"/> explains a rejection.
@@ -52,9 +52,12 @@ public static class HeidenhainBindingCodec
if (HeidenhainAddress.IsBitArea(area))
{
Lsv2Codec.Length(bytes, 1);
// Raw R_MB Boolean encoding is unverified on real controls (R_MB was denied on all four tested);
// the reference accepts only 0/1 here — FF is accepted only for DATA path Booleans.
if (bytes[0] is not (0 or 1)) throw new Lsv2DecodeException("Invalid PLC boolean.");
// Raw R_MB Boolean encoding is unverified on real controls (R_MB was denied on all four tested). The
// SAME one-byte-per-entry operands read through R_DP return FF for a set input/output on a real
// iTNC 530 (plc-count-units.properties hermle59 I.999/O.999), and pyLSV2 (MIT)
// client.py:read_plc_memory decodes these areas with struct "?" (any non-zero = true). Accept the
// observed 00/01/FF — anything else is still malformed, never repaired into a Good value.
if (bytes[0] is not (0 or 1 or 0xFF)) throw new Lsv2DecodeException("Invalid PLC boolean.");
value = bytes[0] != 0;
}
else
@@ -66,6 +69,9 @@ public static class HeidenhainBindingCodec
HeidenhainPlcArea.Dword => Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)),
HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord =>
Exact(bytes, 2, b => (int)BinaryPrimitives.ReadUInt16LittleEndian(b)),
// per pyLSV2 (MIT) client.py:read_plc_memory — INPUT_DWORD/OUTPUT_DWORD unpack "<l" (signed).
HeidenhainPlcArea.InputDword or HeidenhainPlcArea.OutputDword =>
Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)),
HeidenhainPlcArea.String => Lsv2Codec.Text(bytes),
_ => throw new Lsv2DecodeException("Unknown PLC area."),
};
@@ -136,6 +136,15 @@ public static class Lsv2Data
/// B9999/W9998/D9996 are the last valid values for a count of 10000); bit and string counts are entries.
/// The next populated distinct area remains a defensive cap for inconsistent layouts.
/// </summary>
/// <remarks>
/// Field offsets agree with pyLSV2 (MIT) misc.py:decode_system_parameters (<c>!14L8B8L…</c>): eight
/// uint32be (base, count) pairs for markers/inputs/outputs/counters/timers/words/strings at 0–55, the string
/// length byte at 56, input words at 64, output words at 72; the trailing LSV2-version/buffer/key fields are
/// not used. pyLSV2's count UNITS conflict with the real captures and are deliberately not followed:
/// client.py:read_plc_memory caps BYTE at count×2, WORD and INPUT_WORD/OUTPUT_WORD at count (as if the
/// count were words) and only DWORD at count/4 — the iTNC 530 replies in plc-count-units.properties
/// refuse B10000, W10000 and IW1000, and every advertised base + count lands exactly on the next base.
/// </remarks>
public static Lsv2Layout Layout(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != 120 && bytes.Length != 124) throw Bad();
@@ -149,6 +158,9 @@ public static class Lsv2Data
Add(regions, bytes, HeidenhainPlcArea.String, 48, stringWidth, stringWidth);
Add(regions, bytes, HeidenhainPlcArea.InputWord, 64, 1, 2);
Add(regions, bytes, HeidenhainPlcArea.OutputWord, 72, 1, 2);
// per pyLSV2 (MIT) client.py:read_plc_memory — the DWORD views share the input/output-word pools.
Add(regions, bytes, HeidenhainPlcArea.InputDword, 64, 1, 4);
Add(regions, bytes, HeidenhainPlcArea.OutputDword, 72, 1, 4);
CapNumericRegionsAtNextArea(regions);
return new Lsv2Layout(regions);
}
@@ -157,7 +169,11 @@ public static class Lsv2Data
{
// Same-base BYTE/WORD/DWORD views intentionally share an area, not a boundary.
var starts = new SortedSet<long>(regions.Values.Where(r => r.Bytes > 0 && r.Width > 0).Select(r => r.Base));
foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord })
foreach (var area in new[]
{
HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord,
HeidenhainPlcArea.OutputWord, HeidenhainPlcArea.InputDword, HeidenhainPlcArea.OutputDword,
})
{
var region = regions[area];
var next = starts.GetViewBetween(region.Base + 1, long.MaxValue).Cast<long?>().FirstOrDefault();
@@ -0,0 +1,162 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
/// <summary>
/// PLC-memory (<c>R_PR</c>/<c>R_MB</c>) and DATA-path (<c>R_DP</c>) parity with pyLSV2 (MIT). Where pyLSV2
/// disagrees with a real iTNC 530 capture the capture wins and the test pins the capture (see
/// <c>docs/drivers/Heidenhain.md</c> §"pyLSV2 correspondence").
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainPyLsv2ParityTests
{
[Theory]
[InlineData(HeidenhainPlcArea.Marker)]
[InlineData(HeidenhainPlcArea.Input)]
[InlineData(HeidenhainPlcArea.Output)]
[InlineData(HeidenhainPlcArea.Counter)]
[InlineData(HeidenhainPlcArea.Timer)]
public void Plc_bit_areas_accept_the_ff_true_byte_real_controls_return_for_the_same_operands(HeidenhainPlcArea area)
{
// R_DP /PLC/memory/I/999 answered type 11 + FF on hermle59; pyLSV2 read_plc_memory unpacks "?" (non-zero = true).
HeidenhainBindingCodec.DecodePlc(area, [0xFF], DriverDataType.Boolean).ShouldBe(true);
HeidenhainBindingCodec.DecodePlc(area, [0x01], DriverDataType.Boolean).ShouldBe(true);
HeidenhainBindingCodec.DecodePlc(area, [0x00], DriverDataType.Boolean).ShouldBe(false);
// Still strict: pyLSV2 would call 0x02 true, but no control has been seen to send it.
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(area, [0x02], DriverDataType.Boolean));
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(area, [0x00, 0x00], DriverDataType.Boolean));
}
[Fact]
public void The_captured_ff_bit_is_the_same_byte_the_r_mb_decoder_now_accepts()
{
var f = Fixtures.Load("plc-count-units.properties");
var reply = f["hermle59.I.999"]; // S_DP:0000000bff
var value = Convert.FromHexString(reply["S_DP:".Length..]);
Lsv2Data.DataPath(value).ShouldBe(true);
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Input, value.AsSpan(4), DriverDataType.Boolean).ShouldBe(true);
}
[Theory]
[InlineData("PLC:INPUT_DWORD:3", HeidenhainPlcArea.InputDword)]
[InlineData("PLC:OUTPUT_DWORD:0", HeidenhainPlcArea.OutputDword)]
public void Input_and_output_dword_areas_parse_as_element_indexes(string address, HeidenhainPlcArea area)
{
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int32"), out var d, out var error)
.ShouldBeTrue(error);
d!.Address.Area.ShouldBe(area);
d.Address.Text.ShouldBe(address);
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int64"), out _, out _).ShouldBeTrue();
// int32 must not be narrowed into Int16; it is not a Boolean/String area.
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int16"), out _, out _).ShouldBeFalse();
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Boolean"), out _, out _).ShouldBeFalse();
HeidenhainAddress.AreaTokens.ShouldContain(address.Split(':')[1]);
}
[Fact]
public void Dword_io_views_share_the_word_pools_with_element_index_times_four()
{
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(iw.Base + 4, 4));
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(ow.Base + 4, 4));
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 2, layout));
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 2, layout));
}
[Theory]
[InlineData("hermle59")]
[InlineData("hermle70")]
[InlineData("hermle29")]
public void Captured_layouts_give_dword_io_views_a_quarter_of_the_byte_pool(string machine)
{
// The input/output word pools are 1000 BYTES on these controls (IW998 is the last valid R_DP word); a
// DWORD view therefore holds 250 elements. pyLSV2 caps INPUT_DWORD at count/4 too.
var f = Fixtures.Load("plc-count-units.properties");
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 249, layout).Address.ShouldBe(iw.Base + 996);
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 250, layout));
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 249, layout).Address.ShouldBe(ow.Base + 996);
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 250, layout));
}
[Theory]
[InlineData("hermle59")]
[InlineData("hermle70")]
[InlineData("hermle29")]
public void Captured_layouts_contradict_pyLSV2_word_count_units(string machine)
{
// pyLSV2 read_plc_memory: BYTE max = count×2, WORD max = count, INPUT_WORD max = count (count read as words).
// The real controls refuse B10000 / W10000 / IW1000 over R_DP, and each advertised base + count is exactly
// the next advertised base — the counts are bytes. Pin the capture, not pyLSV2.
var f = Fixtures.Load("plc-count-units.properties");
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
var words = layout.Region(HeidenhainPlcArea.Word)!;
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
var markers = layout.Region(HeidenhainPlcArea.Marker)!;
(words.Base + words.Bytes).ShouldBe(iw.Base);
(iw.Base + iw.Bytes).ShouldBe(ow.Base);
(ow.Base + ow.Bytes).ShouldBe(markers.Base);
f[machine + ".B.10000"].ShouldStartWith("T_ER");
f[machine + ".W.9998"].ShouldStartWith("S_DP");
f[machine + ".IW.998"].ShouldStartWith("S_DP");
f[machine + ".IW.1000"].ShouldStartWith("T_ER");
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10000, layout));
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5000, layout));
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 500, layout));
}
[Fact]
public void Dword_io_decodes_signed_int32_little_endian()
{
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int32).ShouldBe(-2);
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.OutputDword, [0x01, 0x02, 0x03, 0x04], DriverDataType.Int64).ShouldBe(0x04030201L);
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0, 0], DriverDataType.Int32));
}
[Fact]
public void Plc_request_is_element_index_times_width_plus_base_like_pyLSV2()
{
// pyLSV2 read_plc_memory: address = start + first_element × mem_byte_count; count byte = width for one element.
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
var w = layout.Region(HeidenhainPlcArea.Word)!;
var range = HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 3, layout);
range.ShouldBe(new HeidenhainMemoryRange(w.Base + 6, 2));
var payload = Lsv2ReadOnlyPolicy.MemoryPayload(range.Address, range.Bytes);
BinaryPrimitives.ReadUInt32BigEndian(payload).ShouldBe((uint)(w.Base + 6));
payload[4].ShouldBe((byte)2);
}
[Fact]
public void Data_path_request_matches_pyLSV2_formatting()
{
// pyLSV2 read_data_path: four 0x00 bytes + path with / → \ and " → ' + NUL.
var payload = Lsv2ReadOnlyPolicy.DataPathPayload("/TABLE/TOOL/T/1/\"DOC\"");
payload[..4].ShouldBe(new byte[4]);
Encoding.Latin1.GetString(payload, 4, payload.Length - 4).ShouldBe("\\TABLE\\TOOL\\T\\1\\'DOC'\0");
}
[Fact]
public void Data_path_value_types_match_pyLSV2_table()
{
Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2); // !h
Lsv2Data.DataPath([0, 0, 0, 3, 0xFF, 0xFF, 0xFF, 0xFE]).ShouldBe(-2); // !l
var dbl = new byte[12];
dbl[3] = 5;
BinaryPrimitives.WriteDoubleLittleEndian(dbl.AsSpan(4), 1.25); // <d
Lsv2Data.DataPath(dbl).ShouldBe(1.25);
Lsv2Data.DataPath([0, 0, 0, 8, (byte)'A', 0]).ShouldBe("A");
Lsv2Data.DataPath([0, 0, 0, 16, 0xFF]).ShouldBe(-1); // !b
Lsv2Data.DataPath([0, 0, 0, 17, 0xFF]).ShouldBe(255); // !B
}
}
@@ -153,7 +153,7 @@ public sealed class Lsv2DataTests
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputWord, [0xFF, 0xFF], DriverDataType.Int32).ShouldBe(65535);
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Word, [0xFF, 0xFF], DriverDataType.Int16).ShouldBe((short)-1);
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [1], DriverDataType.Boolean).ShouldBe(true);
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0xFF], DriverDataType.Boolean));
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0x02], DriverDataType.Boolean));
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, new byte[3], DriverDataType.Int64));
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1.5, DriverDataType.Int32));
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(32768, DriverDataType.Int16));