feat(heidenhain): R_RI runtime-info parity with pyLSV2 — state names, refusal codes, timer/unit docs

Compared every R_RI decoder we use (22-28, 30-34, 51) byte-for-byte with pyLSV2 (MIT,
97a3d691) and ran the captured iTNC 530 replies through pyLSV2 itself: no decode
disagreement, so no published value changes.

- Additive read-only String leaves Status/ExecutionStateName, Status/ProgramStateName and
  Tool/AxisName (pyLSV2 ExecState / PgmState / decode_tool_info names; out-of-table code =
  "Unknown"). They ride the raw leaf's feature, so no extra wire read; raw Int32 leaves unchanged.
- Refusals: 52/72 -> BadOutOfRange, 58 -> BadTimeout, 56 -> BadInvalidState, 57 (outside the
  alarm walk) -> BadNoData. IsStable and the 57-only alarm-walk end are unchanged.
  Lsv2RefusalCodes names every code (pyLSV2 LSV2StatusCode) in exception/log/probe text.
- Timers 30-34 documented as uint32be seconds (lifetime counters) per pyLSV2, hardware-unproven;
  TimersEnabled stays false. Axis/tool units: none on the wire, none assumed.
- Lsv2ReadOnlyPolicy untouched (pinned by a test); R_RI 20/21 left out.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-12 23:55:34 -05:00
parent d9b2381235
commit 4cee05c2f8
12 changed files with 603 additions and 21 deletions
+3
View File
@@ -353,6 +353,9 @@ 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`.
`R_RI` decoders are at parity with pyLSV2 (`HeidenhainPyLsv2ParityTests`); enum NAMES are additive String leaves
(`Status/*StateName`, `Tool/AxisName`) — never change the raw Int32 leaves (OEE consumers key on them). Refusals
52/72/58/56/57 map to specific statuses but only 22/24/23/49 are `IsStable`; only 57 ends the alarm walk.
- **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/*`
+43 -6
View File
@@ -121,13 +121,13 @@ SSH subsystem.
| Folder | Leaves (type) | Source | Group |
|---|---|---|---|
| `Controller` | `Model`, `NcVersion`, `PlcVersion`, `Options` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved) | Slow |
| `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums | `R_RI 23`, `26` | Status |
| `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, controller units | `R_RI 22` | Status |
| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `Length`, `Radius` (Float64) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0) | Slow |
| `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) | `R_RI 30–34`, **disabled by default** | 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
@@ -156,6 +156,33 @@ truncates or wraps — otherwise `BadTypeMismatch`.
At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`).
## 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
@@ -259,8 +286,12 @@ class of the alarm it last showed (and a rebuild carries it). The condition mess
| Condition | Value status / capability |
|---|---|
| Refusal 22/24 | `BadNotSupported` / `Unsupported` — suppressed for the session |
| 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` |
@@ -335,8 +366,14 @@ No real control was available for this port. Still open:
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).
- Physical units of axis positions / tool geometry vs. the HMI; experimental timer semantics (selectors 30–34).
- 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
@@ -59,7 +59,12 @@ public static class HeidenhainCatalog
/// <summary>Feature id reported for the opt-in DATA login.</summary>
public const string DataLoginFeature = "DataLogin";
/// <summary>Experimental timer leaf names, in selector order (30..34).</summary>
/// <summary>
/// Experimental timer leaf names, in selector order (30..34). Per pyLSV2 (MIT) <c>const.py:ParRRI</c> and
/// <c>docs/protocol.rst</c> each is a uint32be lifetime counter in SECONDS (NC uptime +1 s while the NC runs,
/// machine uptime, machine runtime that grows only while a program runs, spindle 0 / spindle 1 runtime — 0 when
/// a second spindle is unused); not the runtime of the current program. Hardware-unproven here.
/// </summary>
public static readonly IReadOnlyList<string> TimerNames =
["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"];
@@ -104,6 +109,9 @@ public static class HeidenhainCatalog
Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Model", "NcVersion", "PlcVersion", "Options");
Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "Execution", "ExecutionState");
Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramState");
// Additive names for the raw enums above (per pyLSV2; HeidenhainStateNames). Same feature = same wire read.
Add("Status", DriverDataType.String, false, HeidenhainPollGroup.Status, "Execution", "ExecutionStateName");
Add("Status", DriverDataType.String, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramStateName");
Add("Program", DriverDataType.String, false, HeidenhainPollGroup.Status, "Program", "Main", "Current");
Add("Program", DriverDataType.Int64, false, HeidenhainPollGroup.Status, "Program", "Line");
Add("Overrides", DriverDataType.Float64, false, HeidenhainPollGroup.Status, "Overrides", "Feed", "Spindle", "Rapid");
@@ -112,6 +120,7 @@ public static class HeidenhainCatalog
Add("Tool", DriverDataType.Int64, false, HeidenhainPollGroup.Slow, "Tool", "Number");
Add("Tool", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Tool", "Index", "AxisCode");
Add("Tool", DriverDataType.Float64, false, HeidenhainPollGroup.Slow, "Tool", "Length", "Radius");
Add("Tool", DriverDataType.String, false, HeidenhainPollGroup.Slow, "Tool", "AxisName");
Add("Alarms", DriverDataType.Int32, true, HeidenhainPollGroup.Slow, "Alarms", "Classes", "Groups", "Numbers");
Add("Alarms", DriverDataType.String, true, HeidenhainPollGroup.Slow, "Alarms", "Messages");
Add("Alarms", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Alarms", "Count");
@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// Names for the raw native enums the control reports through <c>R_RI 23</c> (execution mode), <c>R_RI 26</c>
/// (program state) and the spindle-axis code of <c>R_RI 51</c>. They back the additive <c>…Name</c> String leaves
/// only — the raw Int32 leaves (<c>Status/ExecutionState</c>, <c>Status/ProgramState</c>, <c>Tool/AxisCode</c>) are
/// unchanged and stay the value OEE consumers key on.
/// </summary>
/// <remarks>
/// <para><b>Provenance.</b> The tables are the enums the open-source pyLSV2 library defines — per pyLSV2 (MIT)
/// <c>const.py:ExecState</c>, <c>const.py:PgmState</c> and <c>misc.py:decode_tool_info</c> (commit
/// <c>97a3d691</c>), spelled in this catalog's PascalCase. pyLSV2's own <c>PgmState</c> table comes from a public
/// forum thread, not a HEIDENHAIN document. The four iTNC 530 controls of the 2026-11-11 commissioning run
/// reported values consistent with it (a running program read execution 4 = Automatic with program state
/// 0 = Started and an advancing line; an idle control read 1 = Mdi / 4 = Automatic with 7 = Idle), which
/// corroborates — but does not prove — the tables.</para>
/// <para>A code outside a table publishes <see cref="Unknown"/>, never an invented name (pyLSV2 itself raises on an
/// out-of-table value). The raw leaf still carries the code.</para>
/// </remarks>
public static class HeidenhainStateNames
{
/// <summary>The name published for a code no table defines.</summary>
public const string Unknown = "Unknown";
/// <summary><c>R_RI 23</c> execution mode (per pyLSV2 <c>ExecState</c>).</summary>
public static IReadOnlyList<string> ExecutionStates { get; } =
["Manual", "Mdi", "PassReferences", "SingleStep", "Automatic", "Undefined"];
/// <summary><c>R_RI 26</c> state of the selected program (per pyLSV2 <c>PgmState</c>).</summary>
public static IReadOnlyList<string> ProgramStates { get; } =
["Started", "Stopped", "Finished", "Cancelled", "Interrupted", "Error", "ErrorCleared", "Idle", "Undefined"];
/// <summary><c>R_RI 51</c> tool / spindle axis code (per pyLSV2 <c>decode_tool_info</c>: 0 X, 1 Y, 2 Z).</summary>
public static IReadOnlyList<string> ToolAxes { get; } = ["X", "Y", "Z"];
/// <summary>The name of an execution-mode code.</summary>
/// <param name="code">The raw <c>R_RI 23</c> value.</param>
/// <returns>The name, or <see cref="Unknown"/>.</returns>
public static string ExecutionState(int code) => Lookup(ExecutionStates, code);
/// <summary>The name of a program-state code.</summary>
/// <param name="code">The raw <c>R_RI 26</c> value.</param>
/// <returns>The name, or <see cref="Unknown"/>.</returns>
public static string ProgramState(int code) => Lookup(ProgramStates, code);
/// <summary>The name of a tool-axis code.</summary>
/// <param name="code">The raw axis code of <c>R_RI 51</c>.</param>
/// <returns>The axis letter, or <see cref="Unknown"/>.</returns>
public static string ToolAxis(int code) => Lookup(ToolAxes, code);
private static string Lookup(IReadOnlyList<string> table, int code) =>
code >= 0 && code < table.Count ? table[code] : Unknown;
}
@@ -256,7 +256,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
// Connect / INSPECT / identity failure. Never log payloads or credentials: type + refusal code only.
var reason = ex switch
{
Lsv2RefusalException r => $"INSPECT/identity refused (code {r.Code})",
Lsv2RefusalException r => $"INSPECT/identity refused (code {r.Code} {Lsv2RefusalCodes.Name(r.Code)})",
Lsv2DecodeException => "Malformed identity record",
_ => ex.Message,
};
@@ -81,7 +81,7 @@ public sealed class HeidenhainDriverProbe : IDriverProbe
}
catch (Lsv2RefusalException ex)
{
return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code}).", null);
return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code} {Lsv2RefusalCodes.Name(ex.Code)}).", null);
}
catch (Lsv2DecodeException)
{
@@ -455,6 +455,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
"Number" => (object)t.Number,
"Index" => (object)t.Index,
"AxisCode" => (object)t.Axis,
"AxisName" => HeidenhainStateNames.ToolAxis(t.Axis),
"Length" => (object?)t.Length,
_ => (object?)t.Radius,
};
@@ -468,7 +469,13 @@ internal sealed class HeidenhainSession : IAsyncDisposable
_ => (object)list.Select(x => x.Number).ToArray(),
};
case int i:
return i;
// The …Name leaves share the raw leaf's one wire read; the raw Int32 leaf is published unchanged.
return name switch
{
"ExecutionStateName" => HeidenhainStateNames.ExecutionState(i),
"ProgramStateName" => HeidenhainStateNames.ProgramState(i),
_ => (object)i,
};
case long l:
return l;
default:
@@ -41,13 +41,33 @@ public static class HeidenhainStatus
/// <summary>Authored but not yet acquired.</summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>Maps a controller refusal code to a value status (22/24 unsupported, 23/49 denied, else unexpected).</summary>
/// <summary>Refusal 58 (<c>T_ER_ACCESS_TIMEOUT</c>): the control timed out reaching the data (retried).</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>Refusal 56 (<c>T_ER_OPERATING_MODE</c>): not allowed in the control's current mode (retried).</summary>
public const uint BadInvalidState = 0x80AF0000u;
/// <summary>Refusal 57 (<c>T_ER_NO_NEXT_ERROR</c>) outside the alarm walk, which consumes it as "end of list".</summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>
/// Maps a controller refusal code to a value status. Code names per pyLSV2 (MIT)
/// <c>const.py:LSV2StatusCode</c> (<see cref="Lsv2.Lsv2RefusalCodes"/>): 22 UNKNOWN_TELE / 24 WRONG_PARA ⇒
/// unsupported; 23 NO_PRIV / 49 NO_LOGIN ⇒ access denied; 52 BAD_MEMADR / 72 OUT_OF_RANGE ⇒ out of range;
/// 58 ACCESS_TIMEOUT ⇒ timeout; 56 OPERATING_MODE ⇒ invalid state; 57 NO_NEXT_ERROR ⇒ no data; anything else
/// unexpected. Only the first two are <see cref="IsStable"/> (suppressed for the session) — every other code is
/// retried on the next poll, exactly as before the specific mapping.
/// </summary>
/// <param name="code">The refusal code.</param>
/// <returns>The status.</returns>
public static uint FromRefusal(int code) => code switch
{
22 or 24 => BadNotSupported,
23 or 49 => BadUserAccessDenied,
Lsv2.Lsv2RefusalCodes.UnknownTelegram or Lsv2.Lsv2RefusalCodes.WrongParameter => BadNotSupported,
Lsv2.Lsv2RefusalCodes.NoPrivilege or Lsv2.Lsv2RefusalCodes.NoLogin => BadUserAccessDenied,
Lsv2.Lsv2RefusalCodes.BadMemoryAddress or Lsv2.Lsv2RefusalCodes.OutOfRange => BadOutOfRange,
Lsv2.Lsv2RefusalCodes.AccessTimeout => BadTimeout,
Lsv2.Lsv2RefusalCodes.OperatingMode => BadInvalidState,
Lsv2.Lsv2RefusalCodes.NoNextError => BadNoData,
_ => BadUnexpectedError,
};
@@ -11,7 +11,7 @@ public sealed class Lsv2DecodeException(string message) : Exception(message);
/// framing synchronised, so the session stays usable.
/// </summary>
public sealed class Lsv2RefusalException(int type, int code)
: Exception($"LSV2 controller refusal type={type} code={code}")
: Exception($"LSV2 controller refusal type={type} code={code} ({Lsv2RefusalCodes.Name(code)})")
{
/// <summary>The refusal type byte.</summary>
public int Type { get; } = type;
@@ -46,21 +46,30 @@ public static class Lsv2Data
return new Lsv2Versions(parts[0], parts[1], parts[2], parts[^1]);
}
/// <summary>Exactly two bytes, uint16be.</summary>
/// <summary>
/// Exactly two bytes, uint16be — <c>R_RI 23</c> execution mode / <c>R_RI 26</c> program state, per pyLSV2 (MIT)
/// <c>client.py:execution_state</c> / <c>program_status</c> (<c>struct "!H"</c>).
/// </summary>
public static int U16(ReadOnlySpan<byte> bytes)
{
Lsv2Codec.Length(bytes, 2);
return BinaryPrimitives.ReadUInt16BigEndian(bytes);
}
/// <summary>Exactly four bytes, uint32be.</summary>
/// <summary>
/// Exactly four bytes, uint32be — the <c>R_RI 30–34</c> lifetime timers, in SECONDS per pyLSV2 (MIT)
/// <c>const.py:ParRRI</c> + <c>docs/protocol.rst</c> (hardware-unproven here). Published raw, never rescaled.
/// </summary>
public static long U32(ReadOnlySpan<byte> bytes)
{
Lsv2Codec.Length(bytes, 4);
return BinaryPrimitives.ReadUInt32BigEndian(bytes);
}
/// <summary>uint32be line, main NUL, current NUL, optional NUL padding only.</summary>
/// <summary>
/// uint32be line, main NUL, current NUL, optional NUL padding only — per pyLSV2 (MIT)
/// <c>misc.py:decode_stack_info</c>, but strict (pyLSV2 ignores what follows the second string).
/// </summary>
public static Lsv2Program Program(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 6) throw Bad();
@@ -69,7 +78,10 @@ public static class Lsv2Data
return new Lsv2Program(BinaryPrimitives.ReadUInt32BigEndian(bytes), parts[0], parts[1]);
}
/// <summary>Three uint32be hundredths of percent: feed, spindle, rapid.</summary>
/// <summary>
/// Three uint32be hundredths of percent, in the order feed, spindle, rapid — per pyLSV2 (MIT)
/// <c>misc.py:decode_override_state</c> (<c>/ 100</c>).
/// </summary>
public static double[] Overrides(ReadOnlySpan<byte> bytes)
{
Lsv2Codec.Length(bytes, 12);
@@ -81,7 +93,12 @@ public static class Lsv2Data
];
}
/// <summary>8-byte identity-only or 24-byte reply with two float64le geometry values.</summary>
/// <summary>
/// 8-byte identity-only or 24-byte reply: uint32be number, uint16be index, uint16be axis code (0 X, 1 Y, 2 Z),
/// then float64 LITTLE-endian length and radius — per pyLSV2 (MIT) <c>misc.py:decode_tool_info</c> (<c>"&lt;d"</c>;
/// the mixed endianness is real: a live iTNC 530 read L 138.1506 / R −0.0101 through this decoder, 2026-11-11).
/// No unit is on the wire and pyLSV2 assumes none either; none is inferred here.
/// </summary>
public static Lsv2Tool Tool(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != 8 && bytes.Length != 24) throw Bad();
@@ -98,7 +115,11 @@ public static class Lsv2Data
return new Lsv2Tool(number, index, axis, length, radius);
}
/// <summary>uint16be class, uint16be group, int32be number, terminated text.</summary>
/// <summary>
/// uint16be class, uint16be group, int32be (signed) number, terminated text — per pyLSV2 (MIT)
/// <c>misc.py:decode_error_message</c>. Stricter on the text: it must be NUL-terminated with NUL padding only,
/// and trailing blanks are kept (pyLSV2 <c>ba_to_ustr</c> strips them; the text is part of a condition's identity).
/// </summary>
public static Lsv2Alarm Alarm(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 9) throw Bad();
@@ -109,7 +130,12 @@ public static class Lsv2Data
Lsv2Codec.Text(bytes[8..]));
}
/// <summary>Opaque byte, uint8 count, count numeric strings, count unique non-blank names.</summary>
/// <summary>
/// Opaque byte, uint8 count, count numeric strings, count unique non-blank names — per pyLSV2 (MIT)
/// <c>misc.py:decode_axis_location</c> (values first, then names; byte 0 undocumented there too). Positions are
/// the control's display strings parsed as-is: no unit is on the wire and pyLSV2 assumes none (linear vs rotary
/// axes and mm vs inch are neither reported nor inferred here).
/// </summary>
public static Lsv2Axes Axes(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 2) throw Bad();
@@ -0,0 +1,123 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
/// <summary>
/// The controller refusal codes carried by a <c>T_ER</c>/<c>T_BD</c> frame, with their protocol names — for logs
/// and diagnostics only (behaviour is <see cref="HeidenhainStatus.FromRefusal"/>). Per pyLSV2 (MIT)
/// <c>const.py:LSV2StatusCode</c> (telegram errors 20–99, block-transfer errors 100–200, 255); the numbers are the
/// protocol's, the names are that table's.
/// </summary>
public static class Lsv2RefusalCodes
{
/// <summary>Unknown telegram — the control does not implement the request / selector.</summary>
public const int UnknownTelegram = 22;
/// <summary>No privilege for the request under the current logins.</summary>
public const int NoPrivilege = 23;
/// <summary>Wrong parameter — e.g. an <c>R_RI</c> selector this control does not serve.</summary>
public const int WrongParameter = 24;
/// <summary>The required login has not been made.</summary>
public const int NoLogin = 49;
/// <summary>Bad PLC memory address.</summary>
public const int BadMemoryAddress = 52;
/// <summary>The control is in an operating mode that does not allow the request.</summary>
public const int OperatingMode = 56;
/// <summary>No (next) error — the only proof that the <c>R_RI 27/28</c> alarm walk reached its end.</summary>
public const int NoNextError = 57;
/// <summary>The control timed out accessing the requested data.</summary>
public const int AccessTimeout = 58;
/// <summary>A value out of range.</summary>
public const int OutOfRange = 72;
private static readonly IReadOnlyDictionary<int, string> Names = new Dictionary<int, string>
{
[20] = "T_ER_BAD_FORMAT",
[21] = "T_ER_UNEXPECTED_TELE",
[22] = "T_ER_UNKNOWN_TELE",
[23] = "T_ER_NO_PRIV",
[24] = "T_ER_WRONG_PARA",
[25] = "T_ER_BREAK",
[30] = "T_ER_BAD_KEY",
[31] = "T_ER_BAD_FNAME",
[32] = "T_ER_NO_FILE",
[33] = "T_ER_OPEN_FILE",
[34] = "T_ER_FILE_EXISTS",
[35] = "T_ER_BAD_FILE",
[36] = "T_ER_NO_DELETE",
[37] = "T_ER_NO_NEW_FILE",
[38] = "T_ER_NO_CHANGE_ATT",
[39] = "T_ER_BAD_EMULATEKEY",
[40] = "T_ER_NO_MP",
[41] = "T_ER_NO_WIN",
[42] = "T_ER_WIN_NOT_AKTIV",
[43] = "T_ER_ANZ",
[44] = "T_ER_FONT_NOT_DEFINED",
[45] = "T_ER_FILE_ACCESS",
[46] = "T_ER_WRONG_DNC_STATUS",
[47] = "T_ER_CHANGE_PATH",
[48] = "T_ER_NO_RENAME",
[49] = "T_ER_NO_LOGIN",
[50] = "T_ER_BAD_PARAMETER",
[51] = "T_ER_BAD_NUMBER_FORMAT",
[52] = "T_ER_BAD_MEMADR",
[53] = "T_ER_NO_FREE_SPACE",
[54] = "T_ER_DEL_DIR",
[55] = "T_ER_NO_DIR",
[56] = "T_ER_OPERATING_MODE",
[57] = "T_ER_NO_NEXT_ERROR",
[58] = "T_ER_ACCESS_TIMEOUT",
[59] = "T_ER_NO_WRITE_ACCESS",
[60] = "T_ER_STIB",
[61] = "T_ER_REF_NECESSARY",
[62] = "T_ER_PLC_BUF_FULL",
[63] = "T_ER_NOT_FOUND",
[64] = "T_ER_WRONG_FILE",
[65] = "T_ER_NO_MATCH",
[66] = "T_ER_TOO_MANY_TPTS",
[67] = "T_ER_NOT_ACTIVATED",
[70] = "T_ER_DSP_CHANNEL",
[71] = "T_ER_DSP_PARA",
[72] = "T_ER_OUT_OF_RANGE",
[73] = "T_ER_INVALID_AXIS",
[74] = "T_ER_STREAMING_ACTIVE",
[75] = "T_ER_NO_STREAMING_ACTIVE",
[80] = "T_ER_TO_MANY_OPEN_TCP",
[81] = "T_ER_NO_FREE_HANDLE",
[82] = "T_ER_PLCMEMREMA_CLEAR",
[83] = "T_ER_OSZI_CHSEL",
[90] = "LSV2_BUSY",
[91] = "LSV2_X_BUSY",
[92] = "LSV2_NOCONNECT",
[93] = "LSV2_BAD_BACKUP_FILE",
[94] = "LSV2_RESTORE_NOT_FOUND",
[95] = "LSV2_DLL_NOT_INSTALLED",
[96] = "LSV2_BAD_CONVERT_DLL",
[97] = "LSV2_BAD_BACKUP_LIST",
[99] = "LSV2_UNKNOWN_ERROR",
[100] = "T_BD_NO_NEW_FILE",
[101] = "T_BD_NO_FREE_SPACE",
[102] = "T_BD_FILE_NOT_ALLOWED",
[103] = "T_BD_BAD_FORMAT",
[104] = "T_BD_BAD_BLOCK",
[105] = "T_BD_END_PGM",
[106] = "T_BD_ANZ",
[107] = "T_BD_WIN_NOT_DEFINED",
[108] = "T_BD_WIN_CHANGED",
[110] = "T_BD_DNC_WAIT",
[111] = "T_BD_CANCELLED",
[112] = "T_BD_OSZI_OVERRUN",
[200] = "T_BD_FD",
[255] = "T_USER_ERROR",
};
/// <summary>The protocol name of a refusal code, or <c>UNKNOWN_&lt;code&gt;</c>.</summary>
/// <param name="code">The refusal code byte.</param>
/// <returns>The name.</returns>
public static string Name(int code) => Names.TryGetValue(code, out var n) ? n : "UNKNOWN_" + code;
}
@@ -0,0 +1,304 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
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>
/// Parity with the open-source pyLSV2 (MIT, commit <c>97a3d691</c>) for every <c>R_RI</c> selector the driver
/// reads. The golden expectations below are what pyLSV2's own decoders (<c>misc.py:decode_axis_location</c>,
/// <c>decode_stack_info</c>, <c>decode_error_message</c>, <c>decode_tool_info</c>, <c>const.py:ExecState</c> /
/// <c>PgmState</c> / <c>LSV2StatusCode</c>) return for the captured iTNC 530 replies — produced by running pyLSV2
/// over <c>Fixtures/itnc530-inspect.properties</c>, then pinned here.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainPyLsv2ParityTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static byte[] Payload(string machine, int selector) =>
SimReply.FromFixture(Fixtures.Load("itnc530-inspect.properties")[$"{machine}.{selector}"]).Payload!;
// ---- decoders vs pyLSV2 on the real captures -------------------------------------------------------------
[Theory]
[InlineData("hermle49", new[] { 0.08392, 10.4304, 25.72673, 0.0, 270.0, -90.0 }, "TNC:\\PARTPROG\\00588001302.i")]
[InlineData("hermle59", new[] { 0.01119, 10.51793, 25.31073, 0.0, 180.0, -90.0 }, "TNC:\\partprog\\00588001302.i")]
public void Captured_replies_decode_exactly_as_pyLsv2_decodes_them(string machine, double[] positions, string main)
{
var axes = Lsv2Data.Axes(Payload(machine, 22));
axes.Names.ShouldBe(["X", "Y", "Z", "C", "a", "A"]);
axes.Positions.ShouldBe(positions);
HeidenhainStateNames.ExecutionState(Lsv2Data.U16(Payload(machine, 23))).ShouldBe("Automatic"); // pyLSV2 AUTOMATIC
HeidenhainStateNames.ProgramState(Lsv2Data.U16(Payload(machine, 26))).ShouldBe("Idle"); // pyLSV2 IDLE
var program = Lsv2Data.Program(Payload(machine, 24));
program.Line.ShouldBe(0L);
program.Main.ShouldBe(main);
program.Current.ShouldBe("");
var alarm = Lsv2Data.Alarm(Payload(machine, 27));
(alarm.ErrorClass, alarm.Group, alarm.Number, alarm.Message).ShouldBe((7, 4, -1, "Run test of cutout channels"));
}
[Fact]
public void Captured_24_byte_tool_reply_decodes_as_pyLsv2_with_axis_Z()
{
var tool = Lsv2Data.Tool(Payload("hermle59", 51));
(tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((0L, 0, 2, 0.0, 0.0));
HeidenhainStateNames.ToolAxis(tool.Axis).ShouldBe("Z");
}
[Theory]
[InlineData("hermle49", 25, 23, "T_ER_NO_PRIV")]
[InlineData("hermle49", 28, 57, "T_ER_NO_NEXT_ERROR")]
[InlineData("hermle49", 51, 24, "T_ER_WRONG_PARA")]
[InlineData("hermle59", 25, 23, "T_ER_NO_PRIV")]
public void Captured_refusals_carry_the_pyLsv2_status_code_names(string machine, int selector, int code, string name)
{
var refusal = Payload(machine, selector);
refusal[1].ShouldBe((byte)code);
Lsv2RefusalCodes.Name(code).ShouldBe(name);
new Lsv2RefusalException(refusal[0], refusal[1]).Message.ShouldContain(name);
}
// ---- decoders vs pyLSV2 layouts on synthetic vectors -----------------------------------------------------
[Fact]
public void Overrides_are_feed_spindle_rapid_hundredths_like_pyLsv2()
{
var o = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(o, 10000);
BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(4), 15050);
BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(8), 1);
Lsv2Data.Overrides(o).ShouldBe([100.0, 150.5, 0.01]);
}
[Fact]
public void Tool_geometry_is_little_endian_after_big_endian_identity_like_pyLsv2()
{
var t = new byte[24];
BinaryPrimitives.WriteUInt32BigEndian(t, 49);
BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(4), 3);
BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(6), 1);
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 138.1506);
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), -0.0101);
var tool = Lsv2Data.Tool(t);
(tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((49L, 3, 1, 138.1506, -0.0101));
}
[Fact]
public void Alarm_number_is_signed_int32_like_pyLsv2()
{
// The live 2026-11-11 record 0x8100017B (HMI 702126) is negative as int32 — pyLSV2 unpacks "!l" too.
var a = new byte[8 + 2];
BinaryPrimitives.WriteUInt16BigEndian(a, 7);
BinaryPrimitives.WriteUInt16BigEndian(a.AsSpan(2), 3);
BinaryPrimitives.WriteUInt32BigEndian(a.AsSpan(4), 0x8100017Bu);
a[8] = (byte)'x';
Lsv2Data.Alarm(a).Number.ShouldBe(-2130706053);
}
[Fact]
public void Timers_are_u32be_seconds_published_raw()
{
// per pyLSV2 const.py:ParRRI: u32be, NC uptime +1/s. 0x0001_5180 = 86 400 s = one day; no rescaling.
Lsv2Data.U32([0x00, 0x01, 0x51, 0x80]).ShouldBe(86400L);
Lsv2Data.U32([0xFF, 0xFF, 0xFF, 0xFF]).ShouldBe(4294967295L);
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.U32([0, 0, 1]));
}
// ---- enum names ------------------------------------------------------------------------------------------
[Fact]
public void Name_tables_cover_every_pyLsv2_enum_member_in_order()
{
// per pyLSV2 const.py:ExecState 0..5 and PgmState 0..8 (UPPER_SNAKE there, PascalCase here).
HeidenhainStateNames.ExecutionStates.ShouldBe(["Manual", "Mdi", "PassReferences", "SingleStep", "Automatic", "Undefined"]);
HeidenhainStateNames.ProgramStates.ShouldBe(
["Started", "Stopped", "Finished", "Cancelled", "Interrupted", "Error", "ErrorCleared", "Idle", "Undefined"]);
HeidenhainStateNames.ToolAxes.ShouldBe(["X", "Y", "Z"]);
}
[Theory]
[InlineData(6)]
[InlineData(300)]
[InlineData(65535)]
[InlineData(-1)]
public void An_out_of_table_code_is_Unknown_never_an_invented_name(int code)
{
HeidenhainStateNames.ExecutionState(code).ShouldBe(HeidenhainStateNames.Unknown);
HeidenhainStateNames.ToolAxis(code).ShouldBe(HeidenhainStateNames.Unknown);
if (code != 6) HeidenhainStateNames.ProgramState(code).ShouldBe(HeidenhainStateNames.Unknown);
}
[Fact]
public void Name_leaves_are_additive_String_catalog_entries_on_the_raw_leaves_feature()
{
foreach (var (name, raw) in new[] { ("Status/ExecutionStateName", "Status/ExecutionState"), ("Status/ProgramStateName", "Status/ProgramState"), ("Tool/AxisName", "Tool/AxisCode") })
{
HeidenhainCatalog.TryGet(name, out var n).ShouldBeTrue(name);
HeidenhainCatalog.TryGet(raw, out var r).ShouldBeTrue(raw);
n!.DataType.ShouldBe(Core.Abstractions.DriverDataType.String);
n.IsArray.ShouldBeFalse();
n.Feature.ShouldBe(r!.Feature); // one wire read serves both
n.Group.ShouldBe(r.Group);
r.DataType.ShouldBe(Core.Abstractions.DriverDataType.Int32); // the raw leaf is unchanged
}
}
[Fact]
public async Task Name_leaves_share_the_raw_read_and_leave_the_raw_value_unchanged()
{
var c = new SimulatedControl();
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
c.Runtime[26] = () => SimReply.Ok("S_RI", [0, 0]);
c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 17, 0, 0, 0, 2]); // 8-byte identity-only reply
string[] addresses = ["Status/ExecutionState", "Status/ExecutionStateName", "Status/ProgramState", "Status/ProgramStateName",
"Tool/AxisCode", "Tool/AxisName", "Tool/Length"];
await using var sim = Lsv2Simulator.WithControl(c);
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
await client.OpenAsync(Ct);
await session.InitializeAsync(Ct);
var v = await session.ReadBatchAsync(addresses.Select(a => TestTags.Def(a)).ToList(), Ct);
v[0].Value.ShouldBeOfType<int>().ShouldBe(4);
v[1].Value.ShouldBe("Automatic");
v[2].Value.ShouldBeOfType<int>().ShouldBe(0);
v[3].Value.ShouldBe("Started");
v[4].Value.ShouldBeOfType<int>().ShouldBe(2);
v[5].Value.ShouldBe("Z");
v[5].Status.ShouldBe(HeidenhainStatus.Good); // the axis is in the identity part, served even without geometry
v[6].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 23).ShouldBe(1);
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 26).ShouldBe(1);
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 51).ShouldBe(1);
c.Violations.ShouldBeEmpty();
}
[Fact]
public async Task A_refused_raw_selector_makes_its_name_leaf_bad_with_the_same_status()
{
var c = new SimulatedControl();
c.Runtime[26] = () => SimReply.Refuse(23);
await using var sim = Lsv2Simulator.WithControl(c);
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
await client.OpenAsync(Ct);
await session.InitializeAsync(Ct);
var v = await session.ReadBatchAsync([TestTags.Def("Status/ProgramState"), TestTags.Def("Status/ProgramStateName")], Ct);
v.ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied && s.Value == null);
}
// ---- refusal codes ---------------------------------------------------------------------------------------
[Theory]
[InlineData(22, HeidenhainStatus.BadNotSupported, true)] // T_ER_UNKNOWN_TELE
[InlineData(24, HeidenhainStatus.BadNotSupported, true)] // T_ER_WRONG_PARA
[InlineData(23, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_PRIV
[InlineData(49, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_LOGIN
[InlineData(52, HeidenhainStatus.BadOutOfRange, false)] // T_ER_BAD_MEMADR
[InlineData(72, HeidenhainStatus.BadOutOfRange, false)] // T_ER_OUT_OF_RANGE
[InlineData(58, HeidenhainStatus.BadTimeout, false)] // T_ER_ACCESS_TIMEOUT
[InlineData(56, HeidenhainStatus.BadInvalidState, false)] // T_ER_OPERATING_MODE
[InlineData(57, HeidenhainStatus.BadNoData, false)] // T_ER_NO_NEXT_ERROR (outside the alarm walk)
[InlineData(25, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BREAK
[InlineData(20, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BAD_FORMAT
[InlineData(250, HeidenhainStatus.BadUnexpectedError, false)] // not in the table
public void Refusal_codes_map_to_the_most_specific_status_and_only_the_old_stable_set_is_stable(int code, uint status, bool stable)
{
HeidenhainStatus.FromRefusal(code).ShouldBe(status);
HeidenhainStatus.IsStable(HeidenhainStatus.FromRefusal(code)).ShouldBe(stable);
}
[Fact]
public void New_status_constants_are_the_canonical_opc_ua_numerics()
{
HeidenhainStatus.BadTimeout.ShouldBe(0x800A0000u);
HeidenhainStatus.BadInvalidState.ShouldBe(0x80AF0000u);
HeidenhainStatus.BadNoData.ShouldBe(0x809B0000u);
}
[Theory]
[InlineData(23, "T_ER_NO_PRIV")]
[InlineData(24, "T_ER_WRONG_PARA")]
[InlineData(52, "T_ER_BAD_MEMADR")]
[InlineData(57, "T_ER_NO_NEXT_ERROR")]
[InlineData(58, "T_ER_ACCESS_TIMEOUT")]
[InlineData(111, "T_BD_CANCELLED")]
[InlineData(1, "UNKNOWN_1")]
public void Refusal_names_follow_the_pyLsv2_status_code_table(int code, string name) => Lsv2RefusalCodes.Name(code).ShouldBe(name);
[Fact]
public async Task An_access_timeout_refusal_is_BadTimeout_and_retried_not_suppressed()
{
var calls = 0;
var c = new SimulatedControl();
c.Runtime[23] = () => Interlocked.Increment(ref calls) == 1 ? SimReply.Refuse(58) : SimReply.Ok("S_RI", [0, 4]);
await using var sim = Lsv2Simulator.WithControl(c);
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
await client.OpenAsync(Ct);
await session.InitializeAsync(Ct);
var tag = TestTags.Def("Status/ExecutionState");
(await session.ReadBatchAsync([tag], Ct))[0].Status.ShouldBe(HeidenhainStatus.BadTimeout);
var second = (await session.ReadBatchAsync([tag], Ct))[0];
second.Status.ShouldBe(HeidenhainStatus.Good);
second.Value.ShouldBe(4);
calls.ShouldBe(2);
session.CommunicationLost.ShouldBeFalse(); // a refusal is never a transport failure
}
[Fact]
public async Task Only_57_ends_the_alarm_walk_other_refusals_fail_it()
{
var c = new SimulatedControl();
var record = new byte[10];
BinaryPrimitives.WriteUInt16BigEndian(record, 1);
record[8] = (byte)'a';
c.Runtime[27] = () => SimReply.Ok("S_RI", record);
c.Runtime[28] = () => SimReply.Refuse(58);
await using var sim = Lsv2Simulator.WithControl(c);
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
await client.OpenAsync(Ct);
await session.InitializeAsync(Ct);
var count = (await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0];
count.Status.ShouldBe(HeidenhainStatus.BadTimeout); // never a truncated list of 1
c.Runtime[28] = () => SimReply.Refuse(57);
(await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0].Value.ShouldBe(1);
}
[Fact]
public void The_read_only_allowlist_is_unchanged()
{
// This work adds derived leaves only: no new selector (pyLSV2's 20/21 axis names stay out), request type or role.
Lsv2ReadOnlyPolicy.Selectors.OrderBy(s => s).ShouldBe([22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51]);
Lsv2ReadOnlyPolicy.Roles.OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["DATA", "DNC", "INSPECT"]);
Lsv2ReadOnlyPolicy.Commands.OrderBy(c => c, StringComparer.Ordinal).ShouldBe(["A_LG", "R_DP", "R_MB", "R_PR", "R_RI", "R_VR"]);
foreach (var s in new[] { 20, 21 })
{
var payload = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(payload, (ushort)s);
Should.Throw<Exception>(() => Lsv2ReadOnlyPolicy.Check("R_RI", payload), $"selector {s}");
}
Should.Throw<Exception>(() => Lsv2ReadOnlyPolicy.Check("A_LG", Encoding.Latin1.GetBytes("FILE\0\0")));
}
}