using System;
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public class ArduinoConnection : IDisposable
{
public const int BaudRate = 115200;
// Must match what your sketch replies to "REQUEST_ID" (Form2.cs: EXPECTED_DEVICE_ID)
// public const string ExpectedDeviceId = "EARS_READER";
public const string ExpectedDeviceId = "N_EARS_ESP32C3";
private const int HeartbeatTimeoutMs = 10000;
private const int HeartbeatCheckMs = 2000;
private const int MaxReconnectAttempts = 5;
private const int ResetSettleMs = 1500; // board reboots when DTR asserts
private readonly object _gate = new object();
private SerialPort _port;
private Thread _reader;
private volatile bool _reading;
private DateTime _lastData = DateTime.MinValue;
private uint _heartbeatId;
private bool _autoReconnect;
private int _reconnectAttempts;
private DateTime _nextReconnect = DateTime.MinValue;
/// <summary>Raised on the GTK main thread for every complete line from the device.</summary>
public event Action<string> LineReceived;
/// <summary>Raised on the GTK main thread with human-readable status text.</summary>
public event Action<string> Log;
/// <summary>Raised on the GTK main thread when the link goes up or down.</summary>
public event Action<bool> ConnectionChanged;
public string PortName { get; private set; }
public bool IsOpen
{
get { lock (_gate) return _port != null && _port.IsOpen; }
}
public static string[] ListPorts()
{
return SerialPort.GetPortNames()
.Distinct()
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
}
// ---------- public API ----------
public bool Connect(string portName)
{
if (string.IsNullOrEmpty(portName))
{
Emit(Log, "No port selected");
return false;
}
ClosePort(silent: true);
if (!OpenPort(portName))
return false;
_autoReconnect = false;
_reconnectAttempts = 0;
StartHeartbeat();
Emit(Log, $"Connected to {portName} @ {BaudRate}");
Emit(ConnectionChanged, true);
// Give the board time to finish booting before the first command.
Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3"));
return true;
}
public void Disconnect()
{
_autoReconnect = false;
StopHeartbeat();
ClosePort(silent: false);
}
public void Send(string command)
{
try
{
lock (_gate)
{
if (_port == null || !_port.IsOpen)
{
Emit(Log, $"Cannot send '{command}' — not connected");
return;
}
_port.Write(command + "\n");
}
Emit(Log, $"Sent: {command}");
}
catch (Exception ex)
{
Emit(Log, $"Send failed: {ex.Message}");
}
}
/// <summary>
/// Probes every port for a device answering REQUEST_ID with the expected ID.
/// Returns the port name, or null. Runs off the UI thread; the caller
/// connects on the main thread so no GLib call happens from a worker.
/// </summary>
public Task<string> DetectPortAsync()
{
return Task.Run(() =>
{
foreach (string p in ListPorts())
{
if (IsOpen && p == PortName) continue;
Emit(Log, $"Probing {p}...");
string id;
if (Probe(p, out id))
{
Emit(Log, $"Found {ExpectedDeviceId} on {p}");
return p;
}
if (id != null)
Emit(Log, $" {p}: reported '{id}' (not a match)");
}
return (string)null;
});
}
// ---------- port plumbing ----------
private bool OpenPort(string portName)
{
try
{
lock (_gate)
{
_port = new SerialPort(portName, BaudRate)
{
ReadTimeout = 500,
WriteTimeout = 1000,
NewLine = "\n",
DtrEnable = true,
RtsEnable = true,
};
_port.Open();
}
PortName = portName;
_lastData = DateTime.Now;
StartReader();
return true;
}
catch (Exception ex)
{
Emit(Log, $"Open failed on {portName}: {ex.Message}");
lock (_gate)
{
_port?.Dispose();
_port = null;
}
return false;
}
}
private void ClosePort(bool silent)
{
_reading = false;
Thread t = _reader;
_reader = null;
if (t != null && t.IsAlive && t != Thread.CurrentThread)
t.Join(1000);
lock (_gate)
{
try { if (_port != null && _port.IsOpen) _port.Close(); }
catch (Exception ex) { Emit(Log, $"Close error: {ex.Message}"); }
_port?.Dispose();
_port = null;
}
if (!silent)
{
Emit(Log, "Disconnected");
Emit(ConnectionChanged, false);
}
}
private void StartReader()
{
_reading = true;
_reader = new Thread(ReaderLoop)
{
IsBackground = true,
Name = "arduino-reader"
};
_reader.Start();
}
private void ReaderLoop()
{
var pending = new StringBuilder();
var chunk = new byte[4096];
while (_reading)
{
SerialPort p;
lock (_gate) p = _port;
if (p == null || !p.IsOpen) { Thread.Sleep(100); continue; }
try
{
int n = p.Read(chunk, 0, chunk.Length);
if (n <= 0) continue;
_lastData = DateTime.Now;
pending.Append(Encoding.ASCII.GetString(chunk, 0, n));
string buffered = pending.ToString();
int nl;
while ((nl = buffered.IndexOf('\n')) >= 0)
{
string line = buffered.Substring(0, nl).Trim();
buffered = buffered.Substring(nl + 1);
if (line.Length > 0)
Emit(LineReceived, line);
}
pending.Clear();
pending.Append(buffered);
}
catch (TimeoutException) { /* normal when idle */ }
catch (Exception ex)
{
if (_reading) Emit(Log, $"Read error: {ex.Message}");
Thread.Sleep(200);
}
}
}
private bool Probe(string portName, out string deviceId)
{
deviceId = null;
SerialPort test = null;
try
{
test = new SerialPort(portName, BaudRate)
{
ReadTimeout = 500,
WriteTimeout = 1000,
NewLine = "\n",
DtrEnable = true,
RtsEnable = true,
};
test.Open();
Thread.Sleep(ResetSettleMs);
test.DiscardInBuffer();
test.Write("REQUEST_ID\n");
DateTime deadline = DateTime.Now.AddSeconds(3);
while (DateTime.Now < deadline)
{
try
{
string line = test.ReadLine().Trim();
if (line.StartsWith("DEVICE_ID:"))
{
deviceId = line.Substring("DEVICE_ID:".Length).Trim();
return deviceId == ExpectedDeviceId;
}
}
catch (TimeoutException) { }
}
}
catch (Exception ex)
{
Emit(Log, $" {portName}: {ex.Message}");
}
finally
{
try { test?.Close(); } catch { }
test?.Dispose();
}
return false;
}
// ---------- heartbeat / auto-reconnect (runs on the GTK main loop) ----------
private void StartHeartbeat()
{
StopHeartbeat();
_heartbeatId = GLib.Timeout.Add(HeartbeatCheckMs, OnHeartbeatTick);
}
private void StopHeartbeat()
{
if (_heartbeatId != 0)
{
GLib.Source.Remove(_heartbeatId);
_heartbeatId = 0;
}
}
private bool OnHeartbeatTick()
{
if (_autoReconnect && !IsOpen)
{
if (_reconnectAttempts >= MaxReconnectAttempts)
{
_autoReconnect = false;
Log?.Invoke($"Reconnection failed after {MaxReconnectAttempts} attempts. Check USB cable, port and power, then reconnect manually.");
StopHeartbeat();
return false;
}
if (DateTime.Now >= _nextReconnect)
{
_reconnectAttempts++;
Log?.Invoke($"Reconnect attempt {_reconnectAttempts}/{MaxReconnectAttempts} on {PortName}...");
if (OpenPort(PortName))
{
_autoReconnect = false;
_reconnectAttempts = 0;
Log?.Invoke("Reconnected");
ConnectionChanged?.Invoke(true);
Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3"));
}
else
{
_nextReconnect = DateTime.Now.AddSeconds(2);
}
}
return true;
}
if (!IsOpen) return true;
if ((DateTime.Now - _lastData).TotalMilliseconds >= HeartbeatTimeoutMs)
{
Log?.Invoke("Heartbeat timeout — no device activity");
ClosePort(silent: true);
ConnectionChanged?.Invoke(false);
_autoReconnect = true;
_reconnectAttempts = 0;
_nextReconnect = DateTime.Now.AddSeconds(2);
}
return true;
}
// ---------- helpers ----------
private static void Emit<T>(Action<T> handler, T arg)
{
Action<T> h = handler;
if (h == null) return;
Gtk.Application.Invoke((s, e) => h(arg));
}
public void Dispose()
{
_autoReconnect = false;
StopHeartbeat();
ClosePort(silent: true);
}
}