Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/ControlProfiles.cs
T
Joseph Doherty ba9015fac8 feat(heidenhain): classify the control family from R_VR; DATA gated to iTNC 530 (per pyLSV2)
- HeidenhainControlFamily + HeidenhainControlFamilies.Classify (Contracts): one authority, ported from
  pyLSV2 (MIT) dat_cls.py VersionInfo.control (strip spaces, case-insensitive prefix, ITNC530 first).
  Families: ITnc530, Tnc (640/620/320/128), Tnc7, Lathe (CNC PILOT 640 / MANUALplus 620), MillPlus,
  LegacyTnc (426/430, not a pyLSV2 class), Unknown.
- New additive read-only leaf Controller/Family (String, Identity feature, derived from the same R_VR
  reply: no extra request). Lsv2Versions.Family is computed, so record equality and the #831
  identity-change rediscovery signal are unchanged. Probe message + /hosts diagnostics (<host>.family).
- DATA gate now goes through SupportsDataPath(family) (pyLSV2 client.py read_data_path is iTNC-only):
  non-iTNC and Unknown => Capabilities/DataLogin Unsupported, DATA login never sent. Replaces the
  "model contains itnc" substring check (same result on the real iTNC530 controls).
- No per-family selector pre-gates: pyLSV2's TNC 640 observations (27/28/51/30-34 unknown telegram,
  25/26 need DNC) are already handled by the reply-driven refusal/suppression path; tests pin it.
- Lsv2ReadOnlyPolicy, HeidenhainTagWritable and the no-IWritable rule are untouched.
- Simulator: per-family ControlProfiles (strings from the pyLSV2 README tables / protocol.rst), both
  R_VR field counts, 124-byte R_PR; HeidenhainControlFamilyTests (classification, gates, decoding,
  wire log inside the read-only set, refused selectors asked once).
- docs/drivers/Heidenhain.md: Control families matrix (iTNC 530 hardware-proven; the rest per pyLSV2,
  hardware-unproven) + open questions (C_CC buffer size, encrypted passwords, R_RI 20/21).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-12 23:52:06 -05:00

