diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4470c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Build output +bin/ +obj/ + +# NuGet +*.nupkg +*.snupkg +packages/ +project.lock.json +project.fragment.lock.json +artifacts/ + +# Publish output +publish/ + +# Roslyn / analyzers +*.aps + +# OS +Thumbs.db +Desktop.ini +.DS_Store + +# Claude Code settings (local only) +.claude/settings.local.json diff --git a/KVMote.csproj b/KVMote.csproj new file mode 100644 index 0000000..82e1b17 --- /dev/null +++ b/KVMote.csproj @@ -0,0 +1,18 @@ + + + + WinExe + net8.0-windows + enable + true + disable + KVMote + KVMote + true + + + + + + + diff --git a/KVMote.ino b/KVMote.ino new file mode 100644 index 0000000..a622d00 --- /dev/null +++ b/KVMote.ino @@ -0,0 +1,183 @@ +/* + KVMote — Arduino Leonardo (Serial1 = HC-06) + + Baud rate: 9600 (padrão de fábrica do HC-06). + Se você já configurou o HC-06 para outra velocidade via AT commands, + altere a constante abaixo. + + Protocolo binário: + 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' (laranja — entrou no cliente) + LED host → 'H' (flash verde + volta azul — voltou ao host) + Ping/Pong → '~' → responde [PONG] 1 byte +*/ + +#include +#include + +// ── Pinos do LED RGB ────────────────────────────────────────────────────────── +#define PIN_R 5 +#define PIN_G 6 +#define PIN_B 9 + +#define BAUD_HC06 9600 // ← altere se necessário + +// ── Cor base do LED (muda conforme o modo) ──────────────────────────────────── +uint8_t basR = 0, basG = 0, basB = 255; // azul = idle + +// ── Máquina de estados ──────────────────────────────────────────────────────── +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; + +// ── Helpers de LED ──────────────────────────────────────────────────────────── +void ledCor(int r, int g, int b) { + analogWrite(PIN_R, r); + analogWrite(PIN_G, g); + analogWrite(PIN_B, b); +} + +void piscaVerde() { + ledCor(0, 200, 0); + delay(40); + ledCor(basR, basG, basB); // retorna à cor base atual +} + +void piscaCiano() { + ledCor(0, 200, 200); + delay(40); + ledCor(basR, basG, basB); // retorna à cor base atual +} + +// ── Setup ───────────────────────────────────────────────────────────────────── +void setup() { + Serial1.begin(BAUD_HC06); + Keyboard.begin(); + Mouse.begin(); + ledCor(basR, basG, basB); // azul = pronto +} + +// ── Loop principal (non-blocking) ───────────────────────────────────────────── +void loop() { + while (Serial1.available() > 0) { + byte b = (byte)Serial1.read(); + + switch (estado) { + + // ── Aguarda o byte de comando ────────────────────────────────────────── + 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(); + piscaVerde(); + } + else if (b == 'O') { + // Entrou no PC cliente → LED laranja + basR = 255; basG = 80; basB = 0; + ledCor(basR, basG, basB); + } + else if (b == 'H') { + // Voltou ao host → flash verde, LED azul + basR = 0; basG = 0; basB = 255; + piscaVerde(); + } + else if (b == '~') { + Serial1.print("[PONG]"); + piscaCiano(); + } + // qualquer outro byte é silenciosamente descartado + break; + + // ── Mouse: lê DeltaX ────────────────────────────────────────────────── + case AGUARDA_MOUSE_DX: + pendingDX = (int8_t)b; + estado = AGUARDA_MOUSE_DY; + break; + + // ── Mouse: lê DeltaY e executa o movimento ──────────────────────────── + case AGUARDA_MOUSE_DY: + Mouse.move(pendingDX, (int8_t)b, 0); + estado = AGUARDA_CMD; + // Sem piscaVerde() aqui — delay(40) bloquearia o processamento serial + break; + + // ── Mouse: roda do mouse ────────────────────────────────────────────── + case AGUARDA_MOUSE_WHEEL: + Mouse.move(0, 0, (int8_t)b); + estado = AGUARDA_CMD; + break; + + // ── Teclado: lê o caractere e digita (write) ────────────────────────── + case AGUARDA_TECLA: + Keyboard.write(b); + estado = AGUARDA_CMD; + piscaVerde(); + break; + + // ── Clique: lê L ou R e clica ───────────────────────────────────────── + case AGUARDA_CLIQUE: + if (b == 'L') Mouse.click(MOUSE_LEFT); + if (b == 'R') Mouse.click(MOUSE_RIGHT); + estado = AGUARDA_CMD; + piscaVerde(); + break; + + // ── Tecla press (mantém pressionada) ───────────────────────────────── + case AGUARDA_PRESS_KEY: + Keyboard.press(b); + estado = AGUARDA_CMD; + piscaVerde(); + break; + + // ── Tecla release ───────────────────────────────────────────────────── + case AGUARDA_RELEASE_KEY: + Keyboard.release(b); + estado = AGUARDA_CMD; + piscaVerde(); + break; + + // ── Mouse press (botão segurado) ────────────────────────────────────── + case AGUARDA_MOUSE_PRESS: + if (b == 'L') Mouse.press(MOUSE_LEFT); + if (b == 'R') Mouse.press(MOUSE_RIGHT); + estado = AGUARDA_CMD; + piscaVerde(); + break; + + // ── Mouse release ───────────────────────────────────────────────────── + case AGUARDA_MOUSE_RELEASE: + if (b == 'L') Mouse.release(MOUSE_LEFT); + if (b == 'R') Mouse.release(MOUSE_RIGHT); + estado = AGUARDA_CMD; + piscaVerde(); + break; + } + } +} diff --git a/KVMote.sln b/KVMote.sln new file mode 100644 index 0000000..505b780 --- /dev/null +++ b/KVMote.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KVMote", "KVMote.csproj", "{8F708910-2F3D-45A6-A6C3-2C0FE3FFEFA6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F708910-2F3D-45A6-A6C3-2C0FE3FFEFA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F708910-2F3D-45A6-A6C3-2C0FE3FFEFA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F708910-2F3D-45A6-A6C3-2C0FE3FFEFA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F708910-2F3D-45A6-A6C3-2C0FE3FFEFA6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {94B06ABA-55BF-4DF3-B345-2CD4636F98B2} + EndGlobalSection +EndGlobal diff --git a/Principal.Designer.cs b/Principal.Designer.cs new file mode 100644 index 0000000..1c2944b --- /dev/null +++ b/Principal.Designer.cs @@ -0,0 +1,197 @@ +namespace KVMote +{ + partial class Principal + { + private System.Windows.Forms.Button btnAbove; + private System.Windows.Forms.Button btnLeft; + private System.Windows.Forms.Button btnRight; + private System.Windows.Forms.Button btnBelow; + private System.Windows.Forms.Label lblHost; + private System.Windows.Forms.Label lblPosition; + private System.Windows.Forms.Label lblPortInfo; + private System.Windows.Forms.Button btnDetect; + private System.Windows.Forms.Button btnConnect; + private System.Windows.Forms.Panel pnlBottom; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.Panel pnlContent; + private System.Windows.Forms.Label lblLayout; + private System.Windows.Forms.ComboBox cmbLayout; + + private void InitializeComponent() + { + this.pnlContent = new System.Windows.Forms.Panel(); + this.pnlBottom = new System.Windows.Forms.Panel(); + this.lblLayout = new System.Windows.Forms.Label(); + this.cmbLayout = new System.Windows.Forms.ComboBox(); + this.lblPosition = new System.Windows.Forms.Label(); + this.btnAbove = new System.Windows.Forms.Button(); + this.btnLeft = new System.Windows.Forms.Button(); + this.lblHost = new System.Windows.Forms.Label(); + this.btnRight = new System.Windows.Forms.Button(); + this.btnBelow = new System.Windows.Forms.Button(); + this.lblPortInfo = new System.Windows.Forms.Label(); + this.btnDetect = new System.Windows.Forms.Button(); + this.btnConnect = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + + this.pnlContent.SuspendLayout(); + this.pnlBottom.SuspendLayout(); + this.SuspendLayout(); + + var clrNormal = System.Drawing.Color.FromArgb(55, 55, 55); + var clrBorder = System.Drawing.Color.FromArgb(90, 90, 90); + var clrSilver = System.Drawing.Color.Silver; + var clrWhite = System.Drawing.Color.White; + var fntUI = new System.Drawing.Font("Segoe UI", 9f); + var fntBold = new System.Drawing.Font("Segoe UI", 9f, System.Drawing.FontStyle.Bold); + + int gx = 56, gy = 60; + int cw = 84, ch = 32, cg = 8; + + this.lblPosition.Text = "Posição do PC Cliente:"; + this.lblPosition.ForeColor = clrSilver; + this.lblPosition.Font = fntUI; + this.lblPosition.AutoSize = true; + this.lblPosition.Location = new System.Drawing.Point(gx, 22); + + this.btnAbove.Name = "btnAbove"; + this.btnAbove.Text = "Acima"; + this.btnAbove.Location = new System.Drawing.Point(gx + cw + cg, gy); + this.btnAbove.Size = new System.Drawing.Size(cw, ch); + this.btnAbove.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnAbove.BackColor = clrNormal; + this.btnAbove.ForeColor = clrSilver; + this.btnAbove.Font = fntUI; + this.btnAbove.FlatAppearance.BorderColor = clrBorder; + this.btnAbove.Click += new System.EventHandler(this.btnPosition_Click); + + this.btnLeft.Name = "btnLeft"; + this.btnLeft.Text = "Esquerda"; + this.btnLeft.Location = new System.Drawing.Point(gx, gy + ch + cg); + this.btnLeft.Size = new System.Drawing.Size(cw, ch); + this.btnLeft.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnLeft.BackColor = clrNormal; + this.btnLeft.ForeColor = clrSilver; + this.btnLeft.Font = fntUI; + this.btnLeft.FlatAppearance.BorderColor = clrBorder; + this.btnLeft.Click += new System.EventHandler(this.btnPosition_Click); + + this.lblHost.Text = "[HOST PC]"; + this.lblHost.Location = new System.Drawing.Point(gx + cw + cg, gy + ch + cg); + this.lblHost.Size = new System.Drawing.Size(cw, ch); + this.lblHost.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + this.lblHost.BackColor = System.Drawing.Color.FromArgb(30, 60, 100); + this.lblHost.ForeColor = clrWhite; + this.lblHost.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.lblHost.Font = new System.Drawing.Font("Segoe UI", 8f, System.Drawing.FontStyle.Bold); + + this.btnRight.Name = "btnRight"; + this.btnRight.Text = "Direita"; + this.btnRight.Location = new System.Drawing.Point(gx + (cw + cg) * 2, gy + ch + cg); + this.btnRight.Size = new System.Drawing.Size(cw, ch); + this.btnRight.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnRight.BackColor = clrNormal; + this.btnRight.ForeColor = clrSilver; + this.btnRight.Font = fntUI; + this.btnRight.FlatAppearance.BorderColor = clrBorder; + this.btnRight.Click += new System.EventHandler(this.btnPosition_Click); + + this.btnBelow.Name = "btnBelow"; + this.btnBelow.Text = "Abaixo"; + this.btnBelow.Location = new System.Drawing.Point(gx + cw + cg, gy + (ch + cg) * 2); + this.btnBelow.Size = new System.Drawing.Size(cw, ch); + this.btnBelow.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnBelow.BackColor = clrNormal; + this.btnBelow.ForeColor = clrSilver; + this.btnBelow.Font = fntUI; + this.btnBelow.FlatAppearance.BorderColor = clrBorder; + this.btnBelow.Click += new System.EventHandler(this.btnPosition_Click); + + int sepY = gy + (ch + cg) * 3 + 4; + + this.lblPortInfo.Text = "Porta: detectando..."; + this.lblPortInfo.Location = new System.Drawing.Point(gx, sepY + 10); + this.lblPortInfo.Size = new System.Drawing.Size(268, 20); + this.lblPortInfo.ForeColor = clrSilver; + this.lblPortInfo.Font = fntUI; + + this.btnDetect.Text = "Detectar"; + this.btnDetect.Location = new System.Drawing.Point(gx, sepY + 36); + this.btnDetect.Size = new System.Drawing.Size(100, 30); + this.btnDetect.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnDetect.BackColor = System.Drawing.Color.FromArgb(65, 65, 65); + this.btnDetect.ForeColor = clrWhite; + this.btnDetect.Font = fntUI; + this.btnDetect.FlatAppearance.BorderColor = clrBorder; + this.btnDetect.Click += new System.EventHandler(this.btnDetect_Click); + + this.btnConnect.Text = "Conectar"; + this.btnConnect.Location = new System.Drawing.Point(gx + 108, sepY + 36); + this.btnConnect.Size = new System.Drawing.Size(160, 30); + this.btnConnect.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnConnect.BackColor = System.Drawing.Color.FromArgb(0, 122, 204); + this.btnConnect.ForeColor = clrWhite; + this.btnConnect.Font = fntBold; + this.btnConnect.FlatAppearance.BorderColor = System.Drawing.Color.FromArgb(0, 100, 180); + this.btnConnect.Click += new System.EventHandler(this.btnConnect_Click); + + this.lblLayout.Text = "Layout do cliente:"; + this.lblLayout.ForeColor = clrSilver; + this.lblLayout.Font = fntUI; + this.lblLayout.AutoSize = true; + this.lblLayout.Location = new System.Drawing.Point(gx, sepY + 78); + + this.cmbLayout.Items.AddRange(new object[] { "US / Internacional", "PT-BR ABNT2" }); + this.cmbLayout.SelectedIndex = 0; + this.cmbLayout.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbLayout.Location = new System.Drawing.Point(gx + 120, sepY + 74); + this.cmbLayout.Size = new System.Drawing.Size(148, 22); + this.cmbLayout.Font = fntUI; + this.cmbLayout.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.cmbLayout.BackColor = System.Drawing.Color.FromArgb(40, 40, 40); + this.cmbLayout.ForeColor = clrWhite; + this.cmbLayout.SelectedIndexChanged += new System.EventHandler(this.cmbLayout_SelectedIndexChanged); + + this.pnlContent.BackColor = System.Drawing.Color.FromArgb(20, 20, 20); + this.pnlContent.Dock = System.Windows.Forms.DockStyle.Fill; + this.pnlContent.Controls.Add(this.lblPosition); + this.pnlContent.Controls.Add(this.btnAbove); + this.pnlContent.Controls.Add(this.btnLeft); + this.pnlContent.Controls.Add(this.lblHost); + this.pnlContent.Controls.Add(this.btnRight); + this.pnlContent.Controls.Add(this.btnBelow); + this.pnlContent.Controls.Add(this.lblPortInfo); + this.pnlContent.Controls.Add(this.btnDetect); + this.pnlContent.Controls.Add(this.btnConnect); + this.pnlContent.Controls.Add(this.lblLayout); + this.pnlContent.Controls.Add(this.cmbLayout); + this.pnlBottom.BackColor = System.Drawing.Color.FromArgb(40, 40, 40); + this.pnlBottom.Dock = System.Windows.Forms.DockStyle.Bottom; + this.pnlBottom.Height = 28; + this.pnlBottom.Controls.Add(this.lblStatus); + + this.lblStatus.Text = "Desconectado"; + this.lblStatus.ForeColor = System.Drawing.Color.Gray; + this.lblStatus.Font = fntUI; + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(8, 6); + + int contentH = sepY + 74 + 22 + 16; + + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; + this.BackColor = System.Drawing.Color.FromArgb(20, 20, 20); + this.ClientSize = new System.Drawing.Size(380, contentH + 28); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Text = "KVMote \u2014 KVM over Bluetooth"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Controls.Add(this.pnlContent); + this.Controls.Add(this.pnlBottom); + + this.pnlContent.ResumeLayout(false); + this.pnlBottom.ResumeLayout(false); + this.pnlBottom.PerformLayout(); + this.ResumeLayout(false); + } + } +} diff --git a/Principal.cs b/Principal.cs new file mode 100644 index 0000000..d79663f --- /dev/null +++ b/Principal.cs @@ -0,0 +1,764 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Ports; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace KVMote +{ + public partial class Principal : Form + { + // ── P/Invoke ─────────────────────────────────────────────────────────────── + private delegate IntPtr LowLevelProc(int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] private static extern IntPtr SetWindowsHookEx(int id, LowLevelProc fn, IntPtr mod, uint tid); + [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(IntPtr h); + [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr h, int n, IntPtr w, IntPtr l); + [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); + [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); + + [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 MSLLHOOKSTRUCT + { + public POINT pt; + public uint mouseData, flags, time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KBDLLHOOKSTRUCT + { + public uint vk, sc, flags, time; + public IntPtr dwExtraInfo; + } + + // ── 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; + private const int WM_LBUTTONDOWN = 0x0201; + private const int WM_LBUTTONUP = 0x0202; + private const int WM_RBUTTONDOWN = 0x0204; + private const int WM_RBUTTONUP = 0x0205; + private const int WM_KEYDOWN = 0x0100; + private const int WM_KEYUP = 0x0101; + private const int WM_SYSKEYDOWN = 0x0104; + private const int WM_SYSKEYUP = 0x0105; + private const int WM_MOUSEWHEEL = 0x020A; + private const int MouseThrottleMs = 50; + 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 } + private enum ClientLayout { US, PtBrAbnt2 } + + // ── Fields ───────────────────────────────────────────────────────────────── + + // Serial + private SerialPort? _port; + private bool _userConnected, _isReconnecting; + private readonly object _sendLock = new(); + private int _timeoutCount; + private DateTime _lastPong = DateTime.MinValue; + + // Hooks + private LowLevelProc? _mouseProc, _keyProc; + private IntPtr _mouseHook, _keyHook; + + // KVM state + private bool _clientMode; + private bool _ctrlHeld, _shiftHeld; + private ClientLayout _clientLayout = ClientLayout.US; + private ClientPos _clientPos = ClientPos.None; + private System.Drawing.Point _edgeEntry; + private System.Drawing.Point _lastRawPos; + private int _virtualX, _virtualY; + private int _pendingDX, _pendingDY; + private bool _isWarping; + private readonly Stopwatch _mouseThrottle = Stopwatch.StartNew(); + + // Timers + private readonly System.Windows.Forms.Timer _watchdog = new System.Windows.Forms.Timer { Interval = 2000 }; + private System.Threading.Timer? _heartbeat; + + // Auto-detect + private CancellationTokenSource? _detectCts; + + // ── Constructor ──────────────────────────────────────────────────────────── + public Principal() + { + InitializeComponent(); + _watchdog.Tick += OnWatchdog; + SetStatus("Desconectado", System.Drawing.Color.Gray); + btnConnect.Enabled = false; + _ = AutoDetectAsync(); + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 1 — Auto-detect COM + // ══════════════════════════════════════════════════════════════════════════ + + private async Task AutoDetectAsync() + { + _detectCts?.Cancel(); + _detectCts = new CancellationTokenSource(); + var token = _detectCts.Token; + + SetPortInfo("Detectando..."); + if (btnConnect.InvokeRequired) + btnConnect.Invoke((Action)(() => btnConnect.Enabled = false)); + else + btnConnect.Enabled = false; + + while (!token.IsCancellationRequested) + { + string? found = await Task.Run(() => ProbePorts(), token); + if (found != null) + { + SetPortInfo($"\u25cf {found} detectado"); + if (btnConnect.InvokeRequired) + btnConnect.Invoke((Action)(() => btnConnect.Enabled = true)); + else + btnConnect.Enabled = true; + return; + } + SetPortInfo("Nenhuma porta encontrada..."); + try { await Task.Delay(3000, token); } catch { return; } + } + } + + private string? ProbePorts() + { + foreach (string port in SerialPort.GetPortNames()) + { + 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]")) return port; + } + catch { } + } + return null; + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 2 — Position selector + // ══════════════════════════════════════════════════════════════════════════ + + private void btnPosition_Click(object sender, EventArgs e) + { + if (sender is not Button btn) return; + + var normal = System.Drawing.Color.FromArgb(55, 55, 55); + btnAbove.BackColor = normal; + btnLeft.BackColor = normal; + btnRight.BackColor = normal; + btnBelow.BackColor = normal; + + btn.BackColor = System.Drawing.Color.FromArgb(0, 122, 204); + btn.ForeColor = System.Drawing.Color.White; + + _clientPos = btn.Name switch + { + "btnAbove" => ClientPos.Above, + "btnLeft" => ClientPos.Left, + "btnRight" => ClientPos.Right, + "btnBelow" => ClientPos.Below, + _ => ClientPos.None + }; + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 3 — Detect button + // ══════════════════════════════════════════════════════════════════════════ + + private void btnDetect_Click(object sender, EventArgs e) => _ = AutoDetectAsync(); + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 4 — Connect / Disconnect + // ══════════════════════════════════════════════════════════════════════════ + + private void btnConnect_Click(object sender, EventArgs e) + { + if (!_userConnected) Connect(); + else Disconnect(true); + } + + private void Connect() + { + if (_clientPos == ClientPos.None) + { + MessageBox.Show("Selecione a posição do PC Cliente.", "KVMote", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + string raw = lblPortInfo.Text.Replace("Porta: ", "").Replace("\u25cf ", "").Replace(" detectado", "").Trim(); + string portName = raw.StartsWith("COM") ? raw : ""; + if (string.IsNullOrEmpty(portName)) + { + MessageBox.Show("Nenhuma porta detectada. Clique em Detectar.", "KVMote", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + SetStatus("Conectando...", System.Drawing.Color.Orange); + btnConnect.Enabled = false; + + if (OpenPort(portName)) + { + _userConnected = true; + _timeoutCount = 0; + _lastPong = DateTime.MinValue; + _watchdog.Start(); + _heartbeat = new System.Threading.Timer(OnHeartbeat, null, HeartbeatMs, HeartbeatMs); + InstallHooks(); + SetConnectedUI(true); + SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green); + } + else + { + SetStatus("Falha na conexão", System.Drawing.Color.Red); + btnConnect.Enabled = true; + } + } + + private void Disconnect(bool userInitiated) + { + ExitClientMode(sendRelease: false); + _detectCts?.Cancel(); + _userConnected = false; + _isReconnecting = false; + _watchdog.Stop(); + _heartbeat?.Dispose(); _heartbeat = null; + UninstallHooks(); + ClosePort(); + SetConnectedUI(false); + if (userInitiated) SetStatus("Desconectado", System.Drawing.Color.Gray); + } + + private void SetConnectedUI(bool connected) + { + if (InvokeRequired) { Invoke((Action)(() => SetConnectedUI(connected))); return; } + btnConnect.Text = connected ? "Desconectar" : "Conectar"; + btnConnect.Enabled = true; + btnDetect.Enabled = !connected; + btnAbove.Enabled = !connected; + btnLeft.Enabled = !connected; + btnRight.Enabled = !connected; + btnBelow.Enabled = !connected; + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 5 — Port management + // ══════════════════════════════════════════════════════════════════════════ + + private bool OpenPort(string name) + { + 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; + } + } + + 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(); + } + + private void OnHeartbeat(object? state) + { + if (!_userConnected || _isReconnecting) return; + + if (_lastPong != DateTime.MinValue && + (DateTime.UtcNow - _lastPong).TotalMilliseconds > PongTimeoutMs) + { + Log("PONG timeout. Reconectando..."); + BeginReconnect(); + return; + } + + lock (_sendLock) + { + try { _port?.Write("~"); } + catch { BeginReconnect(); } + } + } + + private void BeginReconnect() + { + if (_isReconnecting) return; + _isReconnecting = true; + string portName = _port?.PortName ?? ""; + SetStatus("Reconectando...", System.Drawing.Color.Orange); + if (InvokeRequired) Invoke((Action)(() => SetConnectedUI(false))); + else SetConnectedUI(false); + ClosePort(); + + Task.Run(async () => + { + while (_isReconnecting && _userConnected) + { + await Task.Delay(2500); + if (OpenPort(portName)) + { + _isReconnecting = false; + _lastPong = DateTime.MinValue; + SetStatus("Reconectado", System.Drawing.Color.Green); + if (InvokeRequired) Invoke((Action)(() => SetConnectedUI(true))); + else SetConnectedUI(true); + return; + } + } + }); + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 7 — Global Hooks + // ══════════════════════════════════════════════════════════════════════════ + + private void InstallHooks() + { + _mouseProc = MouseHookCallback; + _keyProc = KeyboardHookCallback; + IntPtr mod = GetModuleHandle(null); + _mouseHook = SetWindowsHookEx(WH_MOUSE_LL, _mouseProc, mod, 0); + _keyHook = SetWindowsHookEx(WH_KEYBOARD_LL, _keyProc, mod, 0); + } + + private void UninstallHooks() + { + if (_mouseHook != IntPtr.Zero) { UnhookWindowsHookEx(_mouseHook); _mouseHook = IntPtr.Zero; } + if (_keyHook != IntPtr.Zero) { UnhookWindowsHookEx(_keyHook); _keyHook = IntPtr.Zero; } + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 8 — Mouse Hook Callback + // ══════════════════════════════════════════════════════════════════════════ + + private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode < 0 || !_userConnected) + return CallNextHookEx(_mouseHook, nCode, wParam, lParam); + + var info = Marshal.PtrToStructure(lParam); + var cursorPt = new System.Drawing.Point(info.pt.x, info.pt.y); + int msg = (int)wParam; + + if (!_clientMode) + { + if (msg == WM_MOUSEMOVE && _clientPos != ClientPos.None && IsAtExitEdge(cursorPt)) + EnterClientMode(cursorPt); + return CallNextHookEx(_mouseHook, nCode, wParam, lParam); + } + + if (msg == WM_MOUSEMOVE) + { + // Ignora o WM_MOUSEMOVE gerado pelo próprio warp para o centro + if (_isWarping) { _isWarping = false; return (IntPtr)1; } + + int dx = info.pt.x - _lastRawPos.X; + int dy = info.pt.y - _lastRawPos.Y; + + _virtualX += dx; + _virtualY += dy; + _pendingDX += dx; + _pendingDY += dy; + + 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); + + if (_mouseThrottle.ElapsedMilliseconds >= MouseThrottleMs && (_pendingDX != 0 || _pendingDY != 0)) + { + _mouseThrottle.Restart(); + int sdx = Math.Clamp(_pendingDX, -127, 127); + int sdy = Math.Clamp(_pendingDY, -127, 127); + _pendingDX = 0; + _pendingDY = 0; + SendMouse(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_MOUSEWHEEL) + { + short delta = (short)(info.mouseData >> 16); + int notches = Math.Clamp(delta / 120, -127, 127); + if (notches != 0) + Send(new byte[] { (byte)'W', (byte)(sbyte)notches }); + return (IntPtr)1; + } + + return (IntPtr)1; + } + + private bool IsAtExitEdge(System.Drawing.Point p) + { + var s = Screen.PrimaryScreen!.Bounds; + return _clientPos switch + { + ClientPos.Right => p.X >= s.Right - 1, + ClientPos.Left => p.X <= s.Left, + ClientPos.Above => p.Y <= s.Top, + ClientPos.Below => p.Y >= s.Bottom - 1, + _ => false + }; + } + + private bool ShouldReturnToHost() => _clientPos switch + { + ClientPos.Right => _virtualX < -ReturnThreshold, + ClientPos.Left => _virtualX > ReturnThreshold, + ClientPos.Below => _virtualY < -ReturnThreshold, + ClientPos.Above => _virtualY > ReturnThreshold, + _ => false + }; + + private void EnterClientMode(System.Drawing.Point edgePt) + { + _clientMode = true; + _edgeEntry = edgePt; + _virtualX = 0; + _virtualY = 0; + _pendingDX = 0; + _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 + SetStatus("Modo Cliente \u25cf", System.Drawing.Color.DodgerBlue); + } + + private void ExitClientMode(bool sendRelease) + { + if (!_clientMode) return; + _clientMode = false; + _isWarping = false; + _ctrlHeld = false; + _shiftHeld = false; + Cursor.Show(); + + var s = Screen.PrimaryScreen!.Bounds; + var ret = _clientPos switch + { + ClientPos.Right => new System.Drawing.Point(s.Right - 40, _edgeEntry.Y), + ClientPos.Left => new System.Drawing.Point(s.Left + 40, _edgeEntry.Y), + ClientPos.Above => new System.Drawing.Point(_edgeEntry.X, s.Top + 40), + ClientPos.Below => new System.Drawing.Point(_edgeEntry.X, s.Bottom - 40), + _ => _edgeEntry + }; + SetCursorPos(ret.X, ret.Y); + + if (sendRelease) Send(new byte[] { (byte)'A' }); + Send(new byte[] { (byte)'H' }); // flash verde + LED azul no Arduino + SetStatus("Modo Host \u25cf Conectado", System.Drawing.Color.Green); + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 9 — Keyboard Hook Callback + // ══════════════════════════════════════════════════════════════════════════ + + private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode < 0 || !_userConnected || !_clientMode) + return CallNextHookEx(_keyHook, nCode, wParam, lParam); + + int msg = (int)wParam; + bool isDown = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN; + bool isUp = msg == WM_KEYUP || msg == WM_SYSKEYUP; + + if (!isDown && !isUp) + return CallNextHookEx(_keyHook, nCode, wParam, lParam); + + var info = Marshal.PtrToStructure(lParam); + uint vk = info.vk; + + // Rastreia Ctrl e Shift (mais confiável que GetKeyState dentro do hook) + if (vk == 0xA2 || vk == 0xA3 || vk == 0x11) _ctrlHeld = isDown; + if (vk == 0xA0 || vk == 0xA1 || vk == 0x10) _shiftHeld = isDown; + + // Ctrl+V ou Shift+Ins → envia clipboard do host como digitação + if (isDown) + { + if ((vk == 0x56 && _ctrlHeld) || (vk == 0x2D && _shiftHeld)) + { + BeginInvoke((Action)SendClipboardToClient); + return (IntPtr)1; + } + } + + byte? code = VkToArduino(vk); + if (code.HasValue) + Send(new byte[] { isDown ? (byte)'P' : (byte)'U', code.Value }); + + return (IntPtr)1; + } + + // ══════════════════════════════════════════════════════════════════════════ + // SECTION 10 — VK → Arduino keycode mapping + // ══════════════════════════════════════════════════════════════════════════ + + private static readonly Dictionary KeyMap = new Dictionary + { + { 0xA0, 0x81 }, { 0xA1, 0x85 }, { 0xA2, 0x80 }, { 0xA3, 0x84 }, + { 0xA4, 0x82 }, { 0xA5, 0x86 }, { 0x5B, 0x83 }, { 0x5C, 0x87 }, + { 0x10, 0x81 }, { 0x11, 0x80 }, { 0x12, 0x82 }, + { 0x70, 0xC2 }, { 0x71, 0xC3 }, { 0x72, 0xC4 }, { 0x73, 0xC5 }, + { 0x74, 0xC6 }, { 0x75, 0xC7 }, { 0x76, 0xC8 }, { 0x77, 0xC9 }, + { 0x78, 0xCA }, { 0x79, 0xCB }, { 0x7A, 0xCC }, { 0x7B, 0xCD }, + { 0x26, 0xDA }, { 0x28, 0xD9 }, { 0x25, 0xD8 }, { 0x27, 0xD7 }, + { 0x24, 0xD2 }, { 0x23, 0xD5 }, { 0x21, 0xD3 }, { 0x22, 0xD6 }, + { 0x2D, 0xD1 }, { 0x2E, 0xD4 }, + { 0x0D, 0xB0 }, { 0x1B, 0xB1 }, { 0x08, 0xB2 }, { 0x09, 0xB3 }, + { 0x14, 0xC1 }, { 0x2C, 0xCE }, { 0x91, 0xCF }, { 0x13, 0xD0 }, + }; + + private static byte? VkToArduino(uint vk) + { + if (KeyMap.TryGetValue(vk, out byte mapped)) return mapped; + if (vk >= 0x41 && vk <= 0x5A) return (byte)(vk + 0x20); + if (vk >= 0x30 && vk <= 0x39) return (byte)vk; + if (vk >= 0x60 && vk <= 0x69) return (byte)('0' + vk - 0x60); + return vk switch + { + 0x20 => (byte)' ', 0xBD => (byte)'-', 0xBB => (byte)'=', + 0xDB => (byte)'[', 0xDD => (byte)']', 0xDC => (byte)'\\', + 0xBA => (byte)';', 0xDE => (byte)'\'', 0xBC => (byte)',', + 0xBE => (byte)'.', 0xBF => (byte)'/', 0xC0 => (byte)'`', + _ => null + }; + } + + // ══════════════════════════════════════════════════════════════════════════ + // 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 + // ══════════════════════════════════════════════════════════════════════════ + + private void SetStatus(string msg, System.Drawing.Color color) + { + if (InvokeRequired) { Invoke((Action)(() => SetStatus(msg, color))); return; } + lblStatus.Text = msg; + lblStatus.ForeColor = color; + } + + private void SetPortInfo(string msg) + { + if (InvokeRequired) { Invoke((Action)(() => SetPortInfo(msg))); return; } + lblPortInfo.Text = "Porta: " + 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. + // ══════════════════════════════════════════════════════════════════════════ + + 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 + { + { ';', '/' }, // 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 + }; + + 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 (byte)c; + } + + // Chamado via BeginInvoke (thread UI) para acessar Clipboard com segurança + private void SendClipboardToClient() + { + if (!_userConnected) return; + + string text = Clipboard.GetText(); + if (string.IsNullOrEmpty(text)) return; + if (text.Length > MaxClipChars) + { + SetStatus($"Clipboard ({text.Length} chars) excede {MaxClipChars}. 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' }); + Thread.Sleep(100); + + int skipped = 0; + foreach (char c in text) + { + byte b; + if (c == '\n' || c == '\r') b = (byte)'\n'; + else if (c == '\t') b = (byte)'\t'; + else if (c >= 32 && c <= 126) + { + byte? translated = TranslateChar(c); + if (!translated.HasValue) { skipped++; continue; } + b = translated.Value; + } + else { skipped++; continue; } // não-ASCII (é, ã, ç, etc.) + + Send(new byte[] { (byte)'K', b }); + Thread.Sleep(20); // ~50 chars/s — seguro para BT 9600 + } + + string suffix = skipped > 0 ? $" ({skipped} ignorados)" : ""; + SetStatus("Modo Cliente \u25cf" + suffix, System.Drawing.Color.DodgerBlue); + }); + + SetStatus($"Enviando {text.Length} chars...", System.Drawing.Color.DodgerBlue); + } + + private void cmbLayout_SelectedIndexChanged(object sender, EventArgs e) + { + _clientLayout = cmbLayout.SelectedIndex == 1 + ? ClientLayout.PtBrAbnt2 + : ClientLayout.US; + } + + + protected override void OnFormClosing(FormClosingEventArgs e) + { + _detectCts?.Cancel(); + ExitClientMode(sendRelease: false); + _watchdog.Stop(); + _watchdog.Dispose(); + _heartbeat?.Dispose(); + UninstallHooks(); + ClosePort(); + base.OnFormClosing(e); + } + } +} diff --git a/Principal.resx b/Principal.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/Principal.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..a79c7b3 --- /dev/null +++ b/Program.cs @@ -0,0 +1,15 @@ +using System; +using System.Windows.Forms; + +namespace KVMote +{ + internal static class Program + { + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new Principal()); + } + } +}