From edcf14b3eb1182c15cab8b6053914898de9ba180 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 24 Mar 2026 23:25:01 -0300 Subject: [PATCH] feat: s3 e ctrl+alt+del --- .claude/napkin.md | 43 +++ KVMote.csproj | 2 +- Principal.Designer.cs | 17 +- Principal.cs | 417 ++++++++++++++------------- S3/KVMote_ESP32S3/KVMote_ESP32S3.ino | 263 +++++++++++++++++ Transport/BleTransport.cs | 303 +++++++++++++++++++ Transport/IKvmTransport.cs | 36 +++ Transport/SerialTransport.cs | 152 ++++++++++ KVMote.ino => Uno/KVMote.ino | 0 9 files changed, 1031 insertions(+), 202 deletions(-) create mode 100644 .claude/napkin.md create mode 100644 S3/KVMote_ESP32S3/KVMote_ESP32S3.ino create mode 100644 Transport/BleTransport.cs create mode 100644 Transport/IKvmTransport.cs create mode 100644 Transport/SerialTransport.cs rename KVMote.ino => Uno/KVMote.ino (100%) diff --git a/.claude/napkin.md b/.claude/napkin.md new file mode 100644 index 0000000..75f1ca2 --- /dev/null +++ b/.claude/napkin.md @@ -0,0 +1,43 @@ +# Napkin Runbook — KVMote + +## Curation Rules +- Re-prioritize on every read. +- Keep recurring, high-value notes only. +- Max 10 items per category. +- Each item includes date + "Do instead". + +## Execution & Validation (Highest Priority) + +1. **[2026-03-24] `ImplicitUsings=disable` — todo using deve ser explícito** + Do instead: ao adicionar código, verificar se todos os namespaces usados têm `using` explícito no topo. Faltou um → erro CS0103 silencioso em runtime. + +2. **[2026-03-24] Designer file: sempre usar `this.` nos membros de Form** + Do instead: em `Principal.Designer.cs`, escrever `this.btnConnect.Text = ...` — nunca omitir `this.`. Sem isso → CS0103. + +3. **[2026-03-24] AutoScaleMode deve ser `Dpi` com `AutoScaleDimensions = new SizeF(96F, 96F)`** + Do instead: nunca usar `None` (quebra DPI alto) nem `Font` (escala diferente por máquina). + +4. **[2026-03-24] Form1.cs / Form1.Designer.cs são stubs vazios — não remover** + Do instead: manter como `namespace KVMote { }`. VS os inclui no .csproj; remover do disco pode causar erro de build. + +## Build & Deploy + +1. **[2026-03-24] Não usar "Publicar" do Visual Studio — gera ClickOnce (múltiplos arquivos)** + Do instead: usar `dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true` no terminal. + +## Domain Behavior Guardrails + +1. **[2026-03-24] Canal serial é único (9600 baud) — mouse trava durante paste de clipboard** + Do instead: isso é design proposital (limitação do BT HC-06). Não tentar paralelizar sem mudar o protocolo de transporte. + +2. **[2026-03-24] Chars não-ASCII (é, ã, ç) não são enviáveis via clipboard** + Do instead: filtrar/ignorar na `SendClipboardToClient`. Limite: `MaxClipChars = 300`. + +3. **[2026-03-24] `ClipCursor` não é usado para prender o cursor em modo cliente** + Do instead: usar técnica FPS warp — `SetCursorPos` de volta ao centro a cada WM_MOUSEMOVE, acumulando deltas reais em `_pendingDX/_pendingDY`. + +4. **[2026-03-24] `SendMouse` usa `Monitor.TryEnter` (lossy) — descarta pacotes se canal ocupado** + Do instead: isso é intencional. Não trocar por `lock` pois bloquearia o hook de mouse. + +5. **[2026-03-24] Arduino Leonardo é obrigatório — Uno não suporta HID USB nativo** + Do instead: nunca sugerir Arduino Uno ou Mega como substituto. diff --git a/KVMote.csproj b/KVMote.csproj index 82e1b17..0619181 100644 --- a/KVMote.csproj +++ b/KVMote.csproj @@ -2,7 +2,7 @@ WinExe - net8.0-windows + net8.0-windows10.0.19041.0 enable true disable diff --git a/Principal.Designer.cs b/Principal.Designer.cs index 97bf18a..409e544 100644 --- a/Principal.Designer.cs +++ b/Principal.Designer.cs @@ -16,6 +16,7 @@ namespace KVMote private System.Windows.Forms.Panel pnlContent; private System.Windows.Forms.Label lblLayout; private System.Windows.Forms.ComboBox cmbLayout; + private System.Windows.Forms.Button btnSendCad; private void InitializeComponent() { @@ -23,6 +24,7 @@ namespace KVMote this.pnlBottom = new System.Windows.Forms.Panel(); this.lblLayout = new System.Windows.Forms.Label(); this.cmbLayout = new System.Windows.Forms.ComboBox(); + this.btnSendCad = new System.Windows.Forms.Button(); this.lblPosition = new System.Windows.Forms.Label(); this.btnAbove = new System.Windows.Forms.Button(); this.btnLeft = new System.Windows.Forms.Button(); @@ -109,7 +111,7 @@ namespace KVMote int sepY = gy + (ch + cg) * 3 + 4; - this.lblPortInfo.Text = "Porta: detectando..."; + this.lblPortInfo.Text = "Detectando..."; this.lblPortInfo.Location = new System.Drawing.Point(gx, sepY + 10); this.lblPortInfo.Size = new System.Drawing.Size(268, 20); this.lblPortInfo.ForeColor = clrSilver; @@ -152,6 +154,16 @@ namespace KVMote this.cmbLayout.ForeColor = clrWhite; this.cmbLayout.SelectedIndexChanged += new System.EventHandler(this.cmbLayout_SelectedIndexChanged); + this.btnSendCad.Text = "Enviar Ctrl+Alt+Del"; + this.btnSendCad.Location = new System.Drawing.Point(gx, sepY + 108); + this.btnSendCad.Size = new System.Drawing.Size(268, 30); + this.btnSendCad.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnSendCad.BackColor = System.Drawing.Color.FromArgb(65, 65, 65); + this.btnSendCad.ForeColor = clrWhite; + this.btnSendCad.Font = fntUI; + this.btnSendCad.FlatAppearance.BorderColor = clrBorder; + this.btnSendCad.Click += new System.EventHandler(this.btnSendCad_Click); + this.pnlContent.BackColor = System.Drawing.Color.FromArgb(20, 20, 20); this.pnlContent.Dock = System.Windows.Forms.DockStyle.Fill; this.pnlContent.Controls.Add(this.lblPosition); @@ -165,6 +177,7 @@ namespace KVMote this.pnlContent.Controls.Add(this.btnConnect); this.pnlContent.Controls.Add(this.lblLayout); this.pnlContent.Controls.Add(this.cmbLayout); + this.pnlContent.Controls.Add(this.btnSendCad); this.pnlBottom.BackColor = System.Drawing.Color.FromArgb(40, 40, 40); this.pnlBottom.Dock = System.Windows.Forms.DockStyle.Bottom; this.pnlBottom.Height = 28; @@ -176,7 +189,7 @@ namespace KVMote this.lblStatus.AutoSize = true; this.lblStatus.Location = new System.Drawing.Point(8, 6); - int contentH = sepY + 74 + 22 + 16; + int contentH = sepY + 108 + 30 + 16; this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; diff --git a/Principal.cs b/Principal.cs index feadba5..3c897bb 100644 --- a/Principal.cs +++ b/Principal.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO.Ports; +using System.Linq; using System.Runtime.InteropServices; -using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; +using KVMote.Transport; namespace KVMote { @@ -22,6 +22,7 @@ namespace KVMote [DllImport("user32.dll")] private static extern bool ClipCursor(ref RECT r); [DllImport("user32.dll")] private static extern bool ClipCursor(IntPtr zero); [DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string? n); + [DllImport("user32.dll")] private static extern short GetKeyState(int vk); [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } @@ -42,7 +43,6 @@ namespace KVMote } // ── Constants ────────────────────────────────────────────────────────────── - private const int BaudRate = 9600; private const int WH_MOUSE_LL = 14; private const int WH_KEYBOARD_LL = 13; private const int WM_MOUSEMOVE = 0x0200; @@ -59,7 +59,6 @@ namespace KVMote private const int ReturnThreshold = 15; private const int HeartbeatMs = 3000; private const int PongTimeoutMs = 9000; - private const int MaxTimeouts = 3; // ── Enum ─────────────────────────────────────────────────────────────────── private enum ClientPos { None, Left, Right, Above, Below } @@ -67,11 +66,9 @@ namespace KVMote // ── Fields ───────────────────────────────────────────────────────────────── - // Serial - private SerialPort? _port; + // Transport (strategy) + private IKvmTransport? _transport; private bool _userConnected, _isReconnecting; - private readonly object _sendLock = new(); - private int _timeoutCount; private DateTime _lastPong = DateTime.MinValue; // Hooks @@ -80,7 +77,7 @@ namespace KVMote // KVM state private bool _clientMode; - private bool _ctrlHeld, _shiftHeld; + private bool _ctrlHeld, _shiftHeld, _altHeld; private ClientLayout _clientLayout = ClientLayout.US; private ClientPos _clientPos = ClientPos.None; private System.Drawing.Point _edgeEntry; @@ -89,7 +86,10 @@ namespace KVMote private int _pendingDX, _pendingDY; private bool _isWarping; private readonly Stopwatch _mouseThrottle = Stopwatch.StartNew(); - private int _wheelAccum; // acumula deltas do touchpad (smooth scroll) + private int _wheelAccum; + private bool _scrollActive; + private readonly Stopwatch _scrollTimer = new Stopwatch(); + private bool _clipboardReady; // Timers private readonly System.Windows.Forms.Timer _watchdog = new System.Windows.Forms.Timer { Interval = 2000 }; @@ -109,7 +109,7 @@ namespace KVMote } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 1 — Auto-detect COM + // SECTION 1 — Auto-detect (Serial + BLE em paralelo — Opção A) // ══════════════════════════════════════════════════════════════════════════ private async Task AutoDetectAsync() @@ -124,46 +124,78 @@ namespace KVMote else btnConnect.Enabled = false; - while (!token.IsCancellationRequested) + // Fast path: reutiliza transport existente (endereço já conhecido) + if (_transport is not null) { - string? found = await Task.Run(() => ProbePorts(), token); - if (found != null) + try { await Task.Delay(2500, token); } catch { return; } + if (!token.IsCancellationRequested && await _transport.DetectAsync(token)) { - SetPortInfo($"\u25cf {found} detectado"); + SetPortInfo($"\u25cf {_transport.DeviceLabel} detectado"); if (btnConnect.InvokeRequired) btnConnect.Invoke((Action)(() => btnConnect.Enabled = true)); else btnConnect.Enabled = true; return; } - SetPortInfo("Nenhuma porta encontrada..."); + // Dispositivo não responde mais — descarta e faz scan completo + _transport.Dispose(); + _transport = null; + } + + while (!token.IsCancellationRequested) + { + IKvmTransport? found = await DetectRaceAsync(token); + if (found is not null) + { + _transport?.Dispose(); + _transport = found; + SetPortInfo($"\u25cf {found.DeviceLabel} detectado"); + if (btnConnect.InvokeRequired) + btnConnect.Invoke((Action)(() => btnConnect.Enabled = true)); + else + btnConnect.Enabled = true; + return; + } + SetPortInfo("Nenhum dispositivo encontrado..."); try { await Task.Delay(3000, token); } catch { return; } } } - private string? ProbePorts() + // Serial e BLE disputam: primeiro a responder [PONG] vence. + // Serial termina rápido (varre COMs). BLE tem timeout interno de 8s. + // Se ambos falharem, AutoDetectAsync retenta após 3s. + private static async Task DetectRaceAsync(CancellationToken outerCt) { - foreach (string port in SerialPort.GetPortNames()) + using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(outerCt); + var ct = raceCts.Token; + + IKvmTransport[] candidates = [new SerialTransport(), new BleTransport()]; + Task[] tasks = candidates.Select(t => t.DetectAsync(ct)).ToArray(); + var pending = new List>(tasks); + IKvmTransport? winner = null; + + while (pending.Count > 0) { - try + var done = await Task.WhenAny(pending); + pending.Remove(done); + + bool found = false; + try { found = await done; } catch { } + + if (found) { - using var sp = new SerialPort(port, BaudRate) - { - WriteTimeout = 200, - ReadTimeout = 600, - Encoding = Encoding.ASCII - }; - sp.Open(); - Thread.Sleep(150); - sp.Write("~"); - Thread.Sleep(500); - string resp = sp.ReadExisting(); - sp.Close(); - if (resp.Contains("[PONG]")) return port; + int idx = Array.IndexOf(tasks, done); + winner = candidates[idx]; + raceCts.Cancel(); // cancela o perdedor + break; } - catch { } } - return null; + + // Descarta os transportes que não venceram + for (int i = 0; i < candidates.Length; i++) + if (candidates[i] != winner) candidates[i].Dispose(); + + return winner; } // ══════════════════════════════════════════════════════════════════════════ @@ -203,13 +235,13 @@ namespace KVMote // SECTION 4 — Connect / Disconnect // ══════════════════════════════════════════════════════════════════════════ - private void btnConnect_Click(object sender, EventArgs e) + private async void btnConnect_Click(object sender, EventArgs e) { - if (!_userConnected) Connect(); + if (!_userConnected) await ConnectAsync(); else Disconnect(true); } - private void Connect() + private async Task ConnectAsync() { if (_clientPos == ClientPos.None) { @@ -218,11 +250,9 @@ namespace KVMote return; } - string raw = lblPortInfo.Text.Replace("Porta: ", "").Replace("\u25cf ", "").Replace(" detectado", "").Trim(); - string portName = raw.StartsWith("COM") ? raw : ""; - if (string.IsNullOrEmpty(portName)) + if (_transport is null) { - MessageBox.Show("Nenhuma porta detectada. Clique em Detectar.", "KVMote", + MessageBox.Show("Nenhum dispositivo detectado. Clique em Detectar.", "KVMote", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } @@ -230,22 +260,23 @@ namespace KVMote SetStatus("Conectando...", System.Drawing.Color.Orange); btnConnect.Enabled = false; - if (OpenPort(portName)) + if (await _transport.ConnectAsync()) { + _transport.DataReceived += OnTransportData; _userConnected = true; - _timeoutCount = 0; _lastPong = DateTime.MinValue; _watchdog.Start(); _heartbeat = new System.Threading.Timer(OnHeartbeat, null, HeartbeatMs, HeartbeatMs); InstallHooks(); SetConnectedUI(true); - Send(new byte[] { (byte)'H' }); // LED azul no Arduino + _transport.Send(new byte[] { (byte)'H' }); // LED azul no Arduino/ESP32 SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green); } else { SetStatus("Falha na conexão", System.Drawing.Color.Red); btnConnect.Enabled = true; + _ = AutoDetectAsync(); // reinicia detecção (ex: BLE resetou endereço após AccessDenied) } } @@ -258,12 +289,21 @@ namespace KVMote _watchdog.Stop(); _heartbeat?.Dispose(); _heartbeat = null; UninstallHooks(); - // Sinaliza ao Arduino antes de fechar (LED verde = sem host) - if (_port?.IsOpen == true) - try { lock (_sendLock) { _port.Write(new byte[] { (byte)'G' }, 0, 1); } } catch { } - ClosePort(); + + if (_transport is not null) + { + _transport.DataReceived -= OnTransportData; + if (_transport.IsConnected) + try { _transport.Send(new byte[] { (byte)'G' }); } catch { } // LED verde + _transport.Disconnect(); + } + SetConnectedUI(false); - if (userInitiated) SetStatus("Desconectado", System.Drawing.Color.Gray); + if (userInitiated) + { + SetStatus("Desconectado", System.Drawing.Color.Gray); + _ = AutoDetectAsync(); // reinicia detecção automática + } } private void SetConnectedUI(bool connected) @@ -279,53 +319,18 @@ namespace KVMote } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 5 — Port management + // SECTION 5 — Transport data + Watchdog + Heartbeat + Reconnect // ══════════════════════════════════════════════════════════════════════════ - private bool OpenPort(string name) + private void OnTransportData(string data) { - try - { - _port = new SerialPort(name, BaudRate) { WriteTimeout = 200, Encoding = Encoding.ASCII }; - _port.DataReceived += OnDataReceived; - _port.Open(); - return true; - } - catch (Exception ex) - { - Log($"OpenPort: {ex.Message}"); - _port?.Dispose(); - _port = null; - return false; - } + if (data.Contains("[PONG]")) _lastPong = DateTime.UtcNow; } - private void ClosePort() - { - if (_port is null) return; - try { _port.DataReceived -= OnDataReceived; if (_port.IsOpen) _port.Close(); } - catch { } - finally { _port.Dispose(); _port = null; } - } - - private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) - { - try - { - string data = _port?.ReadExisting() ?? ""; - if (data.Contains("[PONG]")) _lastPong = DateTime.UtcNow; - } - catch { } - } - - // ══════════════════════════════════════════════════════════════════════════ - // SECTION 6 — Watchdog + Heartbeat + Reconnect - // ══════════════════════════════════════════════════════════════════════════ - private void OnWatchdog(object? sender, EventArgs e) { if (!_userConnected || _isReconnecting) return; - if (_port is null || !_port.IsOpen) BeginReconnect(); + if (_transport is null || !_transport.IsConnected) BeginReconnect(); } private void OnHeartbeat(object? state) @@ -340,11 +345,7 @@ namespace KVMote return; } - lock (_sendLock) - { - try { _port?.Write("~"); } - catch { BeginReconnect(); } - } + _transport?.Send(new byte[] { (byte)'~' }); } private void BeginReconnect() @@ -352,22 +353,28 @@ namespace KVMote if (_isReconnecting) return; _isReconnecting = true; - // Garante que o cursor volta imediatamente se estava em modo cliente if (InvokeRequired) Invoke((Action)(() => ExitClientMode(sendRelease: false))); else ExitClientMode(sendRelease: false); - string portName = _port?.PortName ?? ""; SetStatus("Reconectando...", System.Drawing.Color.Orange); if (InvokeRequired) Invoke((Action)(() => SetConnectedUI(false))); else SetConnectedUI(false); - ClosePort(); + + _transport?.Disconnect(); Task.Run(async () => { while (_isReconnecting && _userConnected) { await Task.Delay(2500); - if (OpenPort(portName)) + if (_transport is null) break; + + // Re-detecta antes de conectar: BLE pode ter resetado o endereço + // após AccessDenied+unpair; fast-path retorna imediatamente se válido. + using var detectCts = new CancellationTokenSource(10000); + if (!await _transport.DetectAsync(detectCts.Token)) continue; + + if (await _transport.ConnectAsync()) { _isReconnecting = false; _lastPong = DateTime.MinValue; @@ -381,7 +388,7 @@ namespace KVMote } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 7 — Global Hooks + // SECTION 6 — Global Hooks // ══════════════════════════════════════════════════════════════════════════ private void InstallHooks() @@ -400,7 +407,7 @@ namespace KVMote } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 8 — Mouse Hook Callback + // SECTION 7 — Mouse Hook Callback // ══════════════════════════════════════════════════════════════════════════ private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) @@ -421,9 +428,15 @@ namespace KVMote if (msg == WM_MOUSEMOVE) { - // Ignora o WM_MOUSEMOVE gerado pelo próprio warp para o centro if (_isWarping) { _isWarping = false; return (IntPtr)1; } + // Suppress warp during two-finger touchpad scroll (gesture-driver conflict) + if (_scrollActive) + { + if (_scrollTimer.ElapsedMilliseconds > 150) _scrollActive = false; + else { _lastRawPos = new System.Drawing.Point(info.pt.x, info.pt.y); return (IntPtr)1; } + } + int dx = info.pt.x - _lastRawPos.X; int dy = info.pt.y - _lastRawPos.Y; @@ -434,7 +447,6 @@ namespace KVMote if (ShouldReturnToHost()) { ExitClientMode(sendRelease: true); return (IntPtr)1; } - // Warp de volta ao centro para obter deltas reais sem ClipCursor _isWarping = true; SetCursorPos(_lastRawPos.X, _lastRawPos.Y); @@ -445,27 +457,26 @@ namespace KVMote int sdy = Math.Clamp(_pendingDY, -127, 127); _pendingDX = 0; _pendingDY = 0; - SendMouse(new byte[] { (byte)'M', (byte)(sbyte)sdx, (byte)(sbyte)sdy }); + _transport?.SendLossy(new byte[] { (byte)'M', (byte)(sbyte)sdx, (byte)(sbyte)sdy }); } return (IntPtr)1; } - if (msg == WM_LBUTTONDOWN) { Send(new byte[] { (byte)'D', (byte)'L' }); return (IntPtr)1; } - if (msg == WM_LBUTTONUP) { Send(new byte[] { (byte)'E', (byte)'L' }); return (IntPtr)1; } - if (msg == WM_RBUTTONDOWN) { Send(new byte[] { (byte)'D', (byte)'R' }); return (IntPtr)1; } - if (msg == WM_RBUTTONUP) { Send(new byte[] { (byte)'E', (byte)'R' }); return (IntPtr)1; } + if (msg == WM_LBUTTONDOWN) { _transport?.Send(new byte[] { (byte)'D', (byte)'L' }); return (IntPtr)1; } + if (msg == WM_LBUTTONUP) { _transport?.Send(new byte[] { (byte)'E', (byte)'L' }); return (IntPtr)1; } + if (msg == WM_RBUTTONDOWN) { _transport?.Send(new byte[] { (byte)'D', (byte)'R' }); return (IntPtr)1; } + if (msg == WM_RBUTTONUP) { _transport?.Send(new byte[] { (byte)'E', (byte)'R' }); return (IntPtr)1; } if (msg == WM_MOUSEWHEEL) { - // Acumula deltas: mouse wheel envia ±120/notch, - // touchpad 2 dedos envia valores pequenos (±3..±15). - // Enviamos 1 unidade ao Arduino a cada 120 acumulados. + _scrollActive = true; + _scrollTimer.Restart(); _wheelAccum += (short)(info.mouseData >> 16); int toSend = _wheelAccum / 120; if (toSend != 0) { _wheelAccum -= toSend * 120; - Send(new byte[] { (byte)'W', (byte)(sbyte)Math.Clamp(toSend, -127, 127) }); + _transport?.Send(new byte[] { (byte)'W', (byte)(sbyte)Math.Clamp(toSend, -127, 127) }); } return (IntPtr)1; } @@ -505,14 +516,13 @@ namespace KVMote _pendingDY = 0; _mouseThrottle.Restart(); - // Cursor fica invisível no centro da tela — deltas calculados por warp (técnica FPS) var s = Screen.PrimaryScreen!.Bounds; var center = new System.Drawing.Point(s.Left + s.Width / 2, s.Top + s.Height / 2); _lastRawPos = center; _isWarping = true; SetCursorPos(center.X, center.Y); Cursor.Hide(); - Send(new byte[] { (byte)'O' }); // LED laranja no Arduino + _transport?.Send(new byte[] { (byte)'O' }); // LED magenta SetStatus("Modo Cliente \u25cf", System.Drawing.Color.DodgerBlue); } @@ -536,18 +546,18 @@ namespace KVMote }; SetCursorPos(ret.X, ret.Y); - if (sendRelease) Send(new byte[] { (byte)'A' }); - Send(new byte[] { (byte)'H' }); // flash verde + LED azul no Arduino + if (sendRelease) _transport?.Send(new byte[] { (byte)'A' }); + _transport?.Send(new byte[] { (byte)'H' }); // LED azul SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green); } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 9 — Keyboard Hook Callback + // SECTION 8 — Keyboard Hook Callback // ══════════════════════════════════════════════════════════════════════════ private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) { - if (nCode < 0 || !_userConnected || !_clientMode) + if (nCode < 0 || !_userConnected) return CallNextHookEx(_keyHook, nCode, wParam, lParam); int msg = (int)wParam; @@ -560,32 +570,60 @@ namespace KVMote var info = Marshal.PtrToStructure(lParam); uint vk = info.vk; - // Rastreia Ctrl e Shift (mais confiável que GetKeyState dentro do hook) + // Update modifier states (shared for both modes) if (vk == 0xA2 || vk == 0xA3 || vk == 0x11) _ctrlHeld = isDown; if (vk == 0xA0 || vk == 0xA1 || vk == 0x10) _shiftHeld = isDown; + if (vk == 0xA4 || vk == 0xA5 || vk == 0x12) _altHeld = isDown; + + // ── Shortcut: Ctrl + Alt + Insert (0x2D) ────────────────────────── + // Intercepts and sends Ctrl+Alt+Delete to client. + // Works in both Host and Client mode as long as connected. + if (isDown && _ctrlHeld && _altHeld && vk == 0x2D) + { + SendCtrlAltDelToClient(); + return (IntPtr)1; // Suppress from Host + } + + // Host mode: track Ctrl state and mark clipboard ready on Ctrl+C + if (!_clientMode) + { + if (isDown && vk == 0x43 && _ctrlHeld) _clipboardReady = true; + return CallNextHookEx(_keyHook, nCode, wParam, lParam); + } - // Ctrl+V ou Shift+Ins → envia clipboard do host como digitação if (isDown) { if ((vk == 0x56 && _ctrlHeld) || (vk == 0x2D && _shiftHeld)) { - BeginInvoke((Action)SendClipboardToClient); + if (_clipboardReady) + { + _clipboardReady = false; + BeginInvoke((Action)SendClipboardToClient); + } + else + { + // No host clipboard ready — forward Ctrl+V to client normally + byte? vc = VkToArduino(vk); + if (vc.HasValue) + _transport?.Send(new byte[] { (byte)'P', vc.Value }); + } return (IntPtr)1; } } byte? code = VkToArduino(vk); if (code.HasValue) - Send(new byte[] { isDown ? (byte)'P' : (byte)'U', code.Value }); + _transport?.Send(new byte[] { isDown ? (byte)'P' : (byte)'U', code.Value }); return (IntPtr)1; } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 10 — VK → Arduino keycode mapping + // SECTION 9 — VK → Arduino keycode mapping // ══════════════════════════════════════════════════════════════════════════ - private static readonly Dictionary KeyMap = new Dictionary + private static readonly System.Collections.Generic.Dictionary KeyMap = + new System.Collections.Generic.Dictionary { { 0xA0, 0x81 }, { 0xA1, 0x85 }, { 0xA2, 0x80 }, { 0xA3, 0x84 }, { 0xA4, 0x82 }, { 0xA5, 0x86 }, { 0x5B, 0x83 }, { 0x5C, 0x87 }, @@ -617,40 +655,7 @@ namespace KVMote } // ══════════════════════════════════════════════════════════════════════════ - // SECTION 11 — Send methods - // ══════════════════════════════════════════════════════════════════════════ - - private void Send(byte[] data) - { - if (_port is null || !_port.IsOpen || !_userConnected) return; - lock (_sendLock) - { - try { _port.Write(data, 0, data.Length); _timeoutCount = 0; } - catch (TimeoutException) - { - _timeoutCount++; - Log($"Timeout {_timeoutCount}/{MaxTimeouts}"); - if (_timeoutCount >= MaxTimeouts && !_isReconnecting) BeginReconnect(); - } - catch (Exception ex) - { - Log($"Erro: {ex.Message}"); - if (!_isReconnecting) BeginReconnect(); - } - } - } - - private void SendMouse(byte[] data) - { - if (_port is null || !_port.IsOpen || !_userConnected) return; - if (!Monitor.TryEnter(_sendLock)) return; - try { _port.Write(data, 0, data.Length); _timeoutCount = 0; } - catch { } - finally { Monitor.Exit(_sendLock); } - } - - // ══════════════════════════════════════════════════════════════════════════ - // SECTION 12 — Utilities + form closing + // SECTION 10 — Utilities + form closing // ══════════════════════════════════════════════════════════════════════════ private void SetStatus(string msg, System.Drawing.Color color) @@ -663,73 +668,60 @@ namespace KVMote private void SetPortInfo(string msg) { if (InvokeRequired) { Invoke((Action)(() => SetPortInfo(msg))); return; } - lblPortInfo.Text = "Porta: " + msg; + lblPortInfo.Text = msg; } private static void Log(string msg) => Debug.WriteLine($"[KVMote {DateTime.Now:HH:mm:ss.fff}] {msg}"); // ══════════════════════════════════════════════════════════════════════════ - // SECTION 13 — Clipboard text send + layout translation - // Ctrl+V (ou Shift+Ins) em modo cliente: lê clipboard do HOST e digita - // no cliente caractere a caractere via comando 'K' do Arduino. - // - // Keyboard.write(byte) no Arduino usa layout US internamente. - // O PC cliente interpreta o HID keycode com o layout configurado. - // Para PT-BR ABNT2, remapeamos os chars cujo HID keycode difere. + // SECTION 11 — Clipboard text send + layout translation // ══════════════════════════════════════════════════════════════════════════ - private const int MaxClipChars = 300; - - // Tabela PT-BR ABNT2: - // Chave = char desejado no cliente - // Valor = char que Keyboard.write() deve receber para gerar o HID correto - // null = não é possível gerar via Keyboard.write() (char ignorado) - // - // Lógica: Keyboard.write('x') → HID do 'x' em US → cliente PT-BR produz 'y' - // Ex: Keyboard.write('/') → HID 0x38 → PT-BR lê como ';' - // Keyboard.write('?') → HID 0x38+SHIFT → PT-BR lê como ':' - private static readonly Dictionary PtBrMap = new Dictionary + private static readonly System.Collections.Generic.Dictionary PtBrMap = + new System.Collections.Generic.Dictionary { - { ';', '/' }, // HID 0x38 → PT-BR ';' - { ':', '?' }, // HID 0x38+SHIFT → PT-BR ':' - { '[', ']' }, // HID 0x30 → PT-BR '[' - { '{', '}' }, // HID 0x30+SHIFT → PT-BR '{' - { ']', '\\' }, // HID 0x31 → PT-BR ']' - { '}', '|' }, // HID 0x31+SHIFT → PT-BR '}' - // Chars que PT-BR tem em posições sem equivalente no US padrão (ignorados): - { '/', null }, // PT-BR '/' está em HID extra (0x87) — inacessível - { '?', null }, // idem, SHIFT - { '\\', null }, // PT-BR '\' está em HID 0x64 — não existe no US - { '|', null }, // idem, SHIFT - { '@', null }, // PT-BR '@' requer ALTGR+2 + { ';', '/' }, + { ':', '?' }, + { '[', ']' }, + { '{', '}' }, + { ']', '\\' }, + { '}', '|' }, + { '/', null }, + { '?', null }, + { '\\', (char)0xEC }, // KEY_NON_US_BACKSLASH → \ em ABNT2 + { '|', null }, + { '@', null }, }; private byte? TranslateChar(char c) { if (_clientLayout == ClientLayout.PtBrAbnt2 && PtBrMap.TryGetValue(c, out char? mapped)) - return mapped.HasValue ? (byte?)mapped.Value : null; // null = ignorar + return mapped.HasValue ? (byte?)mapped.Value : null; return (byte)c; } - // Chamado via BeginInvoke (thread UI) para acessar Clipboard com segurança private void SendClipboardToClient() { if (!_userConnected) return; + var t = _transport; + if (t is null) return; - string text = Clipboard.GetText(); + string text = System.Windows.Forms.Clipboard.GetText(); if (string.IsNullOrEmpty(text)) return; - if (text.Length > MaxClipChars) + + int maxChars = t.ClipboardMaxChars; + int delayMs = t.ClipboardDelayMs; + + if (text.Length > maxChars) { - SetStatus($"Clipboard ({text.Length} chars) excede {MaxClipChars}. Não enviado.", System.Drawing.Color.Orange); + SetStatus($"Clipboard ({text.Length} chars) excede {maxChars}. Não enviado.", System.Drawing.Color.Orange); return; } Task.Run(() => { - // Solta todas as teclas modificadoras antes de digitar - // (Ctrl pode estar pressionado no Arduino ao interceptar Ctrl+V) - Send(new byte[] { (byte)'A' }); + t.Send(new byte[] { (byte)'A' }); Thread.Sleep(100); int skipped = 0; @@ -744,10 +736,10 @@ namespace KVMote if (!translated.HasValue) { skipped++; continue; } b = translated.Value; } - else { skipped++; continue; } // não-ASCII (é, ã, ç, etc.) + else { skipped++; continue; } - Send(new byte[] { (byte)'K', b }); - Thread.Sleep(20); // ~50 chars/s — seguro para BT 9600 + t.Send(new byte[] { (byte)'K', b }); + Thread.Sleep(delayMs); } string suffix = skipped > 0 ? $" ({skipped} ignorados)" : ""; @@ -764,6 +756,32 @@ namespace KVMote : ClientLayout.US; } + private void btnSendCad_Click(object sender, EventArgs e) => SendCtrlAltDelToClient(); + + private void SendCtrlAltDelToClient() + { + if (!_userConnected) return; + var t = _transport; + if (t is null) return; + + Task.Run(() => + { + // Sequence: Press Ctrl, Press Alt, Press Del, Release Del, Release Alt, Release Ctrl + t.Send(new byte[] { (byte)'P', 0x80 }); // L Ctrl + t.Send(new byte[] { (byte)'P', 0x82 }); // L Alt + t.Send(new byte[] { (byte)'P', 0xD4 }); // Delete + Thread.Sleep(50); + t.Send(new byte[] { (byte)'U', 0xD4 }); + t.Send(new byte[] { (byte)'U', 0x82 }); + t.Send(new byte[] { (byte)'U', 0x80 }); + + SetStatus("Ctrl+Alt+Del enviado", System.Drawing.Color.DodgerBlue); + Thread.Sleep(1500); + if (_userConnected) + SetStatus(_clientMode ? "Modo Cliente \u25cf" : "Modo Host \u25cf Conectado", + _clientMode ? System.Drawing.Color.DodgerBlue : System.Drawing.Color.Green); + }); + } protected override void OnFormClosing(FormClosingEventArgs e) { @@ -773,7 +791,8 @@ namespace KVMote _watchdog.Dispose(); _heartbeat?.Dispose(); UninstallHooks(); - ClosePort(); + _transport?.Disconnect(); + _transport?.Dispose(); base.OnFormClosing(e); } } diff --git a/S3/KVMote_ESP32S3/KVMote_ESP32S3.ino b/S3/KVMote_ESP32S3/KVMote_ESP32S3.ino new file mode 100644 index 0000000..9525d70 --- /dev/null +++ b/S3/KVMote_ESP32S3/KVMote_ESP32S3.ino @@ -0,0 +1,263 @@ +/* + KVMote — ESP32-S3 N16R8 + BLE NUS (Nordic UART Service) → USB HID nativo + + Substitui: Arduino Leonardo + HC-06 + LED RGB externo + LED: WS2812B embutido na placa (GPIO 48 na maioria das DevKit-C1). + Se a sua placa usar outro pino, altere LED_PIN abaixo. + + Protocolo binário: idêntico ao KVMote.ino (Leonardo). + Mouse move → 'M' dx(int8) dy(int8) 3 bytes + Mouse wheel → 'W' delta(int8) 2 bytes + Tecla write → 'K' char 2 bytes + Clique → 'C' 'L'|'R' 2 bytes + Tecla press → 'P' keycode 2 bytes + Tecla release → 'U' keycode 2 bytes + ReleaseAll → 'A' 1 byte + Mouse press → 'D' 'L'|'R' 2 bytes + Mouse release → 'E' 'L'|'R' 2 bytes + LED cliente → 'O' (magenta) + LED host ok → 'H' (azul) + LED sem host → 'G' (verde) + Ping/Pong → '~' → responde [PONG] 1 byte + + Dependências (instale pela Library Manager do Arduino IDE): + - Adafruit NeoPixel (by Adafruit) + Já incluídas no core ESP32: + - USB / USBHIDKeyboard / USBHIDMouse + - BLEDevice / BLEServer / BLE2902 + + Board: "ESP32S3 Dev Module" + USB Mode → Hardware CDC and JTAG ← mantém JTAG para upload via porta COM + USB CDC On Boot → Disabled ← CRÍTICO: libera USB nativo para HID + Upload Mode → Internal USB (ou USB-OTG CDC) + PSRAM → OPI PSRAM (para N16R8) + Flash Size → 16MB + + Conexões: + Porta USB (nativa OTG) → PC cliente (aparece como teclado+mouse HID) + Porta COM (CH343) → PC de desenvolvimento (upload de firmware) + BLE → Host PC (sem fio, KVMote.exe) +*/ + +#include "USB.h" +#include "USBHIDKeyboard.h" +#include "USBHIDMouse.h" +#include +#include +#include +#include +#include + +// ── NUS UUIDs ───────────────────────────────────────────────────────────────── +#define NUS_SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +#define NUS_RX_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // PC escreve aqui +#define NUS_TX_UUID "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // ESP32 notifica (PONG) + +// ── LED WS2812B embutido ────────────────────────────────────────────────────── +#define LED_PIN 48 // GPIO 48 — ESP32-S3-DevKitC-1; altere se necessário +#define LED_COUNT 1 +#define LED_BRIGHTNESS 80 // 0–255 (80 ≈ 30%, evita ofuscar) + +// ── Objetos USB HID ─────────────────────────────────────────────────────────── +USBHIDKeyboard Keyboard; +USBHIDMouse Mouse; + +// ── NeoPixel ────────────────────────────────────────────────────────────────── +Adafruit_NeoPixel pixel(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800); + +void ledCor(uint8_t r, uint8_t g, uint8_t b) { + pixel.setPixelColor(0, pixel.Color(r, g, b)); + pixel.show(); +} + +// ── BLE ─────────────────────────────────────────────────────────────────────── +BLEServer* pServer = nullptr; +BLECharacteristic* pTxChar = nullptr; +bool bleConn = false; + +// ── Fila BLE → HID (desacopla callback BLE do USB TinyUSB) ─────────────────── +// O callback BLE roda numa task FreeRTOS separada. Chamar Mouse.move() / +// Keyboard.press() de lá bloqueia a task BLE esperando o USB. A fila resolve: +// callback só enfileira bytes, loop() drena e chama o HID. +static QueueHandle_t rxQueue; + +// ── Máquina de estados (idêntica ao Leonardo) ───────────────────────────────── +enum Estado : uint8_t { + AGUARDA_CMD, + AGUARDA_MOUSE_DX, + AGUARDA_MOUSE_DY, + AGUARDA_MOUSE_WHEEL, + AGUARDA_TECLA, + AGUARDA_CLIQUE, + AGUARDA_PRESS_KEY, + AGUARDA_RELEASE_KEY, + AGUARDA_MOUSE_PRESS, + AGUARDA_MOUSE_RELEASE +}; + +Estado estado = AGUARDA_CMD; +int8_t pendingDX = 0; + +// ── Processa um byte do protocolo ───────────────────────────────────────────── +// Chamada diretamente do callback BLE (task separada do FreeRTOS). +// As funções HID do ESP32 são thread-safe. +void processaByte(uint8_t b) { + switch (estado) { + + case AGUARDA_CMD: + if (b == 'M') estado = AGUARDA_MOUSE_DX; + else if (b == 'W') estado = AGUARDA_MOUSE_WHEEL; + else if (b == 'K') estado = AGUARDA_TECLA; + else if (b == 'C') estado = AGUARDA_CLIQUE; + else if (b == 'P') estado = AGUARDA_PRESS_KEY; + else if (b == 'U') estado = AGUARDA_RELEASE_KEY; + else if (b == 'D') estado = AGUARDA_MOUSE_PRESS; + else if (b == 'E') estado = AGUARDA_MOUSE_RELEASE; + else if (b == 'A') { Keyboard.releaseAll(); } + else if (b == 'O') { ledCor(255, 0, 255); } // magenta — mouse no cliente + else if (b == 'H') { ledCor( 0, 0, 255); } // azul — host conectado + else if (b == 'G') { ledCor( 0, 255, 0); } // verde — host desconectado + else if (b == '~') { + if (pTxChar && bleConn) { + pTxChar->setValue((uint8_t*)"[PONG]", 6); + pTxChar->notify(); + } + } + break; + + case AGUARDA_MOUSE_DX: + pendingDX = (int8_t)b; + estado = AGUARDA_MOUSE_DY; + break; + + case AGUARDA_MOUSE_DY: + Mouse.move(pendingDX, (int8_t)b, 0); + estado = AGUARDA_CMD; + break; + + case AGUARDA_MOUSE_WHEEL: + Mouse.move(0, 0, (int8_t)b); + estado = AGUARDA_CMD; + break; + + case AGUARDA_TECLA: + Keyboard.write(b); // keycodes >= 0x80 seguem a mesma convenção do Arduino HID + estado = AGUARDA_CMD; + break; + + case AGUARDA_CLIQUE: + if (b == 'L') Mouse.click(MOUSE_LEFT); + if (b == 'R') Mouse.click(MOUSE_RIGHT); + estado = AGUARDA_CMD; + break; + + case AGUARDA_PRESS_KEY: + Keyboard.press(b); + estado = AGUARDA_CMD; + break; + + case AGUARDA_RELEASE_KEY: + Keyboard.release(b); + estado = AGUARDA_CMD; + break; + + case AGUARDA_MOUSE_PRESS: + if (b == 'L') Mouse.press(MOUSE_LEFT); + if (b == 'R') Mouse.press(MOUSE_RIGHT); + estado = AGUARDA_CMD; + break; + + case AGUARDA_MOUSE_RELEASE: + if (b == 'L') Mouse.release(MOUSE_LEFT); + if (b == 'R') Mouse.release(MOUSE_RIGHT); + estado = AGUARDA_CMD; + break; + } +} + +// ── Callback: chegada de dados pelo BLE (PC → ESP32) ───────────────────────── +// Apenas enfileira — não chama HID aqui para não bloquear a task BLE. +class RxCallback : public BLECharacteristicCallbacks { + void onWrite(BLECharacteristic* pChar) override { + String val = pChar->getValue(); + for (int i = 0; i < val.length(); i++) { + uint8_t b = (uint8_t)val[i]; + xQueueSend(rxQueue, &b, 0); // não bloqueia se a fila estiver cheia + } + } +}; + +// ── Callbacks de conexão / desconexão BLE ───────────────────────────────────── +class ServerCallbacks : public BLEServerCallbacks { + void onConnect(BLEServer*) override { + bleConn = true; + // LED permanece verde até o host enviar 'H' + } + void onDisconnect(BLEServer*) override { + bleConn = false; + estado = AGUARDA_CMD; + Keyboard.releaseAll(); + ledCor(0, 255, 0); // verde — aguardando host + BLEDevice::startAdvertising(); // permite reconexão imediata + } +}; + +// ── Setup ───────────────────────────────────────────────────────────────────── +void setup() { + // LED + pixel.begin(); + pixel.setBrightness(LED_BRIGHTNESS); + ledCor(0, 255, 0); // verde — aguardando conexão + + // USB HID (TinyUSB via USB OTG) + USB.productName("KVMote"); + USB.manufacturerName("KVMote"); + USB.begin(); + Keyboard.begin(); + Mouse.begin(); + + // BLE — NUS + BLEDevice::init("KVMote"); + pServer = BLEDevice::createServer(); + pServer->setCallbacks(new ServerCallbacks()); + + BLEService* pService = pServer->createService(NUS_SERVICE_UUID); + + // RX: aceita Write e Write Without Response — sem criptografia (evita AccessDenied no Windows) + BLECharacteristic* pRxChar = pService->createCharacteristic( + NUS_RX_UUID, + BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR + ); + pRxChar->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); + pRxChar->setCallbacks(new RxCallback()); + + // TX: apenas Notify (para [PONG]) — sem criptografia + pTxChar = pService->createCharacteristic( + NUS_TX_UUID, + BLECharacteristic::PROPERTY_NOTIFY + ); + pTxChar->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); + BLE2902* cccd = new BLE2902(); + cccd->setAccessPermissions(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); + pTxChar->addDescriptor(cccd); + + pService->start(); + + rxQueue = xQueueCreate(256, sizeof(uint8_t)); + + BLEAdvertising* pAdv = BLEDevice::getAdvertising(); + pAdv->addServiceUUID(NUS_SERVICE_UUID); + pAdv->setScanResponse(true); + pAdv->setMinPreferred(0x06); // melhora compatibilidade com iOS/Windows + BLEDevice::startAdvertising(); +} + +// ── Loop ────────────────────────────────────────────────────────────────────── +void loop() { + // Drena a fila e processa no contexto do loop (seguro para TinyUSB HID) + uint8_t b; + while (xQueueReceive(rxQueue, &b, 0) == pdTRUE) + processaByte(b); + delay(1); +} diff --git a/Transport/BleTransport.cs b/Transport/BleTransport.cs new file mode 100644 index 0000000..724a1eb --- /dev/null +++ b/Transport/BleTransport.cs @@ -0,0 +1,303 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Storage.Streams; + +namespace KVMote.Transport +{ + internal sealed class BleTransport : IKvmTransport + { + // Nordic UART Service (NUS) — convenção padrão para serial-over-BLE + private static readonly Guid NusServiceUuid = Guid.Parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); + private static readonly Guid NusRxUuid = Guid.Parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"); // PC escreve aqui + private static readonly Guid NusTxUuid = Guid.Parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"); // PC lê (notify) + + private const string AdvertisedName = "KVMote"; + private const int ScanTimeoutMs = 3000; + + private ulong _address; + private BluetoothLEDevice? _device; + private GattDeviceService? _service; + private GattCharacteristic? _rxChar; + private GattCharacteristic? _txChar; + private GattWriteOption _writeOption; + private bool _connected; + private bool _disposed; + + private Channel? _sendChannel; + private CancellationTokenSource? _sendCts; + + public string DeviceLabel => _address != 0 ? "KVMote (BLE)" : "—"; + public bool IsConnected => _connected && + _device?.ConnectionStatus == BluetoothConnectionStatus.Connected; + public int ClipboardMaxChars => 1000; + public int ClipboardDelayMs => 5; + + public event Action? DataReceived; + + // ── Detection ────────────────────────────────────────────────────────── + + public async Task DetectAsync(CancellationToken ct) + { + // Fast path: endereço já conhecido de conexão anterior — não precisa escanear + if (_address != 0) return true; + + // Timeout interno para que o loop de retry em AutoDetectAsync funcione + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(ScanTimeoutMs); + var token = timeoutCts.Token; + + var tcs = new TaskCompletionSource(); + var watcher = new BluetoothLEAdvertisementWatcher + { + ScanningMode = BluetoothLEScanningMode.Active + }; + + watcher.Received += (_, args) => + { + if (args.Advertisement.LocalName == AdvertisedName) + tcs.TrySetResult(args.BluetoothAddress); + }; + + watcher.Start(); + try + { + _address = await tcs.Task.WaitAsync(token); + return true; + } + catch + { + _address = 0; + return false; + } + finally + { + watcher.Stop(); + } + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + public async Task ConnectAsync() + { + try + { + Log("BLE: FromBluetoothAddressAsync..."); + _device = await BluetoothLEDevice.FromBluetoothAddressAsync(_address).AsTask(); + if (_device is null) { Log("BLE: device null"); return false; } + _device.ConnectionStatusChanged += OnConnectionStatusChanged; + + // Aguarda conexão física estabelecer (Windows pode demorar até 3s) + Log("BLE: aguardando ConnectionStatus..."); + for (int i = 0; i < 10; i++) + { + if (_device.ConnectionStatus == BluetoothConnectionStatus.Connected) break; + await System.Threading.Tasks.Task.Delay(300); + } + Log($"BLE: ConnectionStatus={_device.ConnectionStatus}"); + + // Aguarda mais um momento após Connected — Windows ainda finaliza GATT anterior + if (_device.ConnectionStatus == BluetoothConnectionStatus.Connected) + await System.Threading.Tasks.Task.Delay(800); + + // Retry GATT discovery — busca todos os serviços e filtra manualmente + // (GetGattServicesForUuidAsync falha com cache stale no Windows) + for (int attempt = 1; attempt <= 4; attempt++) + { + Log($"BLE: GetGattServicesAsync (tentativa {attempt})..."); + try + { + var all = await _device + .GetGattServicesAsync(BluetoothCacheMode.Uncached) + .AsTask(); + Log($"BLE: GetGattServicesAsync status={all.Status} total={all.Services.Count}"); + if (all.Status == GattCommunicationStatus.Success) + { + foreach (var s in all.Services) + if (s.Uuid == NusServiceUuid) { _service = s; break; } + if (_service is not null) { Log("BLE: NUS encontrado!"); break; } + Log("BLE: NUS não encontrado na lista."); + } + } + catch (Exception ex) + { + Log($"BLE: GATT tentativa {attempt} [{ex.GetType().Name}] hr=0x{ex.HResult:X8}"); + } + await System.Threading.Tasks.Task.Delay(1500); + } + + if (_service is null) return false; + + Log("BLE: GetCharacteristics RX/TX..."); + var rxResult = await _service.GetCharacteristicsForUuidAsync(NusRxUuid).AsTask(); + var txResult = await _service.GetCharacteristicsForUuidAsync(NusTxUuid).AsTask(); + + Log($"BLE: rx={rxResult.Status}/{rxResult.Characteristics.Count} tx={txResult.Status}/{txResult.Characteristics.Count}"); + + // AccessDenied = Windows tem bond stale; desparea, reseta endereço e força + // novo scan BLE na próxima tentativa (evita ciclo de AccessDenied infinito) + if (rxResult.Status == GattCommunicationStatus.AccessDenied || + txResult.Status == GattCommunicationStatus.AccessDenied) + { + Log("BLE: AccessDenied — despareando e resetando endereço..."); + try + { + var devInfo = await Windows.Devices.Enumeration.DeviceInformation + .CreateFromIdAsync(_device.DeviceId).AsTask(); + if (devInfo.Pairing.IsPaired) + { + var r = await devInfo.Pairing.UnpairAsync().AsTask(); + Log($"BLE: Unpair={r.Status}"); + } + } + catch (Exception ex) { Log($"BLE: Unpair ex: {ex.Message}"); } + _address = 0; // força novo scan BLE — evita reuso de bond stale do Windows + Disconnect(); + await System.Threading.Tasks.Task.Delay(2000); // aguarda Windows processar unpair + return false; + } + + if (rxResult.Status != GattCommunicationStatus.Success || + rxResult.Characteristics.Count == 0) return false; + if (txResult.Status != GattCommunicationStatus.Success || + txResult.Characteristics.Count == 0) return false; + + _rxChar = rxResult.Characteristics[0]; + _txChar = txResult.Characteristics[0]; + + // Prefere WriteWithoutResponse (mais rápido) se suportado + _writeOption = _rxChar.CharacteristicProperties + .HasFlag(GattCharacteristicProperties.WriteWithoutResponse) + ? GattWriteOption.WriteWithoutResponse + : GattWriteOption.WriteWithResponse; + + Log("BLE: Subscribing notify..."); + // Inscreve em notificações para receber [PONG] + var cccdStatus = await _txChar + .WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue.Notify) + .AsTask(); + Log($"BLE: cccd={cccdStatus}"); + if (cccdStatus != GattCommunicationStatus.Success) return false; + + _txChar.ValueChanged += OnValueChanged; + _connected = true; + StartSendLoop(); + Log("BLE: Connected OK"); + return true; + } + catch (Exception ex) + { + Log($"BLE: ConnectAsync exception [{ex.GetType().Name}] hr=0x{ex.HResult:X8}: {ex.Message}"); + Disconnect(); + return false; + } + } + + public void Disconnect() + { + _connected = false; + StopSendLoop(); + + if (_txChar is not null) + { + try { _txChar.ValueChanged -= OnValueChanged; } catch { } + _txChar = null; + } + _rxChar = null; + + if (_service is not null) + { + try { _service.Dispose(); } catch { } + _service = null; + } + + if (_device is not null) + { + _device.ConnectionStatusChanged -= OnConnectionStatusChanged; + try { _device.Dispose(); } catch { } + _device = null; + } + } + + // ── Send ─────────────────────────────────────────────────────────────── + + public void Send(byte[] data) => _sendChannel?.Writer.TryWrite(data); + public void SendLossy(byte[] data) => _sendChannel?.Writer.TryWrite(data); + + // ── Send loop (Channel interno — mantém interface síncrona) ──────────── + + private void StartSendLoop() + { + _sendCts = new CancellationTokenSource(); + _sendChannel = Channel.CreateBounded(new BoundedChannelOptions(64) + { + FullMode = BoundedChannelFullMode.DropOldest + }); + + var ct = _sendCts.Token; + _ = Task.Run(async () => + { + await foreach (var data in _sendChannel.Reader.ReadAllAsync(ct)) + { + if (_rxChar is null) break; + try + { + await _rxChar.WriteValueAsync(data.AsBuffer(), _writeOption).AsTask(); + } + catch { break; } + } + }, ct); + } + + private void StopSendLoop() + { + _sendChannel?.Writer.TryComplete(); + _sendCts?.Cancel(); + _sendCts = null; + _sendChannel = null; + } + + // ── Callbacks ────────────────────────────────────────────────────────── + + private void OnValueChanged(GattCharacteristic _, GattValueChangedEventArgs args) + { + var reader = DataReader.FromBuffer(args.CharacteristicValue); + var bytes = new byte[args.CharacteristicValue.Length]; + reader.ReadBytes(bytes); + DataReceived?.Invoke(Encoding.ASCII.GetString(bytes)); + } + + private void OnConnectionStatusChanged(BluetoothLEDevice _, object __) + { + if (_device?.ConnectionStatus == BluetoothConnectionStatus.Disconnected) + _connected = false; + } + + private static readonly string LogFile = + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + "kvmote_ble.log"); + + private static void Log(string msg) + { + string line = $"[BLE {DateTime.Now:HH:mm:ss.fff}] {msg}"; + Debug.WriteLine(line); + try { System.IO.File.AppendAllText(LogFile, line + "\n"); } catch { } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Disconnect(); + } + } +} diff --git a/Transport/IKvmTransport.cs b/Transport/IKvmTransport.cs new file mode 100644 index 0000000..b2d43f7 --- /dev/null +++ b/Transport/IKvmTransport.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace KVMote.Transport +{ + internal interface IKvmTransport : IDisposable + { + /// Scans for the device and stores connection info internally. + Task DetectAsync(CancellationToken ct); + + /// Human-readable device label, e.g. "COM5" or "KVMote (BLE)". + string DeviceLabel { get; } + + /// Connect using info gathered by DetectAsync. + Task ConnectAsync(); + + void Disconnect(); + + bool IsConnected { get; } + + /// Reliable send — keyboard, clicks, LED commands. + void Send(byte[] data); + + /// Lossy send — drops if transport is busy. For mouse move. + void SendLossy(byte[] data); + + event Action DataReceived; + + /// Maximum characters allowed in a single clipboard paste. + int ClipboardMaxChars { get; } + + /// Delay in milliseconds between characters during clipboard paste. + int ClipboardDelayMs { get; } + } +} diff --git a/Transport/SerialTransport.cs b/Transport/SerialTransport.cs new file mode 100644 index 0000000..a22c369 --- /dev/null +++ b/Transport/SerialTransport.cs @@ -0,0 +1,152 @@ +using System; +using System.IO.Ports; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace KVMote.Transport +{ + internal sealed class SerialTransport : IKvmTransport + { + private const int BaudRate = 9600; + private const int MaxTimeouts = 3; + + private SerialPort? _port; + private string _portName = ""; + private readonly object _lock = new(); + private int _timeoutCount; + private bool _disposed; + + public string DeviceLabel => _portName.Length > 0 ? _portName : "—"; + public bool IsConnected => _port?.IsOpen == true; + public int ClipboardMaxChars => 500; + public int ClipboardDelayMs => 8; + + public event Action? DataReceived; + + // ── Detection ────────────────────────────────────────────────────────── + + public Task DetectAsync(CancellationToken ct) => + Task.Run(() => Probe(ct), ct); + + private bool Probe(CancellationToken ct) + { + foreach (string port in SerialPort.GetPortNames()) + { + if (ct.IsCancellationRequested) return false; + try + { + using var sp = new SerialPort(port, BaudRate) + { + WriteTimeout = 200, + ReadTimeout = 600, + Encoding = Encoding.ASCII + }; + sp.Open(); + Thread.Sleep(150); + sp.Write("~"); + Thread.Sleep(500); + string resp = sp.ReadExisting(); + sp.Close(); + if (resp.Contains("[PONG]")) { _portName = port; return true; } + } + catch { } + } + return false; + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + public Task ConnectAsync() => Task.FromResult(ConnectInternal()); + + private bool ConnectInternal() + { + try + { + _port = new SerialPort(_portName, BaudRate) + { + WriteTimeout = 200, + Encoding = Encoding.ASCII + }; + _port.DataReceived += OnDataReceived; + _port.Open(); + _timeoutCount = 0; + return true; + } + catch + { + _port?.Dispose(); + _port = null; + return false; + } + } + + public void Disconnect() + { + if (_port is null) return; + try + { + _port.DataReceived -= OnDataReceived; + if (_port.IsOpen) _port.Close(); + } + catch { } + finally { _port.Dispose(); _port = null; } + } + + // ── Send ─────────────────────────────────────────────────────────────── + + public void Send(byte[] data) + { + if (_port is null || !_port.IsOpen) return; + lock (_lock) + { + try + { + _port.Write(data, 0, data.Length); + _timeoutCount = 0; + } + catch (TimeoutException) + { + if (++_timeoutCount >= MaxTimeouts) MarkDisconnected(); + } + catch + { + MarkDisconnected(); + } + } + } + + public void SendLossy(byte[] data) + { + if (_port is null || !_port.IsOpen) return; + if (!Monitor.TryEnter(_lock)) return; + try { _port.Write(data, 0, data.Length); _timeoutCount = 0; } + catch { } + finally { Monitor.Exit(_lock); } + } + + // ── Private ──────────────────────────────────────────────────────────── + + private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) + { + try + { + string data = _port?.ReadExisting() ?? ""; + if (!string.IsNullOrEmpty(data)) DataReceived?.Invoke(data); + } + catch { } + } + + private void MarkDisconnected() + { + try { _port?.Close(); } catch { } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Disconnect(); + } + } +} diff --git a/KVMote.ino b/Uno/KVMote.ino similarity index 100% rename from KVMote.ino rename to Uno/KVMote.ino