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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user