feat: s3 e ctrl+alt+del

This commit is contained in:
Ricardo Carneiro 2026-03-24 23:25:01 -03:00
parent 91e43e73d5
commit edcf14b3eb
9 changed files with 1031 additions and 202 deletions

43
.claude/napkin.md Normal file
View File

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

View File

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>

17
Principal.Designer.cs generated
View File

@ -16,6 +16,7 @@ namespace KVMote
private System.Windows.Forms.Panel pnlContent; private System.Windows.Forms.Panel pnlContent;
private System.Windows.Forms.Label lblLayout; private System.Windows.Forms.Label lblLayout;
private System.Windows.Forms.ComboBox cmbLayout; private System.Windows.Forms.ComboBox cmbLayout;
private System.Windows.Forms.Button btnSendCad;
private void InitializeComponent() private void InitializeComponent()
{ {
@ -23,6 +24,7 @@ namespace KVMote
this.pnlBottom = new System.Windows.Forms.Panel(); this.pnlBottom = new System.Windows.Forms.Panel();
this.lblLayout = new System.Windows.Forms.Label(); this.lblLayout = new System.Windows.Forms.Label();
this.cmbLayout = new System.Windows.Forms.ComboBox(); this.cmbLayout = new System.Windows.Forms.ComboBox();
this.btnSendCad = new System.Windows.Forms.Button();
this.lblPosition = new System.Windows.Forms.Label(); this.lblPosition = new System.Windows.Forms.Label();
this.btnAbove = new System.Windows.Forms.Button(); this.btnAbove = new System.Windows.Forms.Button();
this.btnLeft = 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; 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.Location = new System.Drawing.Point(gx, sepY + 10);
this.lblPortInfo.Size = new System.Drawing.Size(268, 20); this.lblPortInfo.Size = new System.Drawing.Size(268, 20);
this.lblPortInfo.ForeColor = clrSilver; this.lblPortInfo.ForeColor = clrSilver;
@ -152,6 +154,16 @@ namespace KVMote
this.cmbLayout.ForeColor = clrWhite; this.cmbLayout.ForeColor = clrWhite;
this.cmbLayout.SelectedIndexChanged += new System.EventHandler(this.cmbLayout_SelectedIndexChanged); 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.BackColor = System.Drawing.Color.FromArgb(20, 20, 20);
this.pnlContent.Dock = System.Windows.Forms.DockStyle.Fill; this.pnlContent.Dock = System.Windows.Forms.DockStyle.Fill;
this.pnlContent.Controls.Add(this.lblPosition); this.pnlContent.Controls.Add(this.lblPosition);
@ -165,6 +177,7 @@ namespace KVMote
this.pnlContent.Controls.Add(this.btnConnect); this.pnlContent.Controls.Add(this.btnConnect);
this.pnlContent.Controls.Add(this.lblLayout); this.pnlContent.Controls.Add(this.lblLayout);
this.pnlContent.Controls.Add(this.cmbLayout); this.pnlContent.Controls.Add(this.cmbLayout);
this.pnlContent.Controls.Add(this.btnSendCad);
this.pnlBottom.BackColor = System.Drawing.Color.FromArgb(40, 40, 40); this.pnlBottom.BackColor = System.Drawing.Color.FromArgb(40, 40, 40);
this.pnlBottom.Dock = System.Windows.Forms.DockStyle.Bottom; this.pnlBottom.Dock = System.Windows.Forms.DockStyle.Bottom;
this.pnlBottom.Height = 28; this.pnlBottom.Height = 28;
@ -176,7 +189,7 @@ namespace KVMote
this.lblStatus.AutoSize = true; this.lblStatus.AutoSize = true;
this.lblStatus.Location = new System.Drawing.Point(8, 6); 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.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;

View File

@ -1,12 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO.Ports; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using KVMote.Transport;
namespace KVMote 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(ref RECT r);
[DllImport("user32.dll")] private static extern bool ClipCursor(IntPtr zero); [DllImport("user32.dll")] private static extern bool ClipCursor(IntPtr zero);
[DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string? n); [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 POINT { public int x, y; }
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; }
@ -42,7 +43,6 @@ namespace KVMote
} }
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
private const int BaudRate = 9600;
private const int WH_MOUSE_LL = 14; private const int WH_MOUSE_LL = 14;
private const int WH_KEYBOARD_LL = 13; private const int WH_KEYBOARD_LL = 13;
private const int WM_MOUSEMOVE = 0x0200; private const int WM_MOUSEMOVE = 0x0200;
@ -59,7 +59,6 @@ namespace KVMote
private const int ReturnThreshold = 15; private const int ReturnThreshold = 15;
private const int HeartbeatMs = 3000; private const int HeartbeatMs = 3000;
private const int PongTimeoutMs = 9000; private const int PongTimeoutMs = 9000;
private const int MaxTimeouts = 3;
// ── Enum ─────────────────────────────────────────────────────────────────── // ── Enum ───────────────────────────────────────────────────────────────────
private enum ClientPos { None, Left, Right, Above, Below } private enum ClientPos { None, Left, Right, Above, Below }
@ -67,11 +66,9 @@ namespace KVMote
// ── Fields ───────────────────────────────────────────────────────────────── // ── Fields ─────────────────────────────────────────────────────────────────
// Serial // Transport (strategy)
private SerialPort? _port; private IKvmTransport? _transport;
private bool _userConnected, _isReconnecting; private bool _userConnected, _isReconnecting;
private readonly object _sendLock = new();
private int _timeoutCount;
private DateTime _lastPong = DateTime.MinValue; private DateTime _lastPong = DateTime.MinValue;
// Hooks // Hooks
@ -80,7 +77,7 @@ namespace KVMote
// KVM state // KVM state
private bool _clientMode; private bool _clientMode;
private bool _ctrlHeld, _shiftHeld; private bool _ctrlHeld, _shiftHeld, _altHeld;
private ClientLayout _clientLayout = ClientLayout.US; private ClientLayout _clientLayout = ClientLayout.US;
private ClientPos _clientPos = ClientPos.None; private ClientPos _clientPos = ClientPos.None;
private System.Drawing.Point _edgeEntry; private System.Drawing.Point _edgeEntry;
@ -89,7 +86,10 @@ namespace KVMote
private int _pendingDX, _pendingDY; private int _pendingDX, _pendingDY;
private bool _isWarping; private bool _isWarping;
private readonly Stopwatch _mouseThrottle = Stopwatch.StartNew(); 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 // Timers
private readonly System.Windows.Forms.Timer _watchdog = new System.Windows.Forms.Timer { Interval = 2000 }; 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() private async Task AutoDetectAsync()
@ -124,46 +124,78 @@ namespace KVMote
else else
btnConnect.Enabled = false; 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); try { await Task.Delay(2500, token); } catch { return; }
if (found != null) if (!token.IsCancellationRequested && await _transport.DetectAsync(token))
{ {
SetPortInfo($"\u25cf {found} detectado"); SetPortInfo($"\u25cf {_transport.DeviceLabel} detectado");
if (btnConnect.InvokeRequired) if (btnConnect.InvokeRequired)
btnConnect.Invoke((Action)(() => btnConnect.Enabled = true)); btnConnect.Invoke((Action)(() => btnConnect.Enabled = true));
else else
btnConnect.Enabled = true; btnConnect.Enabled = true;
return; 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; } 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<IKvmTransport?> 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<bool>[] tasks = candidates.Select(t => t.DetectAsync(ct)).ToArray();
var pending = new List<Task<bool>>(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) int idx = Array.IndexOf(tasks, done);
{ winner = candidates[idx];
WriteTimeout = 200, raceCts.Cancel(); // cancela o perdedor
ReadTimeout = 600, break;
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;
} }
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 // 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); else Disconnect(true);
} }
private void Connect() private async Task ConnectAsync()
{ {
if (_clientPos == ClientPos.None) if (_clientPos == ClientPos.None)
{ {
@ -218,11 +250,9 @@ namespace KVMote
return; return;
} }
string raw = lblPortInfo.Text.Replace("Porta: ", "").Replace("\u25cf ", "").Replace(" detectado", "").Trim(); if (_transport is null)
string portName = raw.StartsWith("COM") ? raw : "";
if (string.IsNullOrEmpty(portName))
{ {
MessageBox.Show("Nenhuma porta detectada. Clique em Detectar.", "KVMote", MessageBox.Show("Nenhum dispositivo detectado. Clique em Detectar.", "KVMote",
MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBoxButtons.OK, MessageBoxIcon.Warning);
return; return;
} }
@ -230,22 +260,23 @@ namespace KVMote
SetStatus("Conectando...", System.Drawing.Color.Orange); SetStatus("Conectando...", System.Drawing.Color.Orange);
btnConnect.Enabled = false; btnConnect.Enabled = false;
if (OpenPort(portName)) if (await _transport.ConnectAsync())
{ {
_transport.DataReceived += OnTransportData;
_userConnected = true; _userConnected = true;
_timeoutCount = 0;
_lastPong = DateTime.MinValue; _lastPong = DateTime.MinValue;
_watchdog.Start(); _watchdog.Start();
_heartbeat = new System.Threading.Timer(OnHeartbeat, null, HeartbeatMs, HeartbeatMs); _heartbeat = new System.Threading.Timer(OnHeartbeat, null, HeartbeatMs, HeartbeatMs);
InstallHooks(); InstallHooks();
SetConnectedUI(true); 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); SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green);
} }
else else
{ {
SetStatus("Falha na conexão", System.Drawing.Color.Red); SetStatus("Falha na conexão", System.Drawing.Color.Red);
btnConnect.Enabled = true; btnConnect.Enabled = true;
_ = AutoDetectAsync(); // reinicia detecção (ex: BLE resetou endereço após AccessDenied)
} }
} }
@ -258,12 +289,21 @@ namespace KVMote
_watchdog.Stop(); _watchdog.Stop();
_heartbeat?.Dispose(); _heartbeat = null; _heartbeat?.Dispose(); _heartbeat = null;
UninstallHooks(); UninstallHooks();
// Sinaliza ao Arduino antes de fechar (LED verde = sem host)
if (_port?.IsOpen == true) if (_transport is not null)
try { lock (_sendLock) { _port.Write(new byte[] { (byte)'G' }, 0, 1); } } catch { } {
ClosePort(); _transport.DataReceived -= OnTransportData;
if (_transport.IsConnected)
try { _transport.Send(new byte[] { (byte)'G' }); } catch { } // LED verde
_transport.Disconnect();
}
SetConnectedUI(false); 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) 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 if (data.Contains("[PONG]")) _lastPong = DateTime.UtcNow;
{
_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;
}
} }
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) private void OnWatchdog(object? sender, EventArgs e)
{ {
if (!_userConnected || _isReconnecting) return; if (!_userConnected || _isReconnecting) return;
if (_port is null || !_port.IsOpen) BeginReconnect(); if (_transport is null || !_transport.IsConnected) BeginReconnect();
} }
private void OnHeartbeat(object? state) private void OnHeartbeat(object? state)
@ -340,11 +345,7 @@ namespace KVMote
return; return;
} }
lock (_sendLock) _transport?.Send(new byte[] { (byte)'~' });
{
try { _port?.Write("~"); }
catch { BeginReconnect(); }
}
} }
private void BeginReconnect() private void BeginReconnect()
@ -352,22 +353,28 @@ namespace KVMote
if (_isReconnecting) return; if (_isReconnecting) return;
_isReconnecting = true; _isReconnecting = true;
// Garante que o cursor volta imediatamente se estava em modo cliente
if (InvokeRequired) Invoke((Action)(() => ExitClientMode(sendRelease: false))); if (InvokeRequired) Invoke((Action)(() => ExitClientMode(sendRelease: false)));
else ExitClientMode(sendRelease: false); else ExitClientMode(sendRelease: false);
string portName = _port?.PortName ?? "";
SetStatus("Reconectando...", System.Drawing.Color.Orange); SetStatus("Reconectando...", System.Drawing.Color.Orange);
if (InvokeRequired) Invoke((Action)(() => SetConnectedUI(false))); if (InvokeRequired) Invoke((Action)(() => SetConnectedUI(false)));
else SetConnectedUI(false); else SetConnectedUI(false);
ClosePort();
_transport?.Disconnect();
Task.Run(async () => Task.Run(async () =>
{ {
while (_isReconnecting && _userConnected) while (_isReconnecting && _userConnected)
{ {
await Task.Delay(2500); 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; _isReconnecting = false;
_lastPong = DateTime.MinValue; _lastPong = DateTime.MinValue;
@ -381,7 +388,7 @@ namespace KVMote
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// SECTION 7 — Global Hooks // SECTION 6 — Global Hooks
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
private void InstallHooks() 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) private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
@ -421,9 +428,15 @@ namespace KVMote
if (msg == WM_MOUSEMOVE) if (msg == WM_MOUSEMOVE)
{ {
// Ignora o WM_MOUSEMOVE gerado pelo próprio warp para o centro
if (_isWarping) { _isWarping = false; return (IntPtr)1; } 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 dx = info.pt.x - _lastRawPos.X;
int dy = info.pt.y - _lastRawPos.Y; int dy = info.pt.y - _lastRawPos.Y;
@ -434,7 +447,6 @@ namespace KVMote
if (ShouldReturnToHost()) { ExitClientMode(sendRelease: true); return (IntPtr)1; } if (ShouldReturnToHost()) { ExitClientMode(sendRelease: true); return (IntPtr)1; }
// Warp de volta ao centro para obter deltas reais sem ClipCursor
_isWarping = true; _isWarping = true;
SetCursorPos(_lastRawPos.X, _lastRawPos.Y); SetCursorPos(_lastRawPos.X, _lastRawPos.Y);
@ -445,27 +457,26 @@ namespace KVMote
int sdy = Math.Clamp(_pendingDY, -127, 127); int sdy = Math.Clamp(_pendingDY, -127, 127);
_pendingDX = 0; _pendingDX = 0;
_pendingDY = 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; return (IntPtr)1;
} }
if (msg == WM_LBUTTONDOWN) { Send(new byte[] { (byte)'D', (byte)'L' }); return (IntPtr)1; } if (msg == WM_LBUTTONDOWN) { _transport?.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_LBUTTONUP) { _transport?.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_RBUTTONDOWN) { _transport?.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_RBUTTONUP) { _transport?.Send(new byte[] { (byte)'E', (byte)'R' }); return (IntPtr)1; }
if (msg == WM_MOUSEWHEEL) if (msg == WM_MOUSEWHEEL)
{ {
// Acumula deltas: mouse wheel envia ±120/notch, _scrollActive = true;
// touchpad 2 dedos envia valores pequenos (±3..±15). _scrollTimer.Restart();
// Enviamos 1 unidade ao Arduino a cada 120 acumulados.
_wheelAccum += (short)(info.mouseData >> 16); _wheelAccum += (short)(info.mouseData >> 16);
int toSend = _wheelAccum / 120; int toSend = _wheelAccum / 120;
if (toSend != 0) if (toSend != 0)
{ {
_wheelAccum -= toSend * 120; _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; return (IntPtr)1;
} }
@ -505,14 +516,13 @@ namespace KVMote
_pendingDY = 0; _pendingDY = 0;
_mouseThrottle.Restart(); _mouseThrottle.Restart();
// Cursor fica invisível no centro da tela — deltas calculados por warp (técnica FPS)
var s = Screen.PrimaryScreen!.Bounds; var s = Screen.PrimaryScreen!.Bounds;
var center = new System.Drawing.Point(s.Left + s.Width / 2, s.Top + s.Height / 2); var center = new System.Drawing.Point(s.Left + s.Width / 2, s.Top + s.Height / 2);
_lastRawPos = center; _lastRawPos = center;
_isWarping = true; _isWarping = true;
SetCursorPos(center.X, center.Y); SetCursorPos(center.X, center.Y);
Cursor.Hide(); 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); SetStatus("Modo Cliente \u25cf", System.Drawing.Color.DodgerBlue);
} }
@ -536,18 +546,18 @@ namespace KVMote
}; };
SetCursorPos(ret.X, ret.Y); SetCursorPos(ret.X, ret.Y);
if (sendRelease) Send(new byte[] { (byte)'A' }); if (sendRelease) _transport?.Send(new byte[] { (byte)'A' });
Send(new byte[] { (byte)'H' }); // flash verde + LED azul no Arduino _transport?.Send(new byte[] { (byte)'H' }); // LED azul
SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green); 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) private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{ {
if (nCode < 0 || !_userConnected || !_clientMode) if (nCode < 0 || !_userConnected)
return CallNextHookEx(_keyHook, nCode, wParam, lParam); return CallNextHookEx(_keyHook, nCode, wParam, lParam);
int msg = (int)wParam; int msg = (int)wParam;
@ -560,32 +570,60 @@ namespace KVMote
var info = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam); var info = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
uint vk = info.vk; 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 == 0xA2 || vk == 0xA3 || vk == 0x11) _ctrlHeld = isDown;
if (vk == 0xA0 || vk == 0xA1 || vk == 0x10) _shiftHeld = 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 (isDown)
{ {
if ((vk == 0x56 && _ctrlHeld) || (vk == 0x2D && _shiftHeld)) 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; return (IntPtr)1;
} }
} }
byte? code = VkToArduino(vk); byte? code = VkToArduino(vk);
if (code.HasValue) 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; return (IntPtr)1;
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// SECTION 10 — VK → Arduino keycode mapping // SECTION 9 — VK → Arduino keycode mapping
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
private static readonly Dictionary<uint, byte> KeyMap = new Dictionary<uint, byte> private static readonly System.Collections.Generic.Dictionary<uint, byte> KeyMap =
new System.Collections.Generic.Dictionary<uint, byte>
{ {
{ 0xA0, 0x81 }, { 0xA1, 0x85 }, { 0xA2, 0x80 }, { 0xA3, 0x84 }, { 0xA0, 0x81 }, { 0xA1, 0x85 }, { 0xA2, 0x80 }, { 0xA3, 0x84 },
{ 0xA4, 0x82 }, { 0xA5, 0x86 }, { 0x5B, 0x83 }, { 0x5C, 0x87 }, { 0xA4, 0x82 }, { 0xA5, 0x86 }, { 0x5B, 0x83 }, { 0x5C, 0x87 },
@ -617,40 +655,7 @@ namespace KVMote
} }
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// SECTION 11 — Send methods // SECTION 10 — Utilities + form closing
// ══════════════════════════════════════════════════════════════════════════
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
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
private void SetStatus(string msg, System.Drawing.Color color) private void SetStatus(string msg, System.Drawing.Color color)
@ -663,73 +668,60 @@ namespace KVMote
private void SetPortInfo(string msg) private void SetPortInfo(string msg)
{ {
if (InvokeRequired) { Invoke((Action)(() => SetPortInfo(msg))); return; } if (InvokeRequired) { Invoke((Action)(() => SetPortInfo(msg))); return; }
lblPortInfo.Text = "Porta: " + msg; lblPortInfo.Text = msg;
} }
private static void Log(string msg) => private static void Log(string msg) =>
Debug.WriteLine($"[KVMote {DateTime.Now:HH:mm:ss.fff}] {msg}"); Debug.WriteLine($"[KVMote {DateTime.Now:HH:mm:ss.fff}] {msg}");
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// SECTION 13 — Clipboard text send + layout translation // SECTION 11 — 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.
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
private const int MaxClipChars = 300; private static readonly System.Collections.Generic.Dictionary<char, char?> PtBrMap =
new System.Collections.Generic.Dictionary<char, char?>
// 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<char, char?> PtBrMap = new Dictionary<char, char?>
{ {
{ ';', '/' }, // 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 },
{ '/', null }, // PT-BR '/' está em HID extra (0x87) — inacessível { '?', null },
{ '?', null }, // idem, SHIFT { '\\', (char)0xEC }, // KEY_NON_US_BACKSLASH → \ em ABNT2
{ '\\', null }, // PT-BR '\' está em HID 0x64 — não existe no US { '|', null },
{ '|', null }, // idem, SHIFT { '@', null },
{ '@', null }, // PT-BR '@' requer ALTGR+2
}; };
private byte? TranslateChar(char c) private byte? TranslateChar(char c)
{ {
if (_clientLayout == ClientLayout.PtBrAbnt2 && PtBrMap.TryGetValue(c, out char? mapped)) 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; return (byte)c;
} }
// Chamado via BeginInvoke (thread UI) para acessar Clipboard com segurança
private void SendClipboardToClient() private void SendClipboardToClient()
{ {
if (!_userConnected) return; 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 (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; return;
} }
Task.Run(() => Task.Run(() =>
{ {
// Solta todas as teclas modificadoras antes de digitar t.Send(new byte[] { (byte)'A' });
// (Ctrl pode estar pressionado no Arduino ao interceptar Ctrl+V)
Send(new byte[] { (byte)'A' });
Thread.Sleep(100); Thread.Sleep(100);
int skipped = 0; int skipped = 0;
@ -744,10 +736,10 @@ namespace KVMote
if (!translated.HasValue) { skipped++; continue; } if (!translated.HasValue) { skipped++; continue; }
b = translated.Value; b = translated.Value;
} }
else { skipped++; continue; } // não-ASCII (é, ã, ç, etc.) else { skipped++; continue; }
Send(new byte[] { (byte)'K', b }); t.Send(new byte[] { (byte)'K', b });
Thread.Sleep(20); // ~50 chars/s — seguro para BT 9600 Thread.Sleep(delayMs);
} }
string suffix = skipped > 0 ? $" ({skipped} ignorados)" : ""; string suffix = skipped > 0 ? $" ({skipped} ignorados)" : "";
@ -764,6 +756,32 @@ namespace KVMote
: ClientLayout.US; : 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) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
@ -773,7 +791,8 @@ namespace KVMote
_watchdog.Dispose(); _watchdog.Dispose();
_heartbeat?.Dispose(); _heartbeat?.Dispose();
UninstallHooks(); UninstallHooks();
ClosePort(); _transport?.Disconnect();
_transport?.Dispose();
base.OnFormClosing(e); base.OnFormClosing(e);
} }
} }

