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. } } }