Merge branch 'heidenhain-pylsv2-parity'
v2-ci / unit-tests (push) Waiting to run

This commit is contained in:
Joseph Doherty
2026-11-13 00:32:52 -05:00
22 changed files with 1488 additions and 39 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.
@@ -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"];
@@ -102,8 +107,13 @@ public static class HeidenhainCatalog
}
Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Model", "NcVersion", "PlcVersion", "Options");
// Derived locally from the same R_VR reply (HeidenhainControlFamilies.Classify) — no extra wire request.
Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Family");
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 +122,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,77 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// The HEIDENHAIN control generation, classified from the <c>R_VR</c> model field. The published value of the
/// <c>Controller/Family</c> leaf is the member name. Only <see cref="ITnc530"/> is hardware-proven; every other
/// member follows pyLSV2 (MIT) and is hardware-unproven here (<c>docs/drivers/Heidenhain.md</c> §Control families).
/// </summary>
public enum HeidenhainControlFamily
{
/// <summary>A model string no rule recognises. Treated like a non-iTNC: no iTNC-only feature is attempted.</summary>
Unknown,
/// <summary>iTNC 530 (pyLSV2 <c>ControlType.MILL_OLD</c>) — the only family proven on hardware.</summary>
ITnc530,
/// <summary>TNC 640 / 620 / 320 / 128 (pyLSV2 <c>ControlType.MILL_NEW</c>).</summary>
Tnc,
/// <summary>TNC7 (pyLSV2 <c>ControlType.TNC7</c>: "behaves just a bit different again").</summary>
Tnc7,
/// <summary>CNC PILOT 640 / MANUALplus 620 lathe controls (pyLSV2 <c>ControlType.LATHE_NEW</c>).</summary>
Lathe,
/// <summary>MillPlus (IT V600) — pyLSV2 <c>ControlType.MILLPLUS</c>, "not officially supported by any other Heidenhain tool".</summary>
MillPlus,
/// <summary>
/// Legacy TNC 426 / 430. NOT a pyLSV2 class (pyLSV2 reports these as <c>UNKNOWN</c>); named here only so the
/// leaf is informative. It carries no gate of its own beyond "not an iTNC".
/// </summary>
LegacyTnc,
}
/// <summary>The single authority that classifies a control and states which reference-gated features it has.</summary>
public static class HeidenhainControlFamilies
{
// per pyLSV2 (MIT) dat_cls.py:VersionInfo.control setter — strip every space, upper-case, first matching
// prefix wins. ITNC530 is tested before any TNC prefix; TNC7 cannot collide with TNC640/620/320/128.
private static readonly (string Prefix, HeidenhainControlFamily Family)[] Rules =
[
("ITNC530", HeidenhainControlFamily.ITnc530),
("TNC640", HeidenhainControlFamily.Tnc),
("TNC620", HeidenhainControlFamily.Tnc),
("TNC320", HeidenhainControlFamily.Tnc),
("TNC128", HeidenhainControlFamily.Tnc),
("MANUALPLUS", HeidenhainControlFamily.Lathe),
("CNCPILOT640", HeidenhainControlFamily.Lathe),
("TNC7", HeidenhainControlFamily.Tnc7),
("MILLPLUS", HeidenhainControlFamily.MillPlus),
// Not from pyLSV2 (it has no rule for these): legacy pre-iTNC milling controls.
("TNC426", HeidenhainControlFamily.LegacyTnc),
("TNC430", HeidenhainControlFamily.LegacyTnc),
];
/// <summary>Classifies the <c>R_VR</c> model field (e.g. <c>iTNC530</c>, <c>TNC 640</c>, <c>CNCpilot640</c>).</summary>
/// <param name="model">The model string as the control reported it; null or blank is <see cref="HeidenhainControlFamily.Unknown"/>.</param>
/// <returns>The family.</returns>
public static HeidenhainControlFamily Classify(string? model)
{
if (string.IsNullOrWhiteSpace(model)) return HeidenhainControlFamily.Unknown;
var normalized = model.Replace(" ", "", StringComparison.Ordinal).Trim().ToUpperInvariant();
foreach (var (prefix, family) in Rules)
if (normalized.StartsWith(prefix, StringComparison.Ordinal)) return family;
return HeidenhainControlFamily.Unknown;
}
/// <summary>
/// True when <c>R_DP</c> data-path reads (and therefore the DATA login) may be attempted. Per pyLSV2 (MIT)
/// client.py:read_data_path — "does not work on non iTNC controls" — this is the iTNC 530 alone; an
/// <see cref="HeidenhainControlFamily.Unknown"/> control is refused too, so no unrecognised control ever
/// receives a DATA login.
/// </summary>
/// <param name="family">The family.</param>
/// <returns>Whether DATA is supported.</returns>
public static bool SupportsDataPath(HeidenhainControlFamily family) => family == HeidenhainControlFamily.ITnc530;
}
@@ -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;
}
@@ -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."),
};
@@ -151,6 +151,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
/// <summary>The identity of the current session's control, when connected.</summary>
public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
/// <summary>The control family classified from the current session's identity, or null.</summary>
public HeidenhainControlFamily? ControllerFamily { get { lock (_publication) return _session?.Identity?.Family; } }
/// <summary>Starts the acquisition loop (idempotent).</summary>
public void Start()
{
@@ -256,7 +259,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,
};
@@ -417,6 +417,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
bag[$"{d.HostName}.state"] = d.State.ToString();
bag[$"{d.HostName}.data"] = d.DataHealth;
if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
if (d.ControllerFamily is { } f) bag[$"{d.HostName}.family"] = f.ToString();
}
// lmxopcua#839: controller alarms no ALARM: selector claims (shown by no condition) — current + since start.
@@ -73,7 +73,7 @@ public sealed class HeidenhainDriverProbe : IDriverProbe
await client.LoginAsync("INSPECT", password, cts.Token).ConfigureAwait(false);
var versions = Lsv2Data.Versions(await client.ReadVersionAsync(cts.Token).ConfigureAwait(false));
sw.Stop();
return new(true, $"LSV2 INSPECT session OK at {target}: {versions.Model} NC {versions.Nc}", sw.Elapsed);
return new(true, $"LSV2 INSPECT session OK at {target}: {versions.Model} ({versions.Family}) NC {versions.Nc}", sw.Elapsed);
}
catch (OperationCanceledException)
{
@@ -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)
{
@@ -78,8 +78,9 @@ internal sealed class HeidenhainSession : IAsyncDisposable
if (_settings.TelemetryEnabled && _settings.DncLoginEnabled) _dncStatus = await OptionalLoginAsync("DNC", ct).ConfigureAwait(false);
if (_settings.DataEnabled && _settings.HasDataBindings)
{
// Reference-derived DATA support is iTNC only.
_dataStatus = _identity.Model.Contains("itnc", StringComparison.OrdinalIgnoreCase)
// DATA (R_DP) is iTNC 530 only — per pyLSV2 (MIT) client.py:read_data_path. Any other family, and an
// unrecognised one, is Unsupported WITHOUT sending the DATA login.
_dataStatus = HeidenhainControlFamilies.SupportsDataPath(_identity.Family)
? await OptionalLoginAsync("DATA", ct).ConfigureAwait(false)
: HeidenhainStatus.BadNotSupported;
}
@@ -439,7 +440,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
switch (value)
{
case Lsv2Versions v:
return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, _ => v.Options };
return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, "Family" => v.Family.ToString(), _ => v.Options };
case Lsv2Program p:
return name switch { "Main" => p.Main, "Current" => p.Current, _ => (object)p.Line };
case double[] o when address.StartsWith("Overrides/", StringComparison.Ordinal):
@@ -455,6 +456,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 +470,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;
@@ -5,7 +5,11 @@ using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
/// <summary>Controller identity from <c>R_VR</c>.</summary>
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options);
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options)
{
/// <summary>The control family derived from <see cref="Model"/> (computed, no backing field: equality and the #831 identity-change signal are unchanged).</summary>
public HeidenhainControlFamily Family => HeidenhainControlFamilies.Classify(Model);
}
/// <summary>Program stack from <c>R_RI 24</c>.</summary>
public sealed record Lsv2Program(long Line, string Main, string Current);
@@ -46,21 +50,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 +82,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 +97,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 +119,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 +134,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();
@@ -136,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();
@@ -149,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);
}
@@ -157,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();
@@ -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;
}