KVMote/Principal.cs
Ricardo Carneiro c77a9cac0d Add KVMote source — C#, Arduino sketch and .gitignore
- Principal.cs/Designer.cs: WinForms KVM host app (.NET 8)
  Global mouse/keyboard hooks, edge detection, BT serial protocol,
  clipboard paste via Ctrl+V, PT-BR ABNT2 layout translation
- KVMote.ino: Arduino Leonardo sketch (HC-06 BT, USB HID)
  Non-blocking state machine, LED RGB feedback, full protocol support
- KVMote.csproj/sln: project files with System.IO.Ports NuGet
- .gitignore: C#/Visual Studio standard exclusions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:09:07 -03:00

765 lines
35 KiB
C#

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<MSLLHOOKSTRUCT>(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<KBDLLHOOKSTRUCT>(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<uint, byte> KeyMap = new Dictionary<uint, byte>
{
{ 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<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 }, // 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);
}
}
}