Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/Lsv2Simulator.cs
T
Joseph Doherty d50ee809e3 feat(heidenhain): native read-only LSV2 driver + in-process simulator (#803)
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.
2026-11-10 23:08:30 -05:00

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();
}
}