diff --git a/program/ArduinoConnection.cs b/program/ArduinoConnection.cs new file mode 100644 index 0000000..6edc385 --- /dev/null +++ b/program/ArduinoConnection.cs @@ -0,0 +1,373 @@ +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; + + /// Raised on the GTK main thread for every complete line from the device. + public event Action LineReceived; + /// Raised on the GTK main thread with human-readable status text. + public event Action Log; + /// Raised on the GTK main thread when the link goes up or down. + public event Action 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}"); + } + } + + /// + /// 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. + /// + public Task 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(Action handler, T arg) + { + Action h = handler; + if (h == null) return; + Gtk.Application.Invoke((s, e) => h(arg)); + } + + public void Dispose() + { + _autoReconnect = false; + StopHeartbeat(); + ClosePort(silent: true); + } +} \ No newline at end of file diff --git a/program/MainWindow.cs b/program/MainWindow.cs index e4f74dd..216d3d2 100644 --- a/program/MainWindow.cs +++ b/program/MainWindow.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using Gtk; using UI = Gtk.Builder.ObjectAttribute; @@ -10,8 +11,10 @@ [UI] private Button Option2Button = null; [UI] private Button Option3Button = null; [UI] private Switch LanguageSwitch = null; - [UI] private Label LanguageLabel = null; + [UI] private Label LanguageLabel = null; [UI] private Button CloseButton = null; + [UI] private Button SettingButton = null; + [UI] private Image ConnectionIcon = null; // was GtkIconView in the old glade private CssProvider _scaleCssProvider; private CssProvider _languageCssProvider; @@ -27,6 +30,9 @@ private Dictionary _jpnText; private Dictionary _buttonsById; + private readonly ArduinoConnection _arduino = new ArduinoConnection(); + private SettingsWindow _settingsWindow; + public MainWindow() : this(CreateBuilder()) { } private static Builder CreateBuilder() @@ -51,18 +57,22 @@ Option1Button.Clicked += OnNormalSessionClicked; Option2Button.Clicked += OnPositionRecordingClicked; Option3Button.Clicked += OnExamModeClicked; - CloseButton.Clicked += OnCloseClicked; + CloseButton.Clicked += OnCloseClicked; + SettingButton.Clicked += OnSettingClicked; LanguageSwitch.AddNotification("active", OnLanguageSwitchNotify); - DeleteEvent += OnDeleteEvent; + DeleteEvent += OnDeleteEvent; SizeAllocated += OnWindowSizeAllocated; + _arduino.ConnectionChanged += OnArduinoConnectionChanged; + _buttonsById = new Dictionary { { "Option1Button", Option1Button }, { "Option2Button", Option2Button }, { "Option3Button", Option3Button }, - { "CloseButton", CloseButton }, + { "CloseButton", CloseButton }, + { "SettingButton", SettingButton }, }; // CSS ID selectors (#Option1Button etc.) need the widget Name set explicitly @@ -73,8 +83,46 @@ _jpnText = LoadButtonText("info_jpn.csv"); ApplyLanguage(LanguageSwitch.Active); + OnArduinoConnectionChanged(false); } + // ---------- settings panel ---------- + + private void OnSettingClicked(object sender, EventArgs e) + { + if (_settingsWindow == null) + { + _settingsWindow = new SettingsWindow(_arduino); + _settingsWindow.BackRequested += OnSettingsClosed; + } + + this.Hide(); + _settingsWindow.ShowAll(); + _settingsWindow.Fullscreen(); + _settingsWindow.Present(); + } + + private void OnSettingsClosed(object sender, EventArgs e) + { + this.ShowAll(); + this.Present(); + } + + private void OnArduinoConnectionChanged(bool connected) + { + if (ConnectionIcon == null) return; + + ConnectionIcon.SetFromIconName( + connected ? "network-transmit-receive" : "network-offline", + IconSize.Dnd); + + ConnectionIcon.TooltipText = connected + ? $"Connected to {_arduino.PortName}" + : "Device not connected"; + } + + // ---------- localisation ---------- + private Dictionary LoadButtonText(string path) { var result = new Dictionary(); @@ -91,7 +139,7 @@ var line = lines[i].Trim(); if (string.IsNullOrEmpty(line)) continue; - var parts = line.Split(new[] { ',' }, 2); // split into max 2 pieces, in case text has commas + var parts = line.Split(new[] { ',' }, 2); if (parts.Length < 2) continue; result[parts[0]] = parts[1]; @@ -112,16 +160,15 @@ } if (File.Exists(cssPath)) - { _languageCssProvider.LoadFromPath(cssPath); - } else - { Console.WriteLine($"Warning: CSS file not found: {cssPath}"); - } LanguageLabel.Text = isEnglish ? "あ" : "A"; } + + // ---------- scaling ---------- + private void OnWindowSizeAllocated(object o, SizeAllocatedArgs args) { int width = args.Allocation.Width; @@ -132,39 +179,57 @@ double scaleX = (double)width / BaseWidth; double scaleY = (double)height / BaseHeight; - double scale = Math.Min(scaleX, scaleY); - ApplyScale(scale); + ApplyScale(Math.Min(scaleX, scaleY)); } private void ApplyScale(double scale) { - int fontSize = Math.Max(10, (int)(20 * scale)); - int buttonPadV = Math.Max(4, (int)(12 * scale)); - int buttonPadH = Math.Max(8, (int)(24 * scale)); + int fontSize = Math.Max(10, (int)(20 * scale)); + int buttonPadV = Math.Max(4, (int)(12 * scale)); + int buttonPadH = Math.Max(8, (int)(24 * scale)); + int switchWidth = Math.Max(30, (int)(40 * scale)); + int switchHeight= Math.Max(18, (int)(24 * scale)); - string css = $@" + // NOTE: integers + InvariantCulture. Interpolating a double here emitted + // "40,5px" under ja_JP / de_DE locales and GTK silently dropped the rule. + string css = string.Format(CultureInfo.InvariantCulture, @" grid, label, button, switch {{ - font-size: {fontSize}px; + font-size: {0}px; }} button {{ - padding: {buttonPadV}px {buttonPadH}px; + padding: {1}px {2}px; }} switch {{ - min-width: {40 * scale}px; - min-height: {24 * scale}px; + min-width: {3}px; + min-height: {4}px; }} - "; + ", fontSize, buttonPadV, buttonPadH, switchWidth, switchHeight); _scaleCssProvider.LoadFromData(css); } + // ---------- lifecycle ---------- + private void OnDeleteEvent(object sender, DeleteEventArgs a) { - Application.Quit(); + Shutdown(); a.RetVal = true; } + private void OnCloseClicked(object sender, EventArgs e) + { + Shutdown(); + } + + private void Shutdown() + { + _arduino.Disconnect(); + _arduino.Dispose(); + _settingsWindow?.Destroy(); + Application.Quit(); + } + private void OnNormalSessionClicked(object sender, EventArgs e) { Console.WriteLine("Normal Session selected"); @@ -180,11 +245,6 @@ Console.WriteLine("Exam Mode selected"); } - private void OnCloseClicked(object sender, EventArgs e) - { - Application.Quit(); - } - private void OnLanguageSwitchNotify(object o, GLib.NotifyArgs args) { ApplyLanguage(LanguageSwitch.Active); diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs new file mode 100644 index 0000000..10a53e6 --- /dev/null +++ b/program/SettingWindow.cs @@ -0,0 +1,190 @@ +using System; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +public class SettingsWindow : Window +{ + [UI] private Label ComPortLabel = null; + [UI] private ComboBoxText ComPortComboBox = null; + [UI] private Button ConnectButton = null; + [UI] private Button AutoConnectButton = null; + [UI] private Button RefreshButton = null; + [UI] private Button BackButton = null; + [UI] private Label StatusLabel = null; + [UI] private Label LogLabel = null; + [UI] private TextView LogtextBox = null; + + private const int MaxLogLines = 500; + + private readonly ArduinoConnection _arduino; + private string[] _ports = new string[0]; + + /// Raised after the window hides itself, so the caller can re-show the main window. + public event EventHandler BackRequested; + + public SettingsWindow(ArduinoConnection arduino) + : this(CreateBuilder(), arduino) { } + + private static Builder CreateBuilder() + { + var builder = new Builder(); + builder.AddFromFile("homepage.glade"); + return builder; + } + + private SettingsWindow(Builder builder, ArduinoConnection arduino) + : base(builder.GetObject("SettingWindow").Handle) + { + builder.Autoconnect(this); + _arduino = arduino; + + Title = "Settings"; + ComPortLabel.Text = "COM Port"; + LogLabel.Text = "Log"; + LogtextBox.Editable = false; + LogtextBox.WrapMode = WrapMode.WordChar; + + RefreshButton.Clicked += (s, e) => RefreshPorts(); + ConnectButton.Clicked += OnConnectClicked; + AutoConnectButton.Clicked += OnAutoConnectClicked; + BackButton.Clicked += OnBackClicked; + + DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); }; + + _arduino.Log += AppendLog; + _arduino.LineReceived += OnLineReceived; + _arduino.ConnectionChanged += OnConnectionChanged; + + RefreshPorts(); + OnConnectionChanged(_arduino.IsOpen); + } + + // ---------- UI actions ---------- + + private void RefreshPorts() + { + ComPortComboBox.RemoveAll(); + _ports = ArduinoConnection.ListPorts(); + + foreach (string p in _ports) + ComPortComboBox.AppendText(p); + + if (_ports.Length > 0) + { + ComPortComboBox.Active = 0; + AppendLog($"{_ports.Length} port(s) found"); + } + else + { + AppendLog("No serial ports found"); + } + } + + private void OnConnectClicked(object sender, EventArgs e) + { + if (_arduino.IsOpen) + { + _arduino.Disconnect(); + return; + } + + string port = ComPortComboBox.ActiveText; + if (string.IsNullOrEmpty(port)) + { + AppendLog("Select a COM port first"); + return; + } + _arduino.Connect(port); + } + + private async void OnAutoConnectClicked(object sender, EventArgs e) + { + AutoConnectButton.Sensitive = false; + ConnectButton.Sensitive = false; + AppendLog("Scanning ports for device..."); + + string found = await _arduino.DetectPortAsync(); + + if (found != null) + { + RefreshPorts(); + SelectPort(found); + _arduino.Connect(found); + } + else + { + AppendLog("Device not found — use manual connection"); + } + + AutoConnectButton.Sensitive = true; + ConnectButton.Sensitive = true; + } + + private void OnBackClicked(object sender, EventArgs e) + { + Hide(); + BackRequested?.Invoke(this, EventArgs.Empty); + } + + private void SelectPort(string portName) + { + for (int i = 0; i < _ports.Length; i++) + { + if (_ports[i] == portName) + { + ComPortComboBox.Active = i; + return; + } + } + } + + // ---------- device events ---------- + + private void OnConnectionChanged(bool connected) + { + ConnectButton.Label = connected ? "Disconnect" : "Connect"; + StatusLabel.Text = connected + ? $"Connected ({_arduino.PortName})" + : "Disconnected"; + + ComPortComboBox.Sensitive = !connected; + RefreshButton.Sensitive = !connected; + + StyleContext ctx = StatusLabel.StyleContext; + ctx.RemoveClass(connected ? "status-off" : "status-on"); + ctx.AddClass(connected ? "status-on" : "status-off"); + } + + private void OnLineReceived(string line) + { + // Same filtering Form2.cs applied before parsing. + if (line.StartsWith("Send Status:")) return; + if (line.StartsWith("\u2713") || line.StartsWith("\u2717")) return; + + if (line.Contains("ESPNOW_CONNECTED")) { AppendLog("[ESP-NOW] Peer connected"); return; } + if (line.Contains("ESPNOW_DISCONNECTED")) { AppendLog("[ESP-NOW] Peer disconnected"); return; } + + AppendLog(line); + } + + // ---------- log ---------- + + public void AppendLog(string message) + { + TextBuffer buf = LogtextBox.Buffer; + + TextIter end = buf.EndIter; + buf.Insert(ref end, $"[{DateTime.Now:HH:mm:ss.fff}] {message}\n"); + + if (buf.LineCount > MaxLogLines) + { + TextIter start = buf.StartIter; + TextIter cut = buf.GetIterAtLine(buf.LineCount - MaxLogLines); + buf.Delete(ref start, ref cut); + } + + TextMark mark = buf.CreateMark(null, buf.EndIter, false); + LogtextBox.ScrollToMark(mark, 0, false, 0, 0); + buf.DeleteMark(mark); + } +} \ No newline at end of file diff --git a/program/homepage.glade b/program/homepage.glade index dca2529..5b7939a 100644 --- a/program/homepage.glade +++ b/program/homepage.glade @@ -2,9 +2,177 @@ + + False + 1280 + 800 + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + + + + False EARS + 1280 + 800 @@ -108,10 +276,11 @@ - + True - True - 6 + False + network-offline + 6 4 diff --git a/program/homepage.glade~ b/program/homepage.glade~ index dca2529..dcedc1b 100644 --- a/program/homepage.glade~ +++ b/program/homepage.glade~ @@ -2,9 +2,158 @@ + + False + 1280 + 800 + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + Refresh Ports + True + True + True + center + start + + + 0 + 1 + + + + + True + False + Disconnected + + + 2 + 1 + + + + + False EARS + 1280 + 800 @@ -108,10 +257,12 @@ - + True - True + False 6 + network-offline + 6 4 diff --git a/program/lang_jpn.css b/program/lang_jpn.css index 6916073..13b87e2 100644 --- a/program/lang_jpn.css +++ b/program/lang_jpn.css @@ -24,4 +24,7 @@ font-size: 18px; font-weight: bold; font-style: normal; -} \ No newline at end of file +} + +#StatusLabel.status-on { color: #2e7d32; font-weight: bold; } +#StatusLabel.status-off { color: #c62828; font-weight: bold; } \ No newline at end of file