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());
+ }
+ }
+}