Merge branch 'worktree-agent-a71443b71c4c01c13' into heidenhain-pylsv2-parity

# Conflicts:
#	.claude/rules/drivers.md
#	docs/drivers/Heidenhain.md
#	tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPyLsv2ParityTests.cs
This commit is contained in:
Joseph Doherty
2026-11-12 23:57:06 -05:00
7 changed files with 270 additions and 10 deletions
@@ -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."),
};
@@ -166,6 +166,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();
@@ -179,6 +188,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);
}
@@ -187,7 +199,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();