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