160 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
/// <summary>How a simulated family answers the <c>R_RI</c> selectors the driver reads.</summary>
public enum SimSelectorBehaviour
{
/// <summary>Every read selector answers (the iTNC 530 shape, hardware-captured).</summary>
All,
/// <summary>
/// The TNC 640 observation (pyLSV2 docs/protocol.rst "Differences between different control versions",
/// NC 340590 10 SP2): 27/28/51 and 30–34 refuse <c>T_ER_UNKNOWN_TELE</c> (22); 25/26 need DNC; 24 serves the
/// program without DNC but with block number 0.
/// </summary>
TncObserved,
/// <summary>
/// Core telemetry only (22–26). pyLSV2's own suite asserts overrides/axes/exec state on every control but gates
/// tool (51) and error messages (27/28) to iTNC (tests/test_read_info.py), and its protocol table marks them
/// "no" on CNCpilot — so 27/28/51 and the iTNC lifetime timers 30–34 are refused 22 here. A conservative
/// assumption, not an observation, for the families pyLSV2 records nothing about.
/// </summary>
CoreOnly,
}
/// <summary>One simulated HEIDENHAIN control family.</summary>
/// <param name="Name">Test display name.</param>
/// <param name="Model">The R_VR model field.</param>
/// <param name="Nc">The R_VR NC software field.</param>
/// <param name="Family">The family the driver must classify it as.</param>
/// <param name="Selectors">Which R_RI selectors answer.</param>
/// <param name="FiveFieldVersion">True to send the 5-field R_VR (model, NC, PLC, SPLC, options).</param>
/// <param name="Source">Where the profile's strings come from.</param>
public sealed record ControlProfile(
string Name, string Model, string Nc, HeidenhainControlFamily Family, SimSelectorBehaviour Selectors,
bool FiveFieldVersion, string Source)
{
public override string ToString() => Name;
}
/// <summary>
/// Per-family simulator profiles. Model/NC strings come from the pyLSV2 (MIT) README "Programming Stations" and
/// "Machines" tables and dat_cls.py:VersionInfo; the iTNC 530 one from our own hardware record
/// (docs/drivers/evidence/heidenhain-live-2026-11-11.md). The R_VR field count follows pyLSV2 (MIT)
/// client.py:_read_version, which accepts exactly 4 or 5 NUL-separated fields on every family — which family
/// sends which shape is not recorded, so the 5-field variant is exercised on more than one family.
/// </summary>
public static class ControlProfiles
{
public static readonly ControlProfile ITnc530 = new("iTNC 530", "iTNC530", "340490 04 SP10",
HeidenhainControlFamily.ITnc530, SimSelectorBehaviour.All, false, "hardware: Hermle59 2026-11-11");
public static readonly ControlProfile ITnc530Spaced = new("iTNC 530 (spaced)", "iTNC 530", "606420 03 SP4",
HeidenhainControlFamily.ITnc530, SimSelectorBehaviour.All, true, "pyLSV2 dat_cls.py strips spaces before classifying");
public static readonly ControlProfile Tnc640 = new("TNC 640", "TNC640", "340590 10 SP2",
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 docs/protocol.rst + README");
public static readonly ControlProfile Tnc620 = new("TNC 620", "TNC620", "817605 04 SP1",
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, true, "pyLSV2 README Machines");
public static readonly ControlProfile Tnc320 = new("TNC 320", "TNC320", "340554 04 SP1",
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 README Programming Stations");
public static readonly ControlProfile Tnc128 = new("TNC 128", "TNC 128", "771841 01",
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 dat_cls.py prefix only (NC string illustrative)");
public static readonly ControlProfile Tnc7 = new("TNC7", "TNC7", "817625 17",
HeidenhainControlFamily.Tnc7, SimSelectorBehaviour.CoreOnly, true, "pyLSV2 README Programming Stations");
public static readonly ControlProfile CncPilot640 = new("CNC PILOT 640", "CNCpilot640", "1230521 03 SP1",
HeidenhainControlFamily.Lathe, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
public static readonly ControlProfile ManualPlus620 = new("MANUALplus 620", "MANUALplus620", "634130 02 SP7",
HeidenhainControlFamily.Lathe, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
public static readonly ControlProfile MillPlus = new("MillPlus IT V600", "MillPlusIT V600", "538956 03 SP15",
HeidenhainControlFamily.MillPlus, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
public static readonly ControlProfile Tnc430 = new("TNC 430 (legacy)", "TNC 430", "280476 21",
HeidenhainControlFamily.LegacyTnc, SimSelectorBehaviour.CoreOnly, false, "no pyLSV2 rule (it reports UNKNOWN); illustrative");
public static readonly ControlProfile Unrecognised = new("Unrecognised", "Anilam 6000i", "000000 00",
HeidenhainControlFamily.Unknown, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 ControlType.UNKNOWN");
public static IReadOnlyList<ControlProfile> All { get; } =
[ITnc530, ITnc530Spaced, Tnc640, Tnc620, Tnc320, Tnc128, Tnc7, CncPilot640, ManualPlus620, MillPlus, Tnc430, Unrecognised];
/// <summary>The R_VR reply for a profile.</summary>
public static byte[] VersionReply(ControlProfile p) => Encoding.Latin1.GetBytes(p.FiveFieldVersion
? $"{p.Model}\0{p.Nc}\0PLC1\0SPLC1\0%0000111100000111\0"
: $"{p.Model}\0{p.Nc}\0PLC1\0%0000111100000111\0");
/// <summary>A simulated control speaking the profile. <c>DataPaths["/x"]</c> is populated for every family, so a gate — not the simulator — is what keeps non-iTNC DATA off the wire.</summary>
public static SimulatedControl Build(ControlProfile p)
{
var dnc = 0;
var c = new SimulatedControl
{
Version = VersionReply(p),
Login = (role, _) =>
{
if (role == "DNC") Interlocked.Exchange(ref dnc, 1);
return SimReply.Ok("T_OK", []);
},
};
bool Dnc() => Volatile.Read(ref dnc) == 1;
c.Runtime[22] = () => SimReply.Ok("S_RI", [0, 2, .. Encoding.ASCII.GetBytes("1.5\0-2.25\0X\0Z\0")]);
c.Runtime[23] = () => SimulatedControl.U16(4);
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
switch (p.Selectors)
{
case SimSelectorBehaviour.TncObserved:
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(Dnc() ? 5 : 0));
c.Runtime[25] = () => Dnc() ? SimReply.Ok("S_RI", Overrides()) : SimReply.Refuse(23);
c.Runtime[26] = () => Dnc() ? SimulatedControl.U16(7) : SimReply.Refuse(23);
foreach (var s in new[] { 27, 28, 51, 30, 31, 32, 33, 34 }) c.Runtime[s] = () => SimReply.Refuse(22);
break;
case SimSelectorBehaviour.CoreOnly:
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(5));
c.Runtime[25] = () => SimReply.Ok("S_RI", Overrides());
c.Runtime[26] = () => SimulatedControl.U16(7);
foreach (var s in new[] { 27, 28, 51, 30, 31, 32, 33, 34 }) c.Runtime[s] = () => SimReply.Refuse(22);
break;
default:
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(5));
c.Runtime[25] = () => SimReply.Ok("S_RI", Overrides());
c.Runtime[26] = () => SimulatedControl.U16(7);
c.Runtime[27] = () => SimReply.Ok("S_RI", Alarm());
c.Runtime[28] = () => SimReply.Refuse(57);
c.Runtime[51] = () => SimReply.Ok("S_RI", new byte[24]);
for (var s = 30; s <= 34; s++) c.Runtime[s] = () => SimReply.Ok("S_RI", [0, 0, 1, 0]);
break;
}
return c;
}
private static byte[] Program(int line)
{
var b = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(b, (uint)line);
return [.. b, .. "TNC:\\nc_prog\\a.h\0TNC:\\nc_prog\\a.h\0"u8.ToArray()];
}
private static byte[] Overrides()
{
var b = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(b, 10000);
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(4), 9000);
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(8), 5000);
return b;
}
private static byte[] Alarm() => [0, 7, 0, 4, 0xff, 0xff, 0xff, 0xff, .. "Run test of cutout channels\0"u8.ToArray()];
}