using System.Diagnostics;
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Serial;
///
/// Drives the RIO serial link: a receive loop that frames incoming bytes into
/// packets (with ACK/NAK replies), plus the analog poll + reset-recovery timer.
/// The modern equivalent of the legacy overlapped-I/O watch thread
/// (CommWatchProc/ReadCommBlock); see docs/PROTOCOL.md §2 and §4.
///
/// The link drives a supplied but does not own
/// it: acquiring/releasing the COM port is the transport's lifecycle (create to
/// acquire, dispose to yield).
///
public sealed class RioSerialLink
{
private readonly IRioTransport _transport;
private readonly RioSerialLinkOptions _options;
private readonly PacketParser _parser = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
// Time since the last accepted AnalogReply, for the recovery watchdog.
private readonly Stopwatch _sinceAnalog = new();
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_options = options ?? new RioSerialLinkOptions();
}
/// Raised for every framed packet (after the ACK/NAK reply is sent).
public event Action? PacketReceived;
/// Raised for a decoded, valid .
public event Action? AnalogReceived;
/// Raised for a decoded .
public event Action? VersionReceived;
/// Raised for a decoded .
public event Action? CheckReceived;
/// Raised for a control byte received outside framing (ACK/NAK/RESTART/IDLE/garbage).
public event Action? ControlReceived;
/// Raised when a mid-packet framing error forced a resync.
public event Action? FramingError;
/// The transport's description, surfaced for status/logging.
public string Description => _transport.Description;
///
/// Run the receive loop and (if enabled) the analog poll loop until
/// fires or the transport closes.
///
public async Task RunAsync(CancellationToken cancellationToken)
{
_parser.Reset();
_sinceAnalog.Restart();
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var loops = new List { ReceiveLoopAsync(linked.Token) };
if (_options.AutoPollAnalog)
loops.Add(PollLoopAsync(linked.Token));
try
{
// If any running loop ends (transport closed / error / cancellation),
// tear the others down too.
await Compat.TaskCompat.WhenAny(loops).ConfigureAwait(false);
}
finally
{
linked.Cancel();
await Compat.TaskCompat.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
}
}
/// Send a pre-built packet (see ) to the RIO.
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
{
await Compat.TaskCompat.WaitAsync(_writeLock, cancellationToken).ConfigureAwait(false);
try
{
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
/// Request an analog update ().
public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
/// Request the RIO firmware version ().
public Task RequestVersionAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.VersionRequest(), cancellationToken);
/// Request a board/lamp status check ().
public Task RequestCheckAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.CheckRequest(), cancellationToken);
/// Issue a reset to recalibrate an axis or recover the board.
public Task ResetAsync(RioResetTarget target, CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.ResetRequest(target), cancellationToken);
/// Set a lighted button's state ().
public Task SetLampAsync(byte lampNumber, byte state, CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.LampRequest(lampNumber, state), cancellationToken);
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buffer = new byte[_options.ReadBufferSize];
while (!ct.IsCancellationRequested)
{
int n = await _transport.ReadAsync(buffer, ct).ConfigureAwait(false);
if (n == 0)
break; // transport closed
for (int i = 0; i < n; i++)
{
if (_parser.Feed(buffer[i], out RioRxEvent ev))
await HandleEventAsync(ev, ct).ConfigureAwait(false);
}
}
}
private async Task HandleEventAsync(RioRxEvent ev, CancellationToken ct)
{
switch (ev.Kind)
{
case RioRxEventKind.Packet:
await ReplyAsync(ev, ct).ConfigureAwait(false);
DispatchTyped(ev.Packet);
PacketReceived?.Invoke(ev.Packet);
break;
case RioRxEventKind.ControlByte:
ControlReceived?.Invoke(ev.Byte);
break;
case RioRxEventKind.FramingError:
FramingError?.Invoke();
break;
}
}
private void DispatchTyped(RioPacket packet)
{
switch (packet.Command)
{
case RioCommand.AnalogReply:
if (AnalogReport.TryParse(packet.Payload, out AnalogReport report))
{
_sinceAnalog.Restart();
AnalogReceived?.Invoke(report);
}
break;
case RioCommand.VersionReply:
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload));
break;
case RioCommand.CheckReply:
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload));
break;
}
}
private Task ReplyAsync(RioRxEvent ev, CancellationToken ct)
{
// Documented contract: ACK an accepted packet; NAK a button packet whose
// checksum failed. The legacy path force-accepts (always ACK) unless
// VerifyInboundChecksum re-enables real verification.
bool nak = _options.VerifyInboundChecksum
&& !ev.ChecksumValid
&& ev.Packet.Command is RioCommand.ButtonPressed or RioCommand.ButtonReleased;
byte reply = nak ? (byte)RioControl.Nak : (byte)RioControl.Ack;
return SendAsync(new[] { reply }, ct);
}
private async Task PollLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Compat.TaskCompat.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await RequestAnalogAsync(ct).ConfigureAwait(false);
if (_sinceAnalog.Elapsed > _options.AnalogRecoveryTimeout)
{
// No analog data for too long — recover with a general reset.
await SendAsync(PacketBuilder.ResetRequest(RioResetTarget.All), ct).ConfigureAwait(false);
_sinceAnalog.Restart(); // avoid re-issuing every tick
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private static async Task Swallow(Task task)
{
try
{
await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected on teardown.
}
}
}