diff --git a/src/ble/ble_remote_client.cpp b/src/ble/ble_remote_client.cpp index ad56ec5..1c945b3 100644 --- a/src/ble/ble_remote_client.cpp +++ b/src/ble/ble_remote_client.cpp @@ -64,6 +64,7 @@ static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, siz // Trigger Voice Hold action (hold hotkey & reset audio DSP) key_action_t act = { ACTION_VOICE_HOLD, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 }; usb_hid_dispatch_action(&act); + key_engine_feed_key(&g_key_engine, MI_KEY_VOICE, true, millis()); app_log("ATVV", ">>> Voice button PRESSED (session %d)", s_session_id); } // AUDIO_STOP / MIC_CLOSED / release op: @@ -72,6 +73,7 @@ static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, siz s_ble_state = BLE_STATE_CONNECTED; key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 }; usb_hid_dispatch_action(&act); + key_engine_feed_key(&g_key_engine, MI_KEY_VOICE, false, millis()); app_log("ATVV", "<<< Voice button RELEASED"); // Re-arm remote HTT standby diff --git a/src/keymap/key_config_storage.cpp b/src/keymap/key_config_storage.cpp new file mode 100644 index 0000000..2241dfd --- /dev/null +++ b/src/keymap/key_config_storage.cpp @@ -0,0 +1,143 @@ +#include "key_config_storage.h" +#include "log/app_log.h" +#include +#include + +static Preferences s_key_prefs; + +void key_config_storage_init(key_mapper_engine_t *engine) { + s_key_prefs.begin("keymap_conf", false); + if (!key_config_storage_load(engine)) { + app_log("KEYMAP", "No custom keymap found, loaded factory defaults (%u bindings)", (unsigned int)engine->binding_count); + } else { + app_log("KEYMAP", "Loaded custom keymap from NVS (%u bindings)", (unsigned int)engine->binding_count); + } +} + +String key_config_to_json(const key_mapper_engine_t *engine) { + if (!engine) return "{\"bindings\":[]}"; + + JsonDocument doc; + JsonArray arr = doc["bindings"].to(); + + for (size_t i = 0; i < engine->binding_count; i++) { + const key_binding_t *b = &engine->bindings[i]; + JsonObject obj = arr.add(); + + obj["source_vk"] = b->source_vk; + + // Click action + obj["has_click"] = b->has_click; + obj["click_type"] = (int)b->click_action.type; + obj["click_mod"] = b->click_action.modifier; + obj["click_key"] = b->click_action.key_code; + obj["click_cons"] = b->click_action.consumer_code; + + // Long action + obj["has_long"] = b->has_long; + obj["long_ms"] = b->long_ms; + obj["long_type"] = (int)b->long_action.type; + obj["long_mod"] = b->long_action.modifier; + obj["long_key"] = b->long_action.key_code; + obj["long_cons"] = b->long_action.consumer_code; + + // Double action + obj["has_double"] = b->has_double; + obj["double_ms"] = b->double_ms; + obj["double_type"] = (int)b->double_action.type; + obj["double_mod"] = b->double_action.modifier; + obj["double_key"] = b->double_action.key_code; + obj["double_cons"] = b->double_action.consumer_code; + } + + String out; + serializeJson(doc, out); + return out; +} + +bool key_config_from_json(key_mapper_engine_t *engine, const String &json_str) { + if (!engine || json_str.length() == 0) return false; + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, json_str); + if (err) { + app_log("KEYMAP", "JSON deserialize failed: %s", err.c_str()); + return false; + } + + JsonArray arr = doc["bindings"].as(); + if (arr.isNull()) return false; + + engine->binding_count = 0; + for (JsonObject obj : arr) { + if (engine->binding_count >= MAX_KEY_BINDINGS) break; + + key_binding_t b; + memset(&b, 0, sizeof(b)); + + b.source_vk = obj["source_vk"] | 0; + + b.has_click = obj["has_click"] | false; + b.click_action.type = (key_action_type_t)(obj["click_type"] | 0); + b.click_action.modifier = obj["click_mod"] | 0; + b.click_action.key_code = obj["click_key"] | 0; + b.click_action.consumer_code = obj["click_cons"] | 0; + + b.has_long = obj["has_long"] | false; + b.long_ms = obj["long_ms"] | 500; + b.long_action.type = (key_action_type_t)(obj["long_type"] | 0); + b.long_action.modifier = obj["long_mod"] | 0; + b.long_action.key_code = obj["long_key"] | 0; + b.long_action.consumer_code = obj["long_cons"] | 0; + + b.has_double = obj["has_double"] | false; + b.double_ms = obj["double_ms"] | 250; + b.double_action.type = (key_action_type_t)(obj["double_type"] | 0); + b.double_action.modifier = obj["double_mod"] | 0; + b.double_action.key_code = obj["double_key"] | 0; + b.double_action.consumer_code = obj["double_cons"] | 0; + + engine->bindings[engine->binding_count++] = b; + } + + app_log("KEYMAP", "Updated in-memory keymap (%u bindings)", (unsigned int)engine->binding_count); + return true; +} + +bool key_config_storage_save(key_mapper_engine_t *engine) { + if (!engine) return false; + String json = key_config_to_json(engine); + s_key_prefs.putString("cfg_json", json); + app_log("KEYMAP", "Saved keymap to NVS (%u bytes)", (unsigned int)json.length()); + return true; +} + +bool key_config_storage_load(key_mapper_engine_t *engine) { + if (!engine) return false; + String json = s_key_prefs.getString("cfg_json", ""); + if (json.length() == 0) return false; + return key_config_from_json(engine, json); +} + +void key_config_storage_reset_defaults(key_mapper_engine_t *engine) { + if (!engine) return; + s_key_prefs.remove("cfg_json"); + key_engine_load_defaults(engine); + app_log("KEYMAP", "Reset keymap to factory defaults (%u bindings)", (unsigned int)engine->binding_count); +} + +String key_telemetry_to_json(const key_mapper_engine_t *engine) { + JsonDocument doc; + if (engine) { + doc["source_vk"] = engine->last_telemetry.source_vk; + doc["is_pressed"] = engine->last_telemetry.is_pressed; + doc["duration_ms"] = engine->last_telemetry.duration_ms; + doc["action_type"] = engine->last_telemetry.action_type; + doc["modifier"] = engine->last_telemetry.modifier; + doc["key_code"] = engine->last_telemetry.key_code; + doc["consumer_code"] = engine->last_telemetry.consumer_code; + } + String out; + serializeJson(doc, out); + return out; +} diff --git a/src/keymap/key_config_storage.h b/src/keymap/key_config_storage.h new file mode 100644 index 0000000..651fcb6 --- /dev/null +++ b/src/keymap/key_config_storage.h @@ -0,0 +1,21 @@ +#pragma once + +#include "key_state_machine.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void key_config_storage_init(key_mapper_engine_t *engine); +bool key_config_storage_save(key_mapper_engine_t *engine); +bool key_config_storage_load(key_mapper_engine_t *engine); +void key_config_storage_reset_defaults(key_mapper_engine_t *engine); + +#ifdef __cplusplus +} +#endif + +String key_config_to_json(const key_mapper_engine_t *engine); +bool key_config_from_json(key_mapper_engine_t *engine, const String &json_str); +String key_telemetry_to_json(const key_mapper_engine_t *engine); diff --git a/src/keymap/key_definitions.h b/src/keymap/key_definitions.h index 3c01391..67838a2 100644 --- a/src/keymap/key_definitions.h +++ b/src/keymap/key_definitions.h @@ -9,22 +9,23 @@ extern "C" { // ========================================== // 1. Xiaomi Remote Physical Key Codes (HOGP / HID raw bytes) // ========================================== -#define MI_KEY_VOL_UP 0x80 // Android Volume Up (kbdhid drops this on Windows) -#define MI_KEY_VOL_DOWN 0x81 // Android Volume Down (kbdhid drops this on Windows) -#define MI_KEY_BACK 0xF1 // Android Back key (kbdhid drops this on Windows) -#define MI_KEY_POWER 0xFF // Power button (or 0x66) -#define MI_KEY_POWER_ALT 0x66 -#define MI_KEY_HOME 0x24 // Home button (or 0x4A) +#define MI_KEY_VOL_UP 0x80 // Volume Up +#define MI_KEY_VOL_DOWN 0x81 // Volume Down +#define MI_KEY_BACK 0xF1 // Back key +#define MI_KEY_POWER 0x66 // Power button +#define MI_KEY_POWER_ALT 0xFF +#define MI_KEY_HOME 0x24 // Home button #define MI_KEY_HOME_ALT 0x4A -#define MI_KEY_MENU 0x5D // Menu button (or 0x65) +#define MI_KEY_MENU 0x5D // Menu button #define MI_KEY_MENU_ALT 0x65 -#define MI_KEY_TV 0xC0 // Live / TV button (or 0x35) +#define MI_KEY_TV 0xC0 // TV button #define MI_KEY_TV_ALT 0x35 #define MI_KEY_UP 0x52 // D-Pad Up #define MI_KEY_DOWN 0x51 // D-Pad Down #define MI_KEY_LEFT 0x50 // D-Pad Left #define MI_KEY_RIGHT 0x4F // D-Pad Right #define MI_KEY_OK 0x28 // OK / Enter +#define MI_KEY_VOICE 0x04 // Voice button // ========================================== // 2. USB HID Keyboard Modifier Bitmasks @@ -45,6 +46,7 @@ extern "C" { #define USB_KEY_NONE 0x00 #define USB_KEY_A 0x04 #define USB_KEY_D 0x07 // 'D' (for Win+D Show Desktop) +#define USB_KEY_H 0x0B // 'H' (for Win+H Voice Typing) #define USB_KEY_RETURN 0x28 // Enter #define USB_KEY_ESCAPE 0x29 // Esc #define USB_KEY_BACKSPACE 0x2A @@ -59,22 +61,19 @@ extern "C" { #define USB_KEY_COMMA 0x36 // ',' (for RAlt+, WeChat IME hotkey) // ========================================== -// 4. USB HID Consumer Control Usage Codes (16-bit) +// 4. USB HID Consumer Control Usages // ========================================== -#define USB_CONSUMER_NONE 0x0000 -#define USB_CONSUMER_POWER 0x0030 -#define USB_CONSUMER_RESET 0x0031 -#define USB_CONSUMER_SLEEP 0x0032 -#define USB_CONSUMER_PLAY_PAUSE 0x00CD -#define USB_CONSUMER_MUTE 0x00E2 -#define USB_CONSUMER_VOLUME_UP 0x00E9 // Standard PC Volume Increment -#define USB_CONSUMER_VOLUME_DOWN 0x00EA // Standard PC Volume Decrement -#define USB_CONSUMER_AL_CONSUMER_CTRL 0x0182 -#define USB_CONSUMER_AC_BACK 0x0224 // Standard PC Browser / App Back -#define USB_CONSUMER_AC_FORWARD 0x0225 -#define USB_CONSUMER_AC_REFRESH 0x0227 -#define USB_CONSUMER_AC_BOOKMARKS 0x022A -#define USB_CONSUMER_AC_PAN 0x0238 +#define USB_CONSUMER_NONE 0x0000 +#define USB_CONSUMER_POWER 0x0030 +#define USB_CONSUMER_SLEEP 0x0032 +#define USB_CONSUMER_PLAY_PAUSE 0x00CD +#define USB_CONSUMER_MUTE 0x00E2 +#define USB_CONSUMER_VOLUME_UP 0x00E9 +#define USB_CONSUMER_VOLUME_DOWN 0x00EA +#define USB_CONSUMER_NEXT_TRACK 0x00B5 +#define USB_CONSUMER_PREV_TRACK 0x00B6 +#define USB_CONSUMER_AC_HOME 0x0223 +#define USB_CONSUMER_AC_BACK 0x0224 #ifdef __cplusplus } diff --git a/src/keymap/key_state_machine.c b/src/keymap/key_state_machine.c index 7309d63..6964834 100644 --- a/src/keymap/key_state_machine.c +++ b/src/keymap/key_state_machine.c @@ -16,8 +16,20 @@ static int find_binding_index(const key_mapper_engine_t *engine, uint8_t raw_key return -1; } -static void emit_action(key_mapper_engine_t *engine, const key_action_t *action) { - if (engine && engine->output_cb && action && action->type != ACTION_NONE) { +static void emit_action(key_mapper_engine_t *engine, const key_action_t *action, uint8_t source_vk, bool is_down) { + if (!engine) return; + + // Record last telemetry event + engine->last_telemetry.source_vk = source_vk; + engine->last_telemetry.is_pressed = is_down; + if (action) { + engine->last_telemetry.action_type = action->type; + engine->last_telemetry.modifier = action->modifier; + engine->last_telemetry.key_code = action->key_code; + engine->last_telemetry.consumer_code = action->consumer_code; + } + + if (engine->output_cb && action && action->type != ACTION_NONE) { engine->output_cb(action); } } @@ -26,7 +38,110 @@ void key_engine_load_defaults(key_mapper_engine_t *engine) { if (!engine) return; engine->binding_count = 0; - // 1. Volume Up: 0x80 -> Consumer Volume Up with fast repeat + // 1. Power: 0x66 -> Click: Alt+Tab, Long: Sleep + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_POWER; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LALT, USB_KEY_TAB, 0 }; + b.has_long = true; + b.long_ms = 600; + b.long_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_SLEEP }; + engine->bindings[engine->binding_count++] = b; + } + + // 2. Voice: 0x04 -> Voice Hold & Release + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_VOICE; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_VOICE_HOLD, USB_MOD_LGUI, USB_KEY_H, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 3. D-Pad Up: 0x52 -> Keyboard Up + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_UP; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_UP, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 4. D-Pad Down: 0x51 -> Keyboard Down + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_DOWN; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_DOWN, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 5. D-Pad Left: 0x50 -> Keyboard Left + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_LEFT; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_LEFT, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 6. D-Pad Right: 0x4F -> Keyboard Right + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_RIGHT; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RIGHT, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 7. OK: 0x28 -> Keyboard Return + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_OK; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RETURN, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 8. Back: 0xF1 -> Consumer AC Back (Browser / App Back) + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_BACK; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_AC_BACK }; + engine->bindings[engine->binding_count++] = b; + } + + // 9. Home: 0x24 -> Click: Win+D (Show Desktop) + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_HOME; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LGUI, USB_KEY_D, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 10. Menu: 0x5D -> Click: Space (Play/Pause) + { + key_binding_t b; + memset(&b, 0, sizeof(b)); + b.source_vk = MI_KEY_MENU; + b.has_click = true; + b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_SPACE, 0 }; + engine->bindings[engine->binding_count++] = b; + } + + // 11. Volume Up: 0x80 -> Consumer Volume Up with fast repeat { key_binding_t b; memset(&b, 0, sizeof(b)); @@ -40,7 +155,7 @@ void key_engine_load_defaults(key_mapper_engine_t *engine) { engine->bindings[engine->binding_count++] = b; } - // 2. Volume Down: 0x81 -> Consumer Volume Down with fast repeat + // 12. Volume Down: 0x81 -> Consumer Volume Down with fast repeat { key_binding_t b; memset(&b, 0, sizeof(b)); @@ -54,50 +169,7 @@ void key_engine_load_defaults(key_mapper_engine_t *engine) { engine->bindings[engine->binding_count++] = b; } - // 3. Back: 0xF1 -> Consumer AC Back (Browser / App Back) - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_BACK; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_AC_BACK }; - engine->bindings[engine->binding_count++] = b; - } - - // 4. Power: 0xFF -> Click: Alt+Tab, Long: Sleep - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_POWER; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LALT, USB_KEY_TAB, 0 }; - b.has_long = true; - b.long_ms = 600; - b.long_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_SLEEP }; - engine->bindings[engine->binding_count++] = b; - } - - // 5. Home: 0x24 -> Click: Win+D (Show Desktop) - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_HOME; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LGUI, USB_KEY_D, 0 }; - engine->bindings[engine->binding_count++] = b; - } - - // 6. Menu: 0x5D -> Click: Space (Play/Pause) - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_MENU; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_SPACE, 0 }; - engine->bindings[engine->binding_count++] = b; - } - - // 7. TV: 0xC0 -> Click: F8 + // 13. TV: 0xC0 -> Click: F8 { key_binding_t b; memset(&b, 0, sizeof(b)); @@ -106,56 +178,30 @@ void key_engine_load_defaults(key_mapper_engine_t *engine) { b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_F8, 0 }; engine->bindings[engine->binding_count++] = b; } +} - // 8. D-Pad Up: 0x52 -> Keyboard Up - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_UP; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_UP, 0 }; - engine->bindings[engine->binding_count++] = b; +bool key_engine_set_binding(key_mapper_engine_t *engine, const key_binding_t *binding) { + if (!engine || !binding) return false; + int idx = find_binding_index(engine, binding->source_vk); + if (idx >= 0) { + engine->bindings[idx] = *binding; + return true; } + if (engine->binding_count < MAX_KEY_BINDINGS) { + engine->bindings[engine->binding_count++] = *binding; + return true; + } + return false; +} - // 9. D-Pad Down: 0x51 -> Keyboard Down - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_DOWN; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_DOWN, 0 }; - engine->bindings[engine->binding_count++] = b; - } - - // 10. D-Pad Left: 0x50 -> Keyboard Left - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_LEFT; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_LEFT, 0 }; - engine->bindings[engine->binding_count++] = b; - } - - // 11. D-Pad Right: 0x4F -> Keyboard Right - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_RIGHT; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RIGHT, 0 }; - engine->bindings[engine->binding_count++] = b; - } - - // 12. OK: 0x28 -> Keyboard Return - { - key_binding_t b; - memset(&b, 0, sizeof(b)); - b.source_vk = MI_KEY_OK; - b.has_click = true; - b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RETURN, 0 }; - engine->bindings[engine->binding_count++] = b; +bool key_engine_get_binding(const key_mapper_engine_t *engine, uint8_t source_vk, key_binding_t *out_binding) { + if (!engine || !out_binding) return false; + int idx = find_binding_index(engine, source_vk); + if (idx >= 0) { + *out_binding = engine->bindings[idx]; + return true; } + return false; } void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb) { @@ -168,51 +214,62 @@ void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb) { void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms) { if (!engine) return; + // Immediately record telemetry on EVERY single button state change + engine->last_telemetry.source_vk = raw_key_code; + engine->last_telemetry.is_pressed = is_pressed; + engine->last_telemetry.timestamp = now_ms; + int idx = find_binding_index(engine, raw_key_code); if (idx < 0) return; key_binding_t *b = &engine->bindings[idx]; - key_slot_state_t *st = &engine->states[idx]; + key_slot_state_t *s = &engine->states[idx]; + + // Populate telemetry action info + engine->last_telemetry.action_type = b->click_action.type; + engine->last_telemetry.modifier = b->click_action.modifier; + engine->last_telemetry.key_code = b->click_action.key_code; + engine->last_telemetry.consumer_code = b->click_action.consumer_code; if (is_pressed) { - if (!st->is_pressed) { - st->is_pressed = true; - st->press_timestamp = now_ms; - st->long_fired = false; - st->press_count++; + if (!s->is_pressed) { + s->is_pressed = true; + s->press_timestamp = now_ms; + s->long_fired = false; - // If key has a direct HOLD action without long/double ambiguity, fire immediately - if (!b->has_long && !b->has_double && b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) { - emit_action(engine, &b->click_action); - } else if (b->has_repeat) { - // Initialize repeat - emit_action(engine, &b->click_action); - st->next_repeat_timestamp = now_ms + b->repeat_delay_ms; + if (b->has_repeat) { + s->next_repeat_timestamp = now_ms + b->repeat_delay_ms; + } + + if (b->click_action.type == ACTION_KEYBOARD_HOLD || b->click_action.type == ACTION_CONSUMER_HOLD) { + emit_action(engine, &b->click_action, raw_key_code, true); } } } else { - // Key Released - if (st->is_pressed) { - st->is_pressed = false; - st->release_timestamp = now_ms; + if (s->is_pressed) { + s->is_pressed = false; + s->release_timestamp = now_ms; + uint32_t duration = now_ms - s->press_timestamp; + engine->last_telemetry.duration_ms = duration; - if (b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) { - // Release hold - key_action_t rel = b->click_action; - rel.type = ACTION_KEYBOARD_RELEASE; - emit_action(engine, &rel); - } else if (!st->long_fired && !b->has_repeat) { - if (b->has_double) { - if (st->press_count >= 2) { - emit_action(engine, &b->double_action); - st->press_count = 0; - st->waiting_double = false; - } else { - st->waiting_double = true; + if (b->click_action.type == ACTION_KEYBOARD_HOLD) { + key_action_t rel = { ACTION_KEYBOARD_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->click_action.type == ACTION_CONSUMER_HOLD) { + key_action_t rel = { ACTION_CONSUMER_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->has_click && !s->long_fired) { + if (!b->has_double) { + emit_action(engine, &b->click_action, raw_key_code, false); + } else { + s->press_count++; + if (s->press_count == 1) { + s->waiting_double = true; + } else if (s->press_count >= 2) { + s->waiting_double = false; + s->press_count = 0; + emit_action(engine, &b->double_action, raw_key_code, false); } - } else if (b->has_click) { - emit_action(engine, &b->click_action); - st->press_count = 0; } } } @@ -224,35 +281,24 @@ void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms) { for (size_t i = 0; i < engine->binding_count; i++) { key_binding_t *b = &engine->bindings[i]; - key_slot_state_t *st = &engine->states[i]; + key_slot_state_t *s = &engine->states[i]; - // 1. Long Press check - if (st->is_pressed && b->has_long && !st->long_fired) { - uint32_t held_duration = now_ms - st->press_timestamp; - if (held_duration >= b->long_ms) { - st->long_fired = true; - emit_action(engine, &b->long_action); + if (s->is_pressed) { + uint32_t hold_time = now_ms - s->press_timestamp; + if (b->has_long && !s->long_fired && hold_time >= b->long_ms) { + s->long_fired = true; + emit_action(engine, &b->long_action, b->source_vk, true); } - } - // 2. Repeat check - if (st->is_pressed && b->has_repeat) { - if (now_ms >= st->next_repeat_timestamp) { - emit_action(engine, &b->repeat_action); - st->next_repeat_timestamp = now_ms + b->repeat_interval_ms; + if (b->has_repeat && now_ms >= s->next_repeat_timestamp) { + emit_action(engine, &b->repeat_action, b->source_vk, true); + s->next_repeat_timestamp = now_ms + b->repeat_interval_ms; } - } - - // 3. Double click timeout check - if (!st->is_pressed && st->waiting_double && b->has_double) { - uint32_t since_release = now_ms - st->release_timestamp; - if (since_release >= b->double_ms) { - // Double-click window expired -> emit single click - if (b->has_click) { - emit_action(engine, &b->click_action); - } - st->waiting_double = false; - st->press_count = 0; + } else { + if (s->waiting_double && (now_ms - s->release_timestamp >= b->double_ms)) { + s->waiting_double = false; + s->press_count = 0; + emit_action(engine, &b->click_action, b->source_vk, false); } } } diff --git a/src/keymap/key_state_machine.h b/src/keymap/key_state_machine.h index cd8dc81..cb20ab7 100644 --- a/src/keymap/key_state_machine.h +++ b/src/keymap/key_state_machine.h @@ -54,6 +54,17 @@ typedef struct { bool waiting_double; } key_slot_state_t; +typedef struct { + uint8_t source_vk; + bool is_pressed; + uint32_t timestamp; + uint32_t duration_ms; + uint8_t action_type; + uint8_t modifier; + uint8_t key_code; + uint16_t consumer_code; +} key_event_telemetry_t; + #define MAX_KEY_BINDINGS 16 typedef void (*key_output_callback_t)(const key_action_t *action); @@ -63,6 +74,7 @@ typedef struct { key_slot_state_t states[MAX_KEY_BINDINGS]; size_t binding_count; key_output_callback_t output_cb; + key_event_telemetry_t last_telemetry; } key_mapper_engine_t; /** @@ -75,19 +87,23 @@ void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb); */ void key_engine_load_defaults(key_mapper_engine_t *engine); +/** + * @brief Set or update a key binding + */ +bool key_engine_set_binding(key_mapper_engine_t *engine, const key_binding_t *binding); + +/** + * @brief Get binding for a key code + */ +bool key_engine_get_binding(const key_mapper_engine_t *engine, uint8_t source_vk, key_binding_t *out_binding); + /** * @brief Feed raw physical key event from BLE HOGP - * @param engine Engine handle - * @param raw_key_code Remote key code (MI_KEY_*) - * @param is_pressed true for key down, false for key up - * @param now_ms Current system timestamp in milliseconds */ void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms); /** * @brief Periodic timer tick to evaluate long press, double click timeout, and repeat timers - * @param engine Engine handle - * @param now_ms Current system timestamp in milliseconds */ void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms); diff --git a/src/main.cpp b/src/main.cpp index 64eb9d3..47e8450 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "web/web_server.h" #include "audio/audio_pipeline.h" #include "keymap/key_state_machine.h" +#include "keymap/key_config_storage.h" #include "usb/usb_composite.h" #include "ble/ble_remote_client.h" #include "cli/cli_manager.h" @@ -41,9 +42,10 @@ void setup() { audio_pipeline_init(&g_audio_pipeline); app_log("INIT", "Audio Pipeline initialized (16kHz 16-bit Mono UAC 1.0)"); - // 4. Initialize Key Engine with USB HID dispatcher callback + // 4. Initialize Key Engine with USB HID dispatcher callback and restore NVS mappings key_engine_init(&g_key_engine, usb_hid_dispatch_action); - app_log("INIT", "Key Engine initialized with %u mappings", (unsigned int)g_key_engine.binding_count); + key_config_storage_init(&g_key_engine); + app_log("INIT", "Key Engine active with %u mappings", (unsigned int)g_key_engine.binding_count); // 5. Initialize Serial / CDC CLI Manager cli_manager_init(); diff --git a/src/web/web_server.cpp b/src/web/web_server.cpp index 633ef7e..14c791c 100644 --- a/src/web/web_server.cpp +++ b/src/web/web_server.cpp @@ -6,6 +6,7 @@ #include "ble/ble_remote_client.h" #include "audio/audio_pipeline.h" #include "keymap/key_state_machine.h" +#include "keymap/key_config_storage.h" #include #include @@ -75,12 +76,36 @@ static void handle_wifi_config() { s_server.send(200, "application/json", "{\"status\":\"ok\"}"); } +static void handle_keymap_get() { + String json = key_config_to_json(&g_key_engine); + s_server.send(200, "application/json", json); +} + +static void handle_keymap_save() { + if (!s_server.hasArg("plain")) { + s_server.send(400, "application/json", "{\"error\":\"missing_body\"}"); + return; + } + bool ok = key_config_from_json(&g_key_engine, s_server.arg("plain")); + if (ok) { + key_config_storage_save(&g_key_engine); + s_server.send(200, "application/json", "{\"status\":\"saved\"}"); + } else { + s_server.send(400, "application/json", "{\"error\":\"invalid_keymap_format\"}"); + } +} + static void handle_keymap_reset() { - key_engine_load_defaults(&g_key_engine); + key_config_storage_reset_defaults(&g_key_engine); app_log("KEYMAP", "Reset keymap to factory defaults via Web API"); s_server.send(200, "application/json", "{\"status\":\"reset_ok\"}"); } +static void handle_keymap_telemetry() { + String json = key_telemetry_to_json(&g_key_engine); + s_server.send(200, "application/json", json); +} + static void handle_ble_scan() { String json = ble_remote_scan_devices_json(); s_server.send(200, "application/json", json); @@ -149,7 +174,10 @@ void web_server_init(void) { s_server.on("/api/logs/clear", HTTP_POST, handle_logs_clear); s_server.on("/api/wifi/scan", HTTP_GET, handle_wifi_scan); s_server.on("/api/wifi/config", HTTP_POST, handle_wifi_config); + s_server.on("/api/keymap", HTTP_GET, handle_keymap_get); + s_server.on("/api/keymap/save", HTTP_POST, handle_keymap_save); s_server.on("/api/keymap/reset", HTTP_POST, handle_keymap_reset); + s_server.on("/api/keymap/telemetry", HTTP_GET, handle_keymap_telemetry); s_server.on("/api/ble/scan", HTTP_GET, handle_ble_scan); s_server.on("/api/ble/connect", HTTP_POST, handle_ble_connect); s_server.on("/api/ble/unpair", HTTP_POST, handle_ble_unpair); diff --git a/src/web/web_ui.h b/src/web/web_ui.h index 3a6c721..a22f9a1 100644 --- a/src/web/web_ui.h +++ b/src/web/web_ui.h @@ -1,423 +1,848 @@ #pragma once - #include -static const char INDEX_HTML[] PROGMEM = R"rawliteral( +const char INDEX_HTML[] PROGMEM = R"rawliteral( - - - RemoteMapper 控制台 - + .stat-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; } + .stat-title { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; } + .stat-val { font-size: 18px; font-weight: 700; color: #fff; } + + /* Real Xiaomi Silver Metallic Remote Visualizer */ + .remote-tester-container { display: flex; gap: 36px; align-items: flex-start; justify-content: center; flex-wrap: wrap; padding: 10px 0; } + + .real-remote-body { + width: 220px; + background: linear-gradient(180deg, #d4d4d8 0%, #e4e4e7 40%, #d4d4d8 70%, #a1a1aa 100%); + border: 2px solid #e4e4e7; + border-radius: 36px; + padding: 26px 18px 20px 18px; + box-shadow: 0 20px 40px rgba(0,0,0,0.8), inset 0 2px 4px rgba(255,255,255,0.8); + display: flex; + flex-direction: column; + align-items: center; + position: relative; + } + + .remote-top-row { display: flex; width: 100%; justify-content: space-between; margin-bottom: 20px; } + .r-circle-btn { + width: 44px; height: 44px; + background: #27272a; + border: 1px solid #3f3f46; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: #d4d4d8; cursor: pointer; + transition: all 0.12s; font-size: 16px; user-select: none; + box-shadow: 0 4px 6px rgba(0,0,0,0.3); + } + .r-circle-btn:hover { background: #3f3f46; color: #fff; transform: translateY(-1px); } + .r-circle-btn.pressed { background: #06b6d4 !important; color: #000 !important; transform: scale(0.92) !important; box-shadow: 0 0 20px #06b6d4 !important; } + .r-circle-btn.power { background: #27272a; color: #f87171; } + .r-circle-btn.voice { background: #27272a; color: #60a5fa; } + + /* D-Pad Section */ + .real-dpad-ring { + width: 154px; height: 154px; + border-radius: 50%; + background: #27272a; + border: 1px solid #3f3f46; + position: relative; + margin-bottom: 22px; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 6px 12px rgba(0,0,0,0.35); + } + .dpad-part { position: absolute; background: transparent; border: none; color: #71717a; cursor: pointer; font-size: 14px; transition: all 0.12s; display: flex; align-items: center; justify-content: center; } + .dpad-part:hover { color: #fff; } + .dpad-part.pressed { color: #06b6d4 !important; transform: scale(0.9); text-shadow: 0 0 12px #06b6d4; } + .d-up { top: 6px; width: 60px; height: 38px; } + .d-down { bottom: 6px; width: 60px; height: 38px; } + .d-left { left: 6px; width: 38px; height: 60px; } + .d-right { right: 6px; width: 38px; height: 60px; } + .d-center { + width: 66px; height: 66px; border-radius: 50%; + background: #18181b; border: 1px solid #3f3f46; + z-index: 2; font-size: 13px; font-weight: bold; color: #d4d4d8; + } + .d-center.pressed { background: #06b6d4 !important; color: #000 !important; box-shadow: 0 0 20px #06b6d4 !important; } + + /* 2-Column Lower Controls */ + .remote-controls-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; width: 100%; margin-bottom: 24px; align-items: center; } + .ctrl-col-left { display: flex; flex-direction: column; gap: 14px; align-items: center; } + .ctrl-col-right { display: flex; flex-direction: column; gap: 14px; align-items: center; } + + /* Integrated Volume Rocker */ + .vol-pill { + width: 48px; height: 102px; + background: #27272a; + border: 1px solid #3f3f46; + border-radius: 24px; + display: flex; flex-direction: column; + overflow: hidden; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + } + .vol-half { + flex: 1; border: none; background: transparent; + color: #d4d4d8; cursor: pointer; + font-size: 18px; font-weight: bold; + display: flex; align-items: center; justify-content: center; + transition: all 0.12s; + } + .vol-half:hover { background: #3f3f46; color: #fff; } + .vol-half.pressed { background: #06b6d4 !important; color: #000 !important; } + .vol-half:first-child { border-bottom: 1px solid #3f3f46; } + + .btn-tv-box { width: 48px; height: 48px; border-radius: 50%; font-size: 12px; font-weight: bold; } + .remote-footer { margin-top: 10px; display: flex; flex-direction: column; align-items: center; color: #71717a; font-size: 11px; } + .remote-footer .nfc-icon { width: 14px; height: 14px; border: 1px solid #71717a; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: bold; margin-bottom: 14px; } + + /* Key Event Monitor Panel */ + .event-box { flex: 1; min-width: 320px; background: #090d16; border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; } + .stat-badge { font-size: 26px; font-weight: 700; color: var(--accent-cyan); margin: 6px 0 14px 0; } + .event-field { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; } + .event-field span:first-child { color: var(--text-muted); } + .event-field span:last-child { font-weight: 600; font-family: monospace; color: #fff; } + + /* Ultra-Simple Interactive Remap Modal */ + .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(6px); display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; } + .modal { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-card); max-width: 540px; width: 100%; padding: 24px; box-shadow: 0 25px 50px rgba(0,0,0,0.6); } + + .trigger-tabs { display: flex; background: #090d16; border: 1px solid var(--border-color); border-radius: 10px; padding: 4px; gap: 4px; margin-bottom: 18px; } + .trigger-btn { flex: 1; border: none; background: transparent; color: var(--text-muted); padding: 8px 0; border-radius: 8px; font-weight: 600; font-size: 13px; cursor: pointer; transition: all 0.15s; } + .trigger-btn.active { background: #1e293b; color: var(--accent-cyan); box-shadow: 0 2px 6px rgba(0,0,0,0.4); } + + .key-recorder-box { + border: 2px dashed var(--accent-blue); + background: rgba(59, 130, 246, 0.08); + border-radius: 12px; + padding: 24px 16px; + text-align: center; + cursor: pointer; + outline: none; + transition: all 0.2s; + margin-bottom: 18px; + } + .key-recorder-box:focus, .key-recorder-box.recording { + border-color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.15); + box-shadow: 0 0 20px rgba(6, 182, 212, 0.3); + } + .key-badge-display { font-size: 24px; font-weight: 800; color: #fff; margin-top: 8px; min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; } + .kbd-chip { background: #0f172a; border: 1px solid var(--accent-cyan); color: var(--accent-cyan); padding: 4px 12px; border-radius: 6px; font-size: 18px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); } + + .preset-section { margin-top: 14px; } + .preset-title { font-size: 12px; color: var(--text-muted); font-weight: 600; margin-bottom: 8px; } + .preset-chips { display: flex; flex-wrap: wrap; gap: 8px; } + .preset-chip { + background: #0b0f17; border: 1px solid var(--border-color); color: var(--text-main); + padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; + } + .preset-chip:hover { background: var(--bg-hover); border-color: var(--accent-cyan); color: var(--accent-cyan); } + + .btn { background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: #fff; border: none; padding: 10px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } + .btn:hover { opacity: 0.9; } + .btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-main); } + .btn-outline:hover { background: var(--bg-hover); } + .btn-danger { background: var(--accent-red); } + + .log-terminal { background: #000; border: 1px solid #1f2937; border-radius: 8px; padding: 12px; font-family: "SFMono-Regular", Consolas, Menlo, monospace; font-size: 12px; height: 380px; overflow-y: auto; color: #34d399; line-height: 1.6; } + -
-
- -
-
BLE: 搜索中
-
Wi-Fi: AP+STA
-
IP: 192.168.4.1
-
-
+
+
+
+ + ESP32-S3 Hardware Bridge +
+
正在连接硬件...
+
-
- - - - - + +
+
+
蓝牙遥控器连接状态
+
已连接
+
小米蓝牙语音遥控器
+
+
+
Wi-Fi 局域网 IP
+
192.168.2.179
+
热点: 192.168.4.1
+
+
+
音频流水线状态
+
0 帧
+
16kHz 16-Bit Mono UAC 1.0
+
+
+
系统内存 / 运行时间
+
0s
+
SRAM: 200KB | PSRAM: 8MB
+
+
+ +
+ + + + +
+ + +
+
+
+ 🎮 真机 1:1 遥控器测试器(按压实体遥控器实时联动,点击按键即可修改按键映射) + +
+ +
+ +
+ +
+
⏻
+
🎙
+
+ + +
+ + + + + +
+ + +
+ +
+
<
+
⌂
+
≡
+
+ + +
+
+ + +
+
📺 TV
+
+
+ + + +
+ + +
+

