Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.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

122 lines
5.3 KiB
C#

using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
public enum SimReplyKind { Frame, Drop, Stall }
/// <summary>What the simulated control does with one request.</summary>
public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]? Payload = null)
{
public static SimReply Ok(string command, byte[] payload) => new(SimReplyKind.Frame, command, payload);
public static SimReply Refuse(int code, int type = 1) => new(SimReplyKind.Frame, "T_ER", [(byte)type, (byte)code]);
public static SimReply Drop() => new(SimReplyKind.Drop);
public static SimReply Stall() => new(SimReplyKind.Stall);
/// <summary>Parses a reference fixture value <c>S_RI:0004</c> / <c>T_ER:0117</c>.</summary>
public static SimReply FromFixture(string value)
{
var parts = value.Split(':', 2);
return Ok(parts[0], Convert.FromHexString(parts[1]));
}
}
/// <summary>
/// A small, mutable model of an iTNC 530 (other families: <see cref="ControlProfiles"/>) speaking the six allowlisted LSV2 requests. Anything else is
/// recorded as a <see cref="Violations">violation</see> and refused — the driver must never produce one.
/// </summary>
public sealed class SimulatedControl
{
private readonly ConcurrentQueue<string> _violations = new();
public byte[] Version { get; set; } = Encoding.Latin1.GetBytes("iTNC 530\0NC 340490 04\0PLC\0OPT\0");
public byte[]? Layout { get; set; } = LayoutBytes(10);
public Func<string, string, SimReply?>? Login { get; set; }
public ConcurrentDictionary<int, Func<SimReply>> Runtime { get; } = new();
public ConcurrentDictionary<string, SimReply> DataPaths { get; } = new(StringComparer.Ordinal);
public Func<long, int, SimReply>? Memory { get; set; }
public List<(string Role, string Password)> Logins { get; } = [];
/// <summary>Requests the control refused as not-allowlisted (must stay empty).</summary>
public IReadOnlyList<string> Violations => _violations.ToArray();
/// <summary>Optional hook to override any reply (fault injection).</summary>
public Func<Lsv2Request, SimReply?>? Intercept { get; set; }
public SimReply Handle(Lsv2Request req)
{
if (Intercept?.Invoke(req) is { } forced) return forced;
switch (req.Command)
{
case "A_LG":
{
var fields = req.Text.Split('\0');
var role = fields[0];
var password = fields.Length > 2 ? fields[1] : "";
lock (Logins) Logins.Add((role, password));
return Login?.Invoke(role, password) ?? SimReply.Ok("T_OK", []);
}
case "R_VR" when req.Payload.Length == 0:
return SimReply.Ok("S_VR", Version);
case "R_PR" when req.Payload.Length == 0:
return Layout is null ? SimReply.Refuse(24) : SimReply.Ok("S_PR", Layout);
case "R_RI" when req.Payload.Length == 2:
return Runtime.TryGetValue(req.Selector, out var f) ? f() : SimReply.Refuse(24);
case "R_MB" when req.Payload.Length == 5:
var address = BinaryPrimitives.ReadUInt32BigEndian(req.Payload);
return Memory?.Invoke(address, req.Payload[4]) ?? SimReply.Refuse(23);
case "R_DP" when req.Payload.Length > 5:
{
var path = Encoding.Latin1.GetString(req.Payload, 4, req.Payload.Length - 5).Replace('\\', '/');
return DataPaths.TryGetValue(path, out var r) ? r : SimReply.Refuse(24);
}
default:
_violations.Enqueue(req.Command);
return SimReply.Refuse(22);
}
}
/// <summary>The reference test layout: words at 1000 with the given byte count, strings at 2000 (width 16), IW 3000, OW 4000.</summary>
public static byte[] LayoutBytes(int wordBytes)
{
var b = new byte[120];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(40), 1000);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(44), wordBytes);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(48), 2000);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(52), 10);
b[56] = 16;
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(64), 3000);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(68), 10);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(72), 4000);
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(76), 10);
return b;
}
/// <summary>Two-byte uint16be runtime reply.</summary>
public static SimReply U16(int v)
{
var p = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(p, (ushort)v);
return SimReply.Ok("S_RI", p);
}
}
/// <summary>Loads the ported Java <c>.properties</c> golden vectors.</summary>
public static class Fixtures
{
public static IReadOnlyDictionary<string, string> Load(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var line in File.ReadAllLines(path))
{
if (line.Length == 0 || line[0] == '#') continue;
var eq = line.IndexOf('=');
map[line[..eq]] = line[(eq + 1)..];
}
return map;
}
}