Fix scroll and mouse stability using UI capture and center parking

This commit is contained in:
Ricardo Carneiro 2026-04-24 12:45:30 -03:00
parent 552b2ca62c
commit 6bdf5fcdbd
8 changed files with 439 additions and 36 deletions

307
S3/KVMote_ESP32S3.ino Normal file
View File

@ -0,0 +1,307 @@
/*
KVMote ESP32-S3 N16R8
BLE NUS (Nordic UART Service) USB HID nativo
Substitui: Arduino Leonardo + HC-06 + LED RGB externo
LED: WS2812B embutido na placa (GPIO 48 na maioria das DevKit-C1).
Se a sua placa usar outro pino, altere LED_PIN abaixo.
Protocolo binário: idêntico ao KVMote.ino (Leonardo).
Mouse move 'M' dx(int8) dy(int8) 3 bytes
Mouse wheel 'W' delta(int8) 2 bytes
Tecla write 'K' char 2 bytes
Clique 'C' 'L'|'R' 2 bytes
Tecla press 'P' keycode 2 bytes
Tecla release 'U' keycode 2 bytes
ReleaseAll 'A' 1 byte
Mouse press 'D' 'L'|'R' 2 bytes
Mouse release 'E' 'L'|'R' 2 bytes
LED cliente 'O' (magenta)
LED host ok 'H' (azul)
LED sem host 'G' (verde)
Ping/Pong '~' responde [PONG] 1 byte
Dependências (instale pela Library Manager do Arduino IDE):
- Adafruit NeoPixel (by Adafruit)
incluídas no core ESP32:
- USB / USBHIDKeyboard / USBHIDMouse
- BLEDevice / BLEServer / BLE2902
Board: "ESP32S3 Dev Module"
USB Mode Hardware CDC and JTAG mantém JTAG para upload via porta COM
USB CDC On Boot Disabled CRÍTICO: libera USB nativo para HID
Upload Mode Internal USB (ou USB-OTG CDC)
PSRAM OPI PSRAM (para N16R8)
Flash Size 16MB
Conexões:
Porta USB (nativa OTG) PC cliente (aparece como teclado+mouse HID)
Porta COM (CH343) PC de desenvolvimento (upload de firmware)
BLE Host PC (sem fio, KVMote.exe)
*/
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Adafruit_NeoPixel.h>
// ── NUS UUIDs ─────────────────────────────────────────────────────────────────
#define NUS_SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define NUS_RX_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // PC escreve aqui
#define NUS_TX_UUID "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // ESP32 notifica (PONG)
// ── LED WS2812B embutido ──────────────────────────────────────────────────────
#define LED_PIN 48 // GPIO 48 — ESP32-S3-DevKitC-1; altere se necessário
#define LED_COUNT 1
#define LED_BRIGHTNESS 80 // 0255 (80 ≈ 30%, evita ofuscar)
// ── Objetos USB HID ───────────────────────────────────────────────────────────
USBHIDKeyboard Keyboard;
USBHIDMouse Mouse;
// ── NeoPixel ──────────────────────────────────────────────────────────────────
Adafruit_NeoPixel pixel(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
void ledCor(uint8_t r, uint8_t g, uint8_t b) {
pixel.setPixelColor(0, pixel.Color(r, g, b));
pixel.show();
}
// Cor base atual — restaurada após paste batch
uint8_t basR = 0, basG = 255, basB = 0; // verde = aguardando conexão
// ── 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,
AGUARDA_BATCH_LEN1, // recebendo byte alto do tamanho do chunk
AGUARDA_BATCH_LEN2, // recebendo byte baixo do tamanho do chunk
AGUARDA_BATCH_DATA // recebendo bytes de texto → Keyboard.write()
};
Estado estado = AGUARDA_CMD;
int8_t pendingDX = 0;
uint16_t batchRemaining = 0;
uint32_t batchLedMs = 0;
bool batchLedOn = false;
// ── 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') { basR = 255; basG = 0; basB = 255; ledCor(basR, basG, basB); }
else if (b == 'H') { basR = 0; basG = 0; basB = 255; ledCor(basR, basG, basB); }
else if (b == 'G') { basR = 0; basG = 255; basB = 0; ledCor(basR, basG, basB); }
else if (b == 'T') { estado = AGUARDA_BATCH_LEN1; }
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;
// ── Paste batch: T + uint16_be(N) + N bytes → Keyboard.write por byte ────
// Sem buffer: processa byte a byte enquanto chegam. LED pisca vermelho.
case AGUARDA_BATCH_LEN1:
batchRemaining = (uint16_t)b << 8;
estado = AGUARDA_BATCH_LEN2;
break;
case AGUARDA_BATCH_LEN2:
batchRemaining |= b;
if (batchRemaining == 0) { estado = AGUARDA_CMD; break; }
batchLedMs = millis();
batchLedOn = true;
ledCor(220, 0, 0); // vermelho: início do chunk
estado = AGUARDA_BATCH_DATA;
break;
case AGUARDA_BATCH_DATA: {
uint32_t now = millis();
if (now - batchLedMs >= 150) {
batchLedMs = now;
batchLedOn = !batchLedOn;
ledCor(batchLedOn ? 220 : 0, 0, 0);
}
Keyboard.write(b);
delay(5); // inter-char delay: evita drop do Shift no USB HID em rajada
if (--batchRemaining == 0) {
ledCor(basR, basG, basB); // restaura cor anterior
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;
batchRemaining = 0;
Keyboard.releaseAll();
basR = 0; basG = 255; basB = 0;
ledCor(basR, basG, basB); // 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(2048, 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);
}

4
app.go
View File

@ -76,3 +76,7 @@ func (a *App) ChangeLayout(layout int) {
func (a *App) SetPosition(pos int) {
a.engine.SetPosition(pos)
}
func (a *App) HandleScroll(delta int) {
a.engine.HandleManualScroll(delta)
}

View File

@ -184,6 +184,11 @@
<span x-text="statusText"></span>
</div>
<!-- Área rolável fake para forçar captura de scroll -->
<div id="scroll-sink" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; overflow-y: scroll; z-index: -1; pointer-events: none;">
<div style="height: 5000px; width: 1px;"></div>
</div>
<script>
function kvmApp() {
return {
@ -221,6 +226,16 @@
}
}
}
// Captura o scroll globalmente na janela do KVMote
window.addEventListener('wheel', (event) => {
// Log no console do navegador para você ver se disparar (inspecionar elemento)
console.log("Wheel event:", event.deltaY);
if (window.go && window.go.main && window.go.main.App) {
window.go.main.App.HandleScroll(Math.round(event.deltaY));
}
}, { passive: false });
</script>
</body>
</html>

View File

@ -7,6 +7,8 @@ export function Connect():Promise<string>;
export function Disconnect():Promise<string>;
export function HandleScroll(arg1:number):Promise<void>;
export function SendCtrlAltDel():Promise<void>;
export function SetPosition(arg1:number):Promise<void>;

View File

@ -14,6 +14,10 @@ export function Disconnect() {
return window['go']['main']['App']['Disconnect']();
}
export function HandleScroll(arg1) {
return window['go']['main']['App']['HandleScroll'](arg1);
}
export function SendCtrlAltDel() {
return window['go']['main']['App']['SendCtrlAltDel']();
}

View File

@ -25,4 +25,6 @@ type InputHandler interface {
SetCursorPos(x, y int32) bool
ShowCursor(show bool)
GetScreenResolution() (int32, int32)
RequestFocus()
SetCursorClip(clip bool)
}

View File

@ -1,4 +1,5 @@
//go:build windows
package input
import (
@ -11,21 +12,29 @@ import (
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW")
procUnhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx")
procCallNextHookEx = user32.NewProc("CallNextHookEx")
procGetMessageW = user32.NewProc("GetMessageW")
procSetCursorPos = user32.NewProc("SetCursorPos")
procShowCursor = user32.NewProc("ShowCursor")
procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
procPostThreadMessageW = user32.NewProc("PostThreadMessageW")
procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW")
procUnhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx")
procCallNextHookEx = user32.NewProc("CallNextHookEx")
procGetMessageW = user32.NewProc("GetMessageW")
procSetCursorPos = user32.NewProc("SetCursorPos")
procShowCursor = user32.NewProc("ShowCursor")
procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
procPostThreadMessageW = user32.NewProc("PostThreadMessageW")
procGetActiveWindow = user32.NewProc("GetActiveWindow")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procGetWindowRect = user32.NewProc("GetWindowRect")
procClipCursor = user32.NewProc("ClipCursor")
)
type RECT struct {
Left, Top, Right, Bottom int32
}
const (
WH_KEYBOARD_LL = 13
WH_MOUSE_LL = 14
@ -40,7 +49,7 @@ func init() {
type MSLLHOOKSTRUCT struct {
Pt Point
MouseData uint32 // Mantemos uint32 mas vamos converter no callback
MouseData uint32
Flags uint32
Time uint32
DwExtraInfo uintptr
@ -58,6 +67,7 @@ type windowsInputHandler struct {
mouseHook uintptr
keyHook uintptr
tid uint32
hwnd uintptr
}
func NewInputHandler() InputHandler {
@ -65,6 +75,10 @@ func NewInputHandler() InputHandler {
}
func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEvent) bool, onKey func(KeyboardEvent) bool) error {
// Captura o HWND da janela ativa (que deve ser o nosso App Wails chamando Install)
hwnd, _, _ := procGetActiveWindow.Call()
h.hwnd = hwnd
ready := make(chan error, 1)
go func() {
@ -81,7 +95,7 @@ func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEve
Data: info.MouseData,
}
// Se a engine tratar o evento, não passamos para o próximo hook
// Se a engine tratar o evento (retornar true), bloqueamos o Windows
if onMouse(ev) {
return 1
}
@ -160,7 +174,6 @@ func (h *windowsInputHandler) ShowCursor(show bool) {
if show {
s = 1
}
// Vamos usar uma abordagem mais direta se necessário, mas por enquanto:
procShowCursor.Call(uintptr(s))
}
@ -169,3 +182,16 @@ func (h *windowsInputHandler) GetScreenResolution() (int32, int32) {
h_res, _, _ := procGetSystemMetrics.Call(SM_CYSCREEN)
return int32(w), int32(h_res)
}
func (h *windowsInputHandler) RequestFocus() {
if h.hwnd != 0 {
procSetForegroundWindow.Call(h.hwnd)
}
}
func (h *windowsInputHandler) SetCursorClip(clip bool) {
// Removido temporariamente para testes
if !clip {
procClipCursor.Call(0)
}
}

View File

@ -86,22 +86,23 @@ func (e *Engine) Start(ctx context.Context) error {
return e.inputHandler.Install(ctx, e.onMouse, e.onKey)
}
func (e *Engine) processarScroll(data uint32) {
func (e *Engine) processarScroll(msg uint32, data uint32) {
e.scrollActive = true
e.scrollTimer = time.Now()
delta := int32(int16(data >> 16))
e.wheelAccum += delta
deltaRaw := int16(data >> 16)
const Divisor = 40 // Bem sensível para touchpad
toSend := e.wheelAccum / Divisor
// LOG TOTAL PARA DESCOBRIR O QUE O TOUCHPAD MANDA
go LogDebug(fmt.Sprintf("SCROLL RAW: msg=0x%X data=0x%X delta=%d", msg, data, deltaRaw))
if toSend != 0 {
e.wheelAccum -= toSend * Divisor
err := e.transport.Send([]byte{'W', byte(int8(clamp(int(toSend), -127, 127)))})
if err == nil {
LogDebug(fmt.Sprintf("SCROLL: delta=%d enviado=%d", delta, toSend))
}
if deltaRaw > 0 {
e.transport.Send([]byte{'P', 0xDA}) // Up
time.Sleep(5 * time.Millisecond)
e.transport.Send([]byte{'U', 0xDA})
} else if deltaRaw < 0 {
e.transport.Send([]byte{'P', 0xD9}) // Down
time.Sleep(5 * time.Millisecond)
e.transport.Send([]byte{'U', 0xD9})
}
}
@ -113,6 +114,11 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
return false
}
// LOG PARA DIAGNÓSTICO: Registrar qualquer mensagem que não seja movimento simples (0x0200)
if ev.Message != 0x0200 {
go LogDebug(fmt.Sprintf("MSG MOUSE: 0x%X | ClientMode: %v", ev.Message, e.clientMode))
}
if !e.clientMode {
if ev.Message == 0x0200 && e.isAtExitEdge(ev.Point) {
e.enterClientMode(ev.Point)
@ -123,21 +129,17 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
// ─── MODO CLIENTE ATIVO ───
// Log de qualquer evento que não seja movimento simples (para descobrir o ID do touchpad)
if ev.Message != 0x0200 {
LogDebug(fmt.Sprintf("Evento Mouse: 0x%X | Data: %d", ev.Message, ev.Data))
}
switch ev.Message {
case 0x020A, 0x020E: // Roda Vertical ou Horizontal
e.processarScroll(ev.Data)
e.processarScroll(ev.Message, ev.Data)
return true
case 0x0200: // Move
if e.isWarping { e.isWarping = false; return true }
if e.scrollActive {
if time.Since(e.scrollTimer) > 300*time.Millisecond {
// Se estiver scrollando, ignoramos movimentos por um tempo curto (touchpads)
if time.Since(e.scrollTimer) > 250*time.Millisecond {
e.scrollActive = false
e.virtualX, e.virtualY = 0, 0
}
@ -160,6 +162,8 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
return true
}
// Park no centro para manter o mouse sobre a janela do App
// permitindo a captura do scroll.
w, h := e.inputHandler.GetScreenResolution()
e.isWarping = true
e.inputHandler.SetCursorPos(w/2, h/2)
@ -214,11 +218,20 @@ func (e *Engine) enterClientMode(p input.Point) {
e.wheelAccum = 0
e.mouseThrottle = time.Now()
// Foco para receber scroll da interface
e.inputHandler.RequestFocus()
w, h := e.inputHandler.GetScreenResolution()
e.isWarping = true
// Park no centro como no início
e.inputHandler.SetCursorPos(w/2, h/2)
e.lastRawPos = input.Point{X: w / 2, Y: h / 2}
e.inputHandler.ShowCursor(false)
// Sem esconder cursor para teste de scroll puro
e.inputHandler.ShowCursor(true)
// 'A' (ReleaseAll) limpa estados presos no firmware, 'O' sinaliza LED Magenta
e.transport.Send([]byte{'A'})
e.transport.Send([]byte{'O'})
}
@ -230,6 +243,7 @@ func (e *Engine) exitClientMode() {
w, h := e.inputHandler.GetScreenResolution()
var ret input.Point
const Offset = 120
// Retornamos o cursor exatamente para a borda onde ele entrou
switch e.clientPos {
case PosRight: ret = input.Point{X: w - Offset, Y: e.edgeEntry.Y}
case PosLeft: ret = input.Point{X: Offset, Y: e.edgeEntry.Y}
@ -242,6 +256,35 @@ func (e *Engine) exitClientMode() {
e.transport.Send([]byte{'A'})
}
func (e *Engine) HandleManualScroll(delta int) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.clientMode || !e.transport.IsConnected() {
return
}
e.scrollActive = true
e.scrollTimer = time.Now()
// Acumulamos o delta da UI para não perder movimentos pequenos
e.wheelAccum += int32(-delta) // Invertemos o delta da UI para bater com o padrão HID
// Divisor menor = Mais sensível
const Divisor = 15
toSend := e.wheelAccum / Divisor
if toSend != 0 {
e.wheelAccum -= toSend * Divisor
val := int8(clamp(int(toSend), -127, 127))
go func(v int8) {
e.transport.Send([]byte{'W', byte(v)})
LogDebug(fmt.Sprintf("UI SCROLL -> WHEEL %d (accum remain=%d)", v, e.wheelAccum))
}(val)
}
}
func (e *Engine) onKey(ev input.KeyboardEvent) bool {
e.mu.Lock()
defer e.mu.Unlock()