ba9015fac8
- 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
122 lines
5.3 KiB
C#
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;
|
|
}
|
|
}
|