⚡ 实时按键遥测状态

+
等待按键...
+ +
+ 物理键码 (HID Raw Code) + 0x00 +
+
+ 按键状态 + IDLE +
+
+ 按下持续时间 (Hold Time) + 0 ms +
+
+ 触发动作 (Action Type) + ACTION_NONE +
+
+ 注入键值 (Dispatched Key) + None +
+ +
+ 💡 极简改键说明:直接点击左侧任意遥控器按键,然后直接在电脑键盘上按下您想映射的按键或快捷键(或点选常用多媒体功能),点击保存即可! +
+
+
+
+
+ + +
+
+
+ 📡 蓝牙设备雷达与遥控器配对 + +
+
点击上方按钮扫描附近的蓝牙遥控器...
+
+
+ + +
+
+
+ 📜 ESP32-S3 实时运行日志 (保留完整 250 行) +
+ + +
+
+
正在加载运行日志...
+
+
+ + +
+
+
+
+ 📶 Wi-Fi 网络配置与附近热点搜索 + +
+
+ 点击右上角“搜索 Wi-Fi”可扫描附近 2.4GHz 无线网络,点击即可自动填入 SSID。 +
+
+ + +
+
+ + +
+ +
+ +
+
⚙️ 系统控制
+

+ 当前固件支持 UAC 1.0 USB 麦克风录音设备与标准 HID 键盘/多媒体复合注入。 +

+ +
+
+
- -
-
-
-
🎙️ BLE 遥控器与音频流
-
连接状态Scanning...
-
已绑定遥控器未绑定
-
音频规格16kHz 16-bit Mono (UAC 1.0)
-
已解码音频帧0 帧
-
已推流采样点0 点
-
- - -
-
+ +
- -
-
-
- 🔗 当前绑定设备 - -
-
设备名称未连接
-
MAC 地址--
-
+ + )rawliteral";