View File

@ -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)
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 <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Adafruit_NeoPixel.h>
// ── 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 // 0255 (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);
}

303
Transport/BleTransport.cs Normal file
View File

@ -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<byte[]>? _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<string>? DataReceived;
// ── Detection ──────────────────────────────────────────────────────────
public async Task<bool> 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<ulong>();
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<bool> 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<byte[]>(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();
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace KVMote.Transport
{
internal interface IKvmTransport : IDisposable
{
/// <summary>Scans for the device and stores connection info internally.</summary>
Task<bool> DetectAsync(CancellationToken ct);
/// <summary>Human-readable device label, e.g. "COM5" or "KVMote (BLE)".</summary>
string DeviceLabel { get; }
/// <summary>Connect using info gathered by DetectAsync.</summary>
Task<bool> ConnectAsync();
void Disconnect();
bool IsConnected { get; }
/// <summary>Reliable send — keyboard, clicks, LED commands.</summary>
void Send(byte[] data);
/// <summary>Lossy send — drops if transport is busy. For mouse move.</summary>
void SendLossy(byte[] data);
event Action<string> DataReceived;
/// <summary>Maximum characters allowed in a single clipboard paste.</summary>
int ClipboardMaxChars { get; }
/// <summary>Delay in milliseconds between characters during clipboard paste.</summary>
int ClipboardDelayMs { get; }
}
}

View File

@ -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<string>? DataReceived;
// ── Detection ──────────────────────────────────────────────────────────
public Task<bool> 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<bool> 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();
}
}
}