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.
198 lines
7.5 KiB
C#
198 lines
7.5 KiB
C#
using System.Buffers.Binary;
|
|
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
|
|
|
/// <summary>One request frame as the simulated control received it.</summary>
|
|
public sealed record Lsv2Request(string Command, byte[] Payload)
|
|
{
|
|
/// <summary>The payload decoded as Latin-1 (for login/path assertions).</summary>
|
|
public string Text => Encoding.Latin1.GetString(Payload);
|
|
|
|
/// <summary>The R_RI selector.</summary>
|
|
public int Selector => BinaryPrimitives.ReadUInt16BigEndian(Payload);
|
|
}
|
|
|
|
/// <summary>A scripted connection to the simulator.</summary>
|
|
public sealed class SimConnection(TcpClient client)
|
|
{
|
|
private readonly NetworkStream _stream = client.GetStream();
|
|
|
|
/// <summary>Reads one complete request frame, or null on EOF.</summary>
|
|
public async Task<Lsv2Request?> ReadRequestAsync(CancellationToken ct)
|
|
{
|
|
var header = new byte[8];
|
|
var read = 0;
|
|
while (read < 8)
|
|
{
|
|
var n = await _stream.ReadAsync(header.AsMemory(read), ct).ConfigureAwait(false);
|
|
if (n == 0) return read == 0 ? null : throw new EndOfStreamException();
|
|
read += n;
|
|
}
|
|
|
|
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(header);
|
|
var payload = new byte[length];
|
|
await _stream.ReadExactlyAsync(payload, ct).ConfigureAwait(false);
|
|
return new Lsv2Request(Encoding.ASCII.GetString(header, 4, 4), payload);
|
|
}
|
|
|
|
/// <summary>Writes one well-formed reply frame.</summary>
|
|
public Task ReplyAsync(string command, byte[] payload, CancellationToken ct = default) =>
|
|
WriteRawAsync(Frame(command, payload), byteByByte: false, ct);
|
|
|
|
/// <summary>Writes raw bytes, optionally one byte per write (TCP fragmentation).</summary>
|
|
public async Task WriteRawAsync(byte[] bytes, bool byteByByte, CancellationToken ct = default)
|
|
{
|
|
if (!byteByByte)
|
|
{
|
|
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
|
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
foreach (var b in bytes)
|
|
{
|
|
await _stream.WriteAsync(new[] { b }, ct).ConfigureAwait(false);
|
|
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads one raw byte (blocks until the peer sends or closes).</summary>
|
|
public async Task<int> ReadByteAsync(CancellationToken ct)
|
|
{
|
|
var b = new byte[1];
|
|
return await _stream.ReadAsync(b, ct).ConfigureAwait(false) == 0 ? -1 : b[0];
|
|
}
|
|
|
|
/// <summary>Closes the connection.</summary>
|
|
public void Close() => client.Dispose();
|
|
|
|
/// <summary>Builds a frame independently of the driver's encoder.</summary>
|
|
public static byte[] Frame(string command, byte[] payload)
|
|
{
|
|
var f = new byte[8 + payload.Length];
|
|
BinaryPrimitives.WriteUInt32BigEndian(f, (uint)payload.Length);
|
|
Encoding.ASCII.GetBytes(command, f.AsSpan(4, 4));
|
|
payload.CopyTo(f, 8);
|
|
return f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-process LSV2 peer on a loopback port. Either runs a per-connection <see cref="Script"/> (framing tests)
|
|
/// or serves a <see cref="SimulatedControl"/> model. Records every request it receives on any connection,
|
|
/// so a test can prove that only allowlisted telegrams ever left the driver.
|
|
/// </summary>
|
|
public sealed class Lsv2Simulator : IAsyncDisposable
|
|
{
|
|
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly Func<SimConnection, CancellationToken, Task> _script;
|
|
private readonly ConcurrentQueue<Lsv2Request> _requests = new();
|
|
private readonly ConcurrentBag<TcpClient> _clients = new();
|
|
private readonly Task _accept;
|
|
private readonly ConcurrentQueue<Exception> _faults = new();
|
|
private volatile bool _offline;
|
|
private int _connections;
|
|
|
|
private Lsv2Simulator(Func<SimConnection, CancellationToken, Task> script)
|
|
{
|
|
_script = script;
|
|
_listener.Start();
|
|
_accept = Task.Run(AcceptLoopAsync);
|
|
}
|
|
|
|
/// <summary>A simulator running a raw per-connection script.</summary>
|
|
public static Lsv2Simulator WithScript(Func<SimConnection, CancellationToken, Task> script) => new(script);
|
|
|
|
/// <summary>A simulator serving a control model (one model shared by all connections).</summary>
|
|
public static Lsv2Simulator WithControl(SimulatedControl control)
|
|
{
|
|
Lsv2Simulator? sim = null;
|
|
sim = new Lsv2Simulator(async (c, ct) =>
|
|
{
|
|
while (true)
|
|
{
|
|
var req = await c.ReadRequestAsync(ct).ConfigureAwait(false);
|
|
if (req is null) return;
|
|
sim!._requests.Enqueue(req);
|
|
var reply = control.Handle(req);
|
|
switch (reply.Kind)
|
|
{
|
|
case SimReplyKind.Frame:
|
|
await c.ReplyAsync(reply.Command!, reply.Payload!, ct).ConfigureAwait(false);
|
|
break;
|
|
case SimReplyKind.Drop:
|
|
c.Close();
|
|
return;
|
|
case SimReplyKind.Stall:
|
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
return sim;
|
|
}
|
|
|
|
/// <summary>The loopback port.</summary>
|
|
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
|
|
/// <summary>Every request received (model mode), in arrival order.</summary>
|
|
public IReadOnlyList<Lsv2Request> Requests => _requests.ToArray();
|
|
|
|
/// <summary>Accepted connection count.</summary>
|
|
public int Connections => Volatile.Read(ref _connections);
|
|
|
|
/// <summary>Exceptions a script threw (tests assert this is empty).</summary>
|
|
public IReadOnlyList<Exception> Faults => _faults.ToArray();
|
|
|
|
/// <summary>Simulates a network outage: drops every live connection and refuses (closes) new ones.</summary>
|
|
public void GoOffline()
|
|
{
|
|
_offline = true;
|
|
foreach (var c in _clients) c.Dispose();
|
|
}
|
|
|
|
/// <summary>Ends the outage.</summary>
|
|
public void GoOnline() => _offline = false;
|
|
|
|
private async Task AcceptLoopAsync()
|
|
{
|
|
while (!_cts.IsCancellationRequested)
|
|
{
|
|
TcpClient client;
|
|
try { client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); }
|
|
catch { return; }
|
|
if (_offline)
|
|
{
|
|
client.Dispose();
|
|
continue;
|
|
}
|
|
|
|
Interlocked.Increment(ref _connections);
|
|
_clients.Add(client);
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try { await _script(new SimConnection(client), _cts.Token).ConfigureAwait(false); }
|
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or SocketException or EndOfStreamException) { }
|
|
catch (Exception ex) { _faults.Enqueue(ex); }
|
|
finally { client.Dispose(); }
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync().ConfigureAwait(false);
|
|
_listener.Stop();
|
|
foreach (var c in _clients) c.Dispose();
|
|
try { await _accept.ConfigureAwait(false); } catch { /* stopped */ }
|
|
_cts.Dispose();
|
|
}
|
|
}
|