d50ee809e3
Native LSV2 TCP transport (default port 19000; no Java/Python/vendor SDK), ported from the ignitionoee v0.20.3 reference driver: - Lsv2ReadOnlyPolicy: payload-aware outbound allowlist (A_LG INSPECT/DNC/DATA, R_VR, R_PR, R_RI 22-28/30-34/51, bounded R_MB, R_DP), applied in Lsv2Codec.Frame before any byte is written; no public raw-command API. - Lsv2Client: serialized transactions, one monotonic deadline per transaction, EOF/oversize/ unexpected/trickle close the client; Close never waits on the gate. - Lsv2Data / HeidenhainBindingCodec: reference decoders, R_PR byte-capacity + element-index rules, next-area cap, DATA Boolean 00/01/FF, strict non-truncating coercion. - HeidenhainSession: per-batch wire cache, refusal suppression, provisional DNC denial retry, alarm cursor (57 = end, overflow = BadOutOfRange), capability strings. - HeidenhainDeviceRuntime: one loop per control, generation-guarded publication, all-node BadNoCommunication on comms loss, bounded exponential reconnect. - HeidenhainDriver: cached ReadAsync, push subscriptions deduped on (value,status), static catalog discovery (no connect), host status, diagnostics, skipped-tag report. No IWritable. - Probe (INSPECT login + version) and factory registered in DriverFactoryBootstrap (probe on admin nodes too); DriverTypeNames.Heidenhain. Tests: 159 unit tests against an in-process LSV2 simulator incl. the Java fixtures' captured iTNC 530 replies as golden vectors; env-gated LiveIntegration category for real hardware.
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 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;
|
|
}
|
|
}
|