diff --git a/README.md b/README.md index e9cd5b3..bc739d3 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ * 支持 AP 热点配置(支持设置 WPA2 密码)与 STA 局域网接入。 * 配置数据持久化保存在 ESP32 的 NVS 分区中,掉电不丢失。 +5. **局域网网络唤醒 (Wake-on-LAN / WOL)** + * 支持将任意按键(单击、长按或双击)配置为向局域网广播 WOL 魔术包(Magic Packet)。 + * 无论电脑处于关机(S5)还是睡眠(S3/S4)状态,只要网卡支持 WOL,按下按键即可远程无线开机。 + * Web 控制台内置“发送测试唤醒包”调试按钮,支持标准 MAC 地址格式,配置直观。 + +6. **遥控器电量实时监控与低电量硬件提醒** + * 自动发现并监听 BLE 标准电池服务(Battery Service `0x180F` / `0x2A19`)。 + * WebUI 顶部状态栏与蓝牙详情面板实时显示电池电量百分比(`🔋 xx%`)。 + * **硬件级防断电警示**:当遥控器电量低于等于 15% 时,ESP32 板载 RGB LED 会在就绪状态下以 2 秒为周期双闪白光,直观提醒更换 7 号电池。 + +7. **面向未来的高级扩展架构** + * 按键动作模式新增【高级扩展】面板,除已上线的 WOL 唤醒外,界面已预留按键宏序列(Macro)、HTTP Webhook 网络请求以及 MQTT 物联网消息推送扩展占位,满足极客与智能家居联动需求。 + --- ## 默认出厂按键映射 @@ -179,7 +192,7 @@ pio run -e esp32s3_n4r2 -t upload ### 3. 按键与层级设置 1. 进入「按键映射和层级设置」页面。 2. 点击上方层级选项卡切换配置层(默认层、层1 ~ 层4)。 -3. 点击遥控器模型上的按键进入编辑弹窗,可独立设置单击、长按、双击触发的行为(键盘按键、多媒体控制或层级切换)。 +3. 点击遥控器模型上的按键进入编辑弹窗,可独立设置单击、长按、双击触发的行为(键盘按键、多媒体控制、层级切换或高级扩展如 WOL 局域网唤醒)。 4. 保存配置后即刻生效并写入 Flash。 ### 4. 输入法语音配置 diff --git a/ROADMAP.md b/ROADMAP.md index 13c449c..536734a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,40 +1,45 @@ # 🗺️ RemoteMapper-ESP32 路线规划与功能规划 (Roadmap) -本文档汇总了社区用户的 Issue 需求反馈与项目后续的功能演进规划。 +本文档汇总了社区用户的需求反馈、已落地实现的功能以及项目后续的演进规划。 --- -## 📌 近期规划与进行中功能 (In Progress / Planned) +## ✅ 已完成落地功能 (Completed) ### 1. 局域网网络唤醒 (Wake-on-LAN / WOL) -- **关联需求**:[Issue #1 (WOL功能建议)](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues/1) -- **功能描述**: - 将按键(例如电源键长按或双击)绑定为发送局域网 WOL 魔术包(Magic Packet)。当电脑处于关机(S5)或睡眠(S3/S4)状态且支持网卡唤醒时,按下遥控器即可通过 ESP32-S3 发送 UDP 广播唤醒电脑,让遥控器成为真正的“电脑无线开机键”。 -- **技术实现路径**: - 1. **Web 配置项**:在系统/网络配置页面中增加“目标电脑 MAC 地址”与“广播端口(默认 9)”设置,并持久化至 NVS。 - 2. **按键动作扩展**:在按键映射引擎中新增 `ACTION_WOL` 动作类型,支持任意按键的单击/长按/双击绑定。 - 3. **WOL 发送器**:基于 WiFi UDP 广播实现标准 Magic Packet 组包(`6 * 0xFF` + `16 * Target MAC`)。 - ---- +- **关联需求**:[Issue #1 (添加WOL功能的建议)](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues/1) +- **实现版本**:master / v1.2+ +- **功能特性**: + - 在按键映射引擎中新增 `ACTION_WOL (11)` 动作模式。 + - 支持将任意按键的单击、长按或双击配置为唤醒目标电脑。 + - 自动向局域网广播标准 WOL Magic Packet(`6 * 0xFF` + `16 * 目标MAC`)。 + - 支持灵活的 MAC 输入格式(`AA:BB:CC:DD:EE:FF`、`AA-BB-CC-DD-EE-FF`、连续 12 位十六进制)。 + - WebUI 内置“发送测试唤醒包”快捷调试按钮及持久化存储。 ### 2. 遥控器电池电量显示与低电量提醒 -- **关联需求**:[Issue #5 (是否有办法在页面上补充电量信息和提示)](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues/5) -- **功能描述**: - 在 Web 控制台的顶部状态栏以及“蓝牙配对管理”页面中,实时展示当前连接的遥控器电量百分比,并在电量过低(如低于 15%)时提供视觉提醒,方便用户及时更换电池。 -- **技术实现路径**: - 1. **BLE 电池服务解析**:在 `ble_remote_client` 中探测标准蓝牙电池服务(Battery Service UUID: `0x180F`,Characteristic: `0x2A19`)。 - 2. **GATT 订阅/读取**:连接成功后主动读取当前电量,并启用 Notification 监听电量变动。 - 3. **API 与 Web 界面渲染**:在 `/api/status` 与 `/api/ble/info` 返回 JSON 中增加 `battery_pct` 字段;Web 界面增加动态电量图标(如 🔋 85%)。 +- **关联需求**:[Issue #5 (是否有办法在配置页面补全充电提醒和电量提示)](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues/5) +- **实现版本**:master / v1.2+ +- **功能特性**: + - 自动发现并解析 BLE 标准电池服务(UUID `0x180F`,特征 `0x2A19`)。 + - 连接建立时主动采集电量,并支持 Notification 订阅与 60 秒自动心跳轮询。 + - WebUI 顶部状态栏与蓝牙详情面板实时展示精确电量(`🔋 85%`),离线与低电量自适应状态指示。 + - **板载硬件级提醒**:当遥控器电量 `<= 15%` 时,板载 RGB LED 会在正常连接状态下以 2 秒为周期执行白灯双脉冲闪烁(Double-pulse White Blink),提醒用户及时更换电池。 --- -## 🚀 中远期规划探索 (Future Exploration) +## 📌 近期与中远期规划 (Planned / Future) -- [ ] **宏按键序列 (Key Macro Sequences)**:支持单个按键触发一连串按键或文本输入(带延时可调)。 -- [ ] **多款蓝牙遥控器协议自动适配**:除小米标准蓝牙语音遥控器外,扩展支持常见的小米电视盒子其他型号及第三方 BLE 遥控器。 -- [ ] **Web 蓝牙直连/本地辅助工具**:在免 Wi-Fi 模式下,支持基于 WebSerial / WebHID 直接在浏览器端配置按键。 +### 1. 高级扩展模块落地 +Web 控制台映射弹窗已完成“高级扩展”架构设计与交互占位符: +- [ ] **按键宏序列 (Key Macro Sequences)**:支持单个按键触发一连串按键或文本输入(带毫秒级延时可调)。 +- [ ] **HTTP Webhook 网络请求**:支持按键触发向局域网/互联网 REST API 发送 GET/POST 请求,深度联动智能家居、家庭服务器(如 Home Assistant、Node-RED)。 +- [ ] **MQTT 物联网推送**:支持按键触发向指定 MQTT Broker 发送自定义 Topic 与 Payload。 + +### 2. 硬件与生态兼容性扩展 +- [ ] **更多型号蓝牙遥控器适配**:除小米蓝牙语音遥控器 2 Pro (RC003) 外,逐步适配常见的小米电视盒子系列及第三方标准 BLE 遥控器。 +- [ ] **Web 蓝牙直连/本地辅助工具**:在免 Wi-Fi 模式下,探索基于 WebSerial / WebHID 直接在浏览器端免网配按键。 --- ## 💡 欢迎贡献 -如果你有兴趣实现上述功能,或有其他功能建议,欢迎提交 PR 或在 [GitHub Issues](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues) 中参与讨论! +如果你有兴趣实现上述规划,或有新的功能建议,欢迎提交 PR 或在 [GitHub Issues](https://github.com/cuicui-V5/RemoteMapper-ESP32/issues) 中发起讨论! diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin index 12309b2..7f34b20 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin index eb48d1b..6049699 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin index 5c101c2..0d01233 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin index 2d70262..637668a 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin index 7a541d6..f72d1f1 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin index cb17332..dcbe322 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin differ diff --git a/include/led_indicator.h b/include/led_indicator.h index 352de38..4bf4547 100644 --- a/include/led_indicator.h +++ b/include/led_indicator.h @@ -19,6 +19,7 @@ void led_indicator_init(void); void led_indicator_set(led_state_t state); void led_indicator_trigger_key(bool is_voice_key); void led_indicator_set_layer_color(uint32_t rgb_color); +void led_indicator_set_low_battery(bool is_low); #ifdef __cplusplus } diff --git a/include/wol_manager.h b/include/wol_manager.h new file mode 100644 index 0000000..069b69b --- /dev/null +++ b/include/wol_manager.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +bool wol_manager_parse_mac(const char* mac_str, uint8_t out_mac[6]); +void wol_manager_mac_to_str(const uint8_t mac[6], char* out_str, size_t out_size); +bool wol_manager_send(const uint8_t mac[6], uint16_t port); +bool wol_manager_send_str(const char* mac_str, uint16_t port); + +#ifdef __cplusplus +} +#endif diff --git a/src/ble/ble_remote_client.cpp b/src/ble/ble_remote_client.cpp index 20b7dd1..eff1c32 100644 --- a/src/ble/ble_remote_client.cpp +++ b/src/ble/ble_remote_client.cpp @@ -15,6 +15,9 @@ static NimBLEClient* s_client = nullptr; static NimBLERemoteCharacteristic* s_char_cmd = nullptr; static NimBLERemoteCharacteristic* s_char_aud = nullptr; static NimBLERemoteCharacteristic* s_char_ctl = nullptr; +static NimBLERemoteCharacteristic* s_char_bat = nullptr; +static int s_battery_pct = -1; +static uint32_t s_last_battery_poll_ms = 0; static volatile bool s_is_encrypted = false; static Preferences s_ble_prefs; @@ -58,6 +61,15 @@ static bool do_connect_adv_device(NimBLEAdvertisedDevice* advDevice); static bool do_connect_mac(const String& mac_str, uint8_t addr_type); static bool setup_services_and_handshake(); +// Battery Notification Callback (0x180F / 0x2A19) +static void on_battery_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) { + if (pData && length >= 1) { + s_battery_pct = (int)pData[0]; + app_log("BATTERY", "Remote battery level updated: %d%%", s_battery_pct); + led_indicator_set_low_battery(s_battery_pct >= 0 && s_battery_pct <= 15); + } +} + // Audio Notification Callback (ATVV Char 0x03) static void on_audio_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) { if (length == 0) return; @@ -344,11 +356,14 @@ class ClientCallbacks : public NimBLEClientCallbacks { s_char_cmd = nullptr; s_char_aud = nullptr; s_char_ctl = nullptr; + s_char_bat = nullptr; + s_battery_pct = -1; s_last_hogp_key = 0; key_engine_release_all(&g_key_engine, millis()); usb_hid_keyboard_release(); usb_hid_consumer_release(); audio_pipeline_stop_session(&g_audio_pipeline); + led_indicator_set_low_battery(false); led_indicator_set(LED_STATE_WAIT_CONNECTION); // NOTE: Do NOT call start_scan() directly inside GAP disconnect callback. // ble_remote_task() on Core 0 automatically restarts scanning on the next tick. @@ -417,6 +432,7 @@ static bool setup_services_and_handshake() { NimBLERemoteService* atvv_svc = nullptr; NimBLERemoteService* hid_svc = nullptr; + NimBLERemoteService* bat_svc = nullptr; for (auto* pSvc : *pServices) { String svc_uuid = pSvc->getUUID().toString().c_str(); @@ -427,6 +443,8 @@ static bool setup_services_and_handshake() { atvv_svc = pSvc; } else if (pSvc->getUUID().equals(NimBLEUUID((uint16_t)HOGP_SVC_UUID)) || svc_uuid.indexOf("1812") >= 0) { hid_svc = pSvc; + } else if (pSvc->getUUID().equals(NimBLEUUID((uint16_t)0x180F)) || svc_uuid.indexOf("180f") >= 0) { + bat_svc = pSvc; } } @@ -521,6 +539,43 @@ static bool setup_services_and_handshake() { app_log("HOGP", "Warning: HID Service (0x1812) not found in GATT services!"); } + // STEP 3: Process Battery Service (0x180F) + if (bat_svc) { + app_log("BATTERY", "Discovering Battery characteristics..."); + std::vector* pChars = nullptr; + for (int retry = 0; retry < 3; retry++) { + pChars = bat_svc->getCharacteristics(true); + if (pChars && !pChars->empty()) break; + vTaskDelay(pdMS_TO_TICKS(50)); + } + + if (pChars) { + for (auto* pChar : *pChars) { + String char_uuid = pChar->getUUID().toString().c_str(); + char_uuid.toLowerCase(); + if (pChar->getUUID().equals(NimBLEUUID((uint16_t)0x2A19)) || char_uuid.indexOf("2a19") >= 0) { + s_char_bat = pChar; + if (pChar->canRead()) { + NimBLEAttValue val = pChar->readValue(); + if (val.length() >= 1) { + s_battery_pct = (int)val[0]; + app_log("BATTERY", "Remote battery initial level: %d%%", s_battery_pct); + led_indicator_set_low_battery(s_battery_pct >= 0 && s_battery_pct <= 15); + } + } + if (pChar->canNotify()) { + pChar->subscribe(true, on_battery_notify, false); + sub_count++; + app_log("BATTERY", "Subscribed to Battery Level notifications"); + vTaskDelay(pdMS_TO_TICKS(25)); + } + } + } + } + } else { + app_log("BATTERY", "Battery Service (0x180F) not found in GATT services"); + } + app_log("BLE", "Total Subscribed Characteristic(s): %d", sub_count); // 3. Negotiate data length and connection parameters @@ -752,6 +807,21 @@ void ble_remote_task(void) { } } } + + // 5. Periodic Battery polling (every 60 seconds if connected) + if (s_ble_state >= BLE_STATE_CONNECTED && s_client && s_client->isConnected() && s_char_bat != nullptr) { + if (now - s_last_battery_poll_ms >= 60000) { + s_last_battery_poll_ms = now; + if (s_char_bat->canRead()) { + NimBLEAttValue val = s_char_bat->readValue(); + if (val.length() >= 1) { + s_battery_pct = (int)val[0]; + app_log("BATTERY", "Periodic battery poll: %d%%", s_battery_pct); + led_indicator_set_low_battery(s_battery_pct >= 0 && s_battery_pct <= 15); + } + } + } + } } ble_remote_state_t ble_remote_get_state(void) { @@ -844,9 +914,14 @@ String ble_remote_get_connected_info(void) { doc["mac"] = s_connected_mac; doc["bound_mac"] = s_bound_mac; doc["bound_name"] = s_bound_name; + doc["battery_pct"] = s_battery_pct; String out; serializeJson(doc, out); return out; } +int ble_remote_get_battery_pct(void) { + return s_battery_pct; +} + } // extern "C" diff --git a/src/ble/ble_remote_client.h b/src/ble/ble_remote_client.h index 1b342ed..7a12a57 100644 --- a/src/ble/ble_remote_client.h +++ b/src/ble/ble_remote_client.h @@ -63,6 +63,11 @@ void ble_remote_unpair(void); */ String ble_remote_get_connected_info(void); +/** + * @brief Get connected remote battery level percentage (0~100, or -1 if unknown/disconnected) + */ +int ble_remote_get_battery_pct(void); + #ifdef __cplusplus } #endif diff --git a/src/keymap/key_config_storage.cpp b/src/keymap/key_config_storage.cpp index c1769cf..6f5aa71 100644 --- a/src/keymap/key_config_storage.cpp +++ b/src/keymap/key_config_storage.cpp @@ -1,5 +1,6 @@ #include "key_config_storage.h" #include "log/app_log.h" +#include "wol_manager.h" #include #include @@ -46,6 +47,11 @@ String key_config_to_json(const key_mapper_engine_t *engine) { if (b->click_action.key_code != 0) obj["click_key"] = b->click_action.key_code; if (b->click_action.consumer_code != 0) obj["click_cons"] = b->click_action.consumer_code; if (b->click_action.type == ACTION_SWITCH_LAYER) obj["click_layer"] = b->click_action.target_layer; + if (b->click_action.type == ACTION_WOL) { + char mac_buf[20]; + wol_manager_mac_to_str(b->click_action.wol_mac, mac_buf, sizeof(mac_buf)); + obj["click_mac"] = mac_buf; + } } // Long action @@ -57,6 +63,11 @@ String key_config_to_json(const key_mapper_engine_t *engine) { if (b->long_action.key_code != 0) obj["long_key"] = b->long_action.key_code; if (b->long_action.consumer_code != 0) obj["long_cons"] = b->long_action.consumer_code; if (b->long_action.type == ACTION_SWITCH_LAYER) obj["long_layer"] = b->long_action.target_layer; + if (b->long_action.type == ACTION_WOL) { + char mac_buf[20]; + wol_manager_mac_to_str(b->long_action.wol_mac, mac_buf, sizeof(mac_buf)); + obj["long_mac"] = mac_buf; + } } // Double action @@ -68,6 +79,11 @@ String key_config_to_json(const key_mapper_engine_t *engine) { if (b->double_action.key_code != 0) obj["double_key"] = b->double_action.key_code; if (b->double_action.consumer_code != 0) obj["double_cons"] = b->double_action.consumer_code; if (b->double_action.type == ACTION_SWITCH_LAYER) obj["double_layer"] = b->double_action.target_layer; + if (b->double_action.type == ACTION_WOL) { + char mac_buf[20]; + wol_manager_mac_to_str(b->double_action.wol_mac, mac_buf, sizeof(mac_buf)); + obj["double_mac"] = mac_buf; + } } } } @@ -110,6 +126,9 @@ static void parse_bindings_array(JsonArray arr, key_layer_t *layer) { b.click_action.key_code = (uint8_t)parse_u32_or_hex(obj["click_key"], 0); b.click_action.consumer_code = (uint16_t)parse_u32_or_hex(obj["click_cons"], 0); b.click_action.target_layer = (uint8_t)parse_u32_or_hex(obj["click_layer"], 0); + if (!obj["click_mac"].isNull()) { + wol_manager_parse_mac(obj["click_mac"].as().c_str(), b.click_action.wol_mac); + } // Normalize MI_KEY_VOICE_ALT (0x3E) to MI_KEY_VOICE (0x04) if (b.source_vk == MI_KEY_VOICE_ALT) { @@ -128,6 +147,9 @@ static void parse_bindings_array(JsonArray arr, key_layer_t *layer) { b.long_action.key_code = (uint8_t)parse_u32_or_hex(obj["long_key"], 0); b.long_action.consumer_code = (uint16_t)parse_u32_or_hex(obj["long_cons"], 0); b.long_action.target_layer = (uint8_t)parse_u32_or_hex(obj["long_layer"], 0); + if (!obj["long_mac"].isNull()) { + wol_manager_parse_mac(obj["long_mac"].as().c_str(), b.long_action.wol_mac); + } b.has_double = obj["has_double"] | false; b.double_ms = parse_u32_or_hex(obj["double_ms"], 250); @@ -136,6 +158,9 @@ static void parse_bindings_array(JsonArray arr, key_layer_t *layer) { b.double_action.key_code = (uint8_t)parse_u32_or_hex(obj["double_key"], 0); b.double_action.consumer_code = (uint16_t)parse_u32_or_hex(obj["double_cons"], 0); b.double_action.target_layer = (uint8_t)parse_u32_or_hex(obj["double_layer"], 0); + if (!obj["double_mac"].isNull()) { + wol_manager_parse_mac(obj["double_mac"].as().c_str(), b.double_action.wol_mac); + } layer->bindings[layer->binding_count++] = b; } diff --git a/src/keymap/key_state_machine.c b/src/keymap/key_state_machine.c index 060ace6..29c76a5 100644 --- a/src/keymap/key_state_machine.c +++ b/src/keymap/key_state_machine.c @@ -1,4 +1,5 @@ #include "key_state_machine.h" +#include "wol_manager.h" #include "app_config.h" #include #include @@ -142,6 +143,12 @@ static void emit_action(key_mapper_engine_t *engine, const key_action_t *action, return; } + // Wake-on-LAN Action + if (action->type == ACTION_WOL) { + wol_manager_send(action->wol_mac, 9); + return; + } + if (engine->output_cb) { engine->output_cb(action); } @@ -473,7 +480,7 @@ void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool b.click_action.type == ACTION_CONSUMER_HOLD || b.click_action.type == ACTION_VOICE_HOLD) { emit_action(engine, &b.click_action, raw_key_code, true); - } else if (b.click_action.type == ACTION_SWITCH_LAYER) { + } else if (b.click_action.type == ACTION_SWITCH_LAYER || b.click_action.type == ACTION_WOL) { emit_action(engine, &b.click_action, raw_key_code, true); } } @@ -496,7 +503,7 @@ void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool } else if (b.click_action.type == ACTION_VOICE_HOLD) { key_action_t rel = { ACTION_VOICE_RELEASE, 0, 0, 0, 0 }; emit_action(engine, &rel, raw_key_code, false); - } else if (b.has_click && b.click_action.type != ACTION_SWITCH_LAYER) { + } else if (b.has_click && b.click_action.type != ACTION_SWITCH_LAYER && b.click_action.type != ACTION_WOL) { emit_action(engine, &b.click_action, raw_key_code, false); } } else { diff --git a/src/keymap/key_state_machine.h b/src/keymap/key_state_machine.h index 27333dd..9f5b963 100644 --- a/src/keymap/key_state_machine.h +++ b/src/keymap/key_state_machine.h @@ -20,7 +20,11 @@ typedef enum { ACTION_VOICE_HOLD, // Trigger voice recording + hold hotkey ACTION_VOICE_RELEASE, // End voice recording + release hotkey ACTION_SWITCH_LAYER, // Switch to target layer (auto-toggles to 0 if current == target) - ACTION_TRANSPARENT // Transparent / Inherit from Layer 0 + ACTION_TRANSPARENT, // Transparent / Inherit from Layer 0 + ACTION_WOL, // 11: Wake-on-LAN Magic Packet + ACTION_ADV_MACRO, // 12: Future placeholder (Macro Sequence) + ACTION_ADV_HTTP, // 13: Future placeholder (HTTP Webhook) + ACTION_ADV_MQTT // 14: Future placeholder (MQTT Publish) } key_action_type_t; typedef struct { @@ -29,6 +33,7 @@ typedef struct { uint8_t key_code; // USB_KEY_* uint16_t consumer_code;// USB_CONSUMER_* uint8_t target_layer; // Target layer (0 ~ 4) for ACTION_SWITCH_LAYER + uint8_t wol_mac[6]; // Target MAC address for ACTION_WOL } key_action_t; typedef struct { diff --git a/src/led_indicator.cpp b/src/led_indicator.cpp index 7916909..cb9dcf3 100644 --- a/src/led_indicator.cpp +++ b/src/led_indicator.cpp @@ -15,6 +15,7 @@ static bool s_is_flashing = false; static uint32_t s_layer_color = 0x00FF00; // Default green for Layer 0 static bool s_layer_flash = false; +static bool s_low_battery = false; static void update_hardware_led(led_state_t state) { if (s_layer_flash && s_is_flashing) { @@ -25,6 +26,15 @@ static void update_hardware_led(led_state_t state) { return; } + // Low battery white double-pulse alert when connected + if (s_low_battery && state == LED_STATE_CONNECTED) { + uint32_t phase = millis() % 2000; + if ((phase < 120) || (phase >= 220 && phase < 340)) { + neopixelWrite(RGB_BUILTIN, 32, 32, 32); // Crisp White flash + return; + } + } + switch (state) { case LED_STATE_WAIT_CONNECTION: neopixelWrite(RGB_BUILTIN, 24, 0, 0); // Red @@ -93,3 +103,7 @@ void led_indicator_set_layer_color(uint32_t rgb_color) { s_is_flashing = true; } +void led_indicator_set_low_battery(bool is_low) { + s_low_battery = is_low; +} + diff --git a/src/net/wol_manager.cpp b/src/net/wol_manager.cpp new file mode 100644 index 0000000..0c0962e --- /dev/null +++ b/src/net/wol_manager.cpp @@ -0,0 +1,96 @@ +#include "wol_manager.h" +#include "log/app_log.h" +#include "wifi/wifi_manager.h" +#include "led_indicator.h" +#include +#include +#include + +extern "C" { + +bool wol_manager_parse_mac(const char* mac_str, uint8_t out_mac[6]) { + if (!mac_str || !out_mac) return false; + unsigned int m[6]; + if (sscanf(mac_str, "%02x:%02x:%02x:%02x:%02x:%02x", &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6 || + sscanf(mac_str, "%02x-%02x-%02x-%02x-%02x-%02x", &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6) { + for (int i = 0; i < 6; i++) out_mac[i] = (uint8_t)m[i]; + return true; + } + if (strlen(mac_str) == 12) { + if (sscanf(mac_str, "%02x%02x%02x%02x%02x%02x", &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6) { + for (int i = 0; i < 6; i++) out_mac[i] = (uint8_t)m[i]; + return true; + } + } + return false; +} + +void wol_manager_mac_to_str(const uint8_t mac[6], char* out_str, size_t out_size) { + if (!mac || !out_str || out_size < 18) return; + snprintf(out_str, out_size, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +bool wol_manager_send(const uint8_t mac[6], uint16_t port) { + if (!mac) return false; + if (port == 0) port = 9; + + bool all_zero = true; + for (int i = 0; i < 6; i++) { + if (mac[i] != 0) { + all_zero = false; + break; + } + } + if (all_zero) { + app_log("WOL", "Error: target MAC address is 00:00:00:00:00:00"); + return false; + } + + char mac_buf[20]; + wol_manager_mac_to_str(mac, mac_buf, sizeof(mac_buf)); + + uint8_t magic_packet[102]; + memset(magic_packet, 0xFF, 6); + for (int i = 0; i < 16; i++) { + memcpy(magic_packet + 6 + (i * 6), mac, 6); + } + + WiFiUDP udp; + IPAddress bcast(255, 255, 255, 255); + if (WiFi.status() == WL_CONNECTED) { + IPAddress localBcast = WiFi.broadcastIP(); + if (localBcast[0] != 0) { + bcast = localBcast; + } + } + + udp.begin(0); + udp.beginPacket(bcast, port); + size_t written = udp.write(magic_packet, sizeof(magic_packet)); + bool ok = udp.endPacket(); + udp.stop(); + + if (ok && written == sizeof(magic_packet)) { + app_log("WOL", "Magic packet sent to %s via %s:%u", + mac_buf, bcast.toString().c_str(), port); + led_indicator_trigger_key(false); + return true; + } else { + app_log("WOL", "Failed to send magic packet to %s (written=%u, ok=%d)", + mac_buf, (unsigned int)written, ok ? 1 : 0); + return false; + } +} + +bool wol_manager_send_str(const char* mac_str, uint16_t port) { + if (!mac_str) return false; + uint8_t mac[6]; + if (!wol_manager_parse_mac(mac_str, mac)) { + app_log("WOL", "Invalid MAC format: %s", mac_str); + return false; + } + return wol_manager_send(mac, port); +} + +} // extern "C" diff --git a/src/web/web_server.cpp b/src/web/web_server.cpp index bce284d..a9b382d 100644 --- a/src/web/web_server.cpp +++ b/src/web/web_server.cpp @@ -8,6 +8,7 @@ #include "keymap/key_state_machine.h" #include "keymap/key_config_storage.h" #include "nvs/nvs_manager.h" +#include "wol_manager.h" #include #include @@ -25,6 +26,7 @@ static void handle_status() { doc["version"] = FIRMWARE_VERSION; doc["uptime_sec"] = millis() / 1000; doc["ble_state"] = (int)ble_remote_get_state(); + doc["battery_pct"] = ble_remote_get_battery_pct(); doc["frames_decoded"] = g_audio_pipeline.total_frames_decoded; doc["samples_pushed"] = g_audio_pipeline.total_samples_pushed; doc["free_heap"] = ESP.getFreeHeap(); @@ -220,6 +222,40 @@ static void handle_nvs_reset() { } } +static void handle_wol_test() { + String mac = ""; + uint16_t port = 9; + + if (s_server.hasArg("plain")) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, s_server.arg("plain")); + if (!err) { + if (doc["mac"].is()) mac = doc["mac"].as(); + if (doc["port"].is()) port = (uint16_t)doc["port"].as(); + } + } + if (mac.length() == 0 && s_server.hasArg("mac")) { + mac = s_server.arg("mac"); + } + if (s_server.hasArg("port")) { + port = (uint16_t)s_server.arg("port").toInt(); + } + if (port == 0) port = 9; + + mac.trim(); + if (mac.length() == 0) { + s_server.send(400, "application/json", "{\"status\":\"error\",\"message\":\"缺少 MAC 地址\"}"); + return; + } + + bool ok = wol_manager_send_str(mac.c_str(), port); + if (ok) { + s_server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Wake-on-LAN 唤醒魔术包已广播发送\"}"); + } else { + s_server.send(400, "application/json", "{\"status\":\"error\",\"message\":\"发送失败,请检查 MAC 地址格式是否正确 (如 AA:BB:CC:DD:EE:FF)\"}"); + } +} + static void handle_captive_portal() { String host = s_server.hostHeader(); if (host != "192.168.4.1" && host != "remotemapper.local") { @@ -243,6 +279,7 @@ void web_server_init(void) { 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/wol/test", HTTP_POST, handle_wol_test); 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 cab40b2..51255a0 100644 --- a/src/web/web_ui.h +++ b/src/web/web_ui.h @@ -305,7 +305,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
蓝牙遥控器连接状态
已连接
-
小米蓝牙语音遥控器
+
+ 小米蓝牙语音遥控器 + -- +
Wi-Fi 局域网 IP
@@ -502,12 +505,17 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
-
+
实时物理连接
正在获取...
-
+
+
遥控器电池电量
+
正在获取...
+
-
+
NVS 固化绑定目标 (专属独占)
正在获取...
@@ -788,6 +796,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
切换层级
切换目标层
+
+
高级扩展
+
WOL网络唤醒等
+
穿透继承
继承默认层配置
@@ -814,6 +826,60 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
+ + +
@@ -1160,8 +1226,9 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( 0xC0: { source_vk: 0xC0, has_click: true, click_type: 1, click_mod: 0, click_key: 65, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 } }; - function getActionSummaryText(type, mod, key, cons, target_layer) { + function getActionSummaryText(type, mod, key, cons, target_layer, mac) { if (type === 10) return '[继承默认层]'; + if (type === 11) return `[WOL唤醒: ${mac || '已配置'}]`; if (type === 9) { const tgt = target_layer || 0; let tgtName = tgt === 0 ? '默认层' : `层${tgt}`; @@ -1412,13 +1479,14 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const effClickKey = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_key : (b0 ? b0.click_key : 0); const effClickCons = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_cons : (b0 ? b0.click_cons : 0); const effClickLayer = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_layer : (b0 ? b0.click_layer : 0); + const effClickMac = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_mac : (b0 ? b0.click_mac : ''); let desc = `【${KEY_NAMES[vk] || hex}】`; if (currentEditingLayer > 0) { desc += isOverride ? ` (本层覆盖)` : ` (继承默认层)`; } - desc += `\n单击: ${getActionSummaryText(effClickType, effClickMod, effClickKey, effClickCons, effClickLayer)}`; + desc += `\n单击: ${getActionSummaryText(effClickType, effClickMod, effClickKey, effClickCons, effClickLayer, effClickMac)}`; const hasLong = (bCur && bCur.has_long && bCur.long_type !== 10) ? true : (b0 ? b0.has_long : false); if (hasLong) { @@ -1428,7 +1496,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const lCons = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_cons : b0.long_cons; const lLayer = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_layer : b0.long_layer; const lMs = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_ms : b0.long_ms; - desc += `\n长按(${lMs || 600}ms): ${getActionSummaryText(lType, lMod, lKey, lCons, lLayer)}`; + const lMac = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_mac : b0.long_mac; + desc += `\n长按(${lMs || 600}ms): ${getActionSummaryText(lType, lMod, lKey, lCons, lLayer, lMac)}`; } const hasDouble = (bCur && bCur.has_double && bCur.double_type !== 10) ? true : (b0 ? b0.has_double : false); @@ -1439,7 +1508,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const dCons = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_cons : b0.double_cons; const dLayer = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_layer : b0.double_layer; const dMs = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_ms : b0.double_ms; - desc += `\n双击(${dMs || 250}ms): ${getActionSummaryText(dType, dMod, dKey, dCons, dLayer)}`; + const dMac = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_mac : b0.double_mac; + desc += `\n双击(${dMs || 250}ms): ${getActionSummaryText(dType, dMod, dKey, dCons, dLayer, dMac)}`; } el.title = desc; @@ -1510,6 +1580,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const doubleSlider = document.getElementById('slider-double-ms'); if (doubleSlider) editingBinding.double_ms = parseInt(doubleSlider.value) || 250; + const wolMac = (document.getElementById('adv-wol-mac') ? document.getElementById('adv-wol-mac').value : '').trim(); + if (currentTriggerTab === 'click') { editingBinding.click_type = mode; if (mode === 9) { @@ -1519,6 +1591,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( } else if (mode === 10) { editingBinding.click_key = 0; editingBinding.click_mod = 0; editingBinding.click_cons = 0; editingBinding.has_click = true; + } else if (mode === 11) { + editingBinding.click_mac = wolMac; + editingBinding.click_key = 0; editingBinding.click_mod = 0; editingBinding.click_cons = 0; + editingBinding.has_click = (wolMac.length >= 12); } else if (mode === 4) { editingBinding.click_cons = code; editingBinding.click_key = 0; editingBinding.click_mod = 0; @@ -1536,6 +1612,9 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( editingBinding.long_key = 0; editingBinding.long_mod = 0; editingBinding.long_cons = 0; } else if (mode === 10) { editingBinding.long_key = 0; editingBinding.long_mod = 0; editingBinding.long_cons = 0; + } else if (mode === 11) { + editingBinding.long_mac = wolMac; + editingBinding.long_key = 0; editingBinding.long_mod = 0; editingBinding.long_cons = 0; } else if (mode === 4) { editingBinding.long_cons = code; editingBinding.long_key = 0; editingBinding.long_mod = 0; @@ -1551,6 +1630,9 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( editingBinding.double_key = 0; editingBinding.double_mod = 0; editingBinding.double_cons = 0; } else if (mode === 10) { editingBinding.double_key = 0; editingBinding.double_mod = 0; editingBinding.double_cons = 0; + } else if (mode === 11) { + editingBinding.double_mac = wolMac; + editingBinding.double_key = 0; editingBinding.double_mod = 0; editingBinding.double_cons = 0; } else if (mode === 4) { editingBinding.double_cons = code; editingBinding.double_key = 0; editingBinding.double_mod = 0; @@ -1571,7 +1653,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const doubleHeader = document.getElementById('double-click-header'); const actionLabel = document.getElementById('action-mode-label'); - let mode = 2, mod = 0, key = 0, cons = 0, target_layer = 1; + let mode = 2, mod = 0, key = 0, cons = 0, target_layer = 1, mac = ''; if (tab === 'click') { longHeader.style.display = 'none'; @@ -1582,6 +1664,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( key = editingBinding.click_key || 0; cons = editingBinding.click_cons || 0; target_layer = editingBinding.click_layer !== undefined ? editingBinding.click_layer : (currentEditingLayer === 1 ? 0 : 1); + mac = editingBinding.click_mac || ''; } else if (tab === 'long') { longHeader.style.display = 'block'; doubleHeader.style.display = 'none'; @@ -1594,6 +1677,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( key = editingBinding.long_key || 0; cons = editingBinding.long_cons || 0; target_layer = editingBinding.long_layer !== undefined ? editingBinding.long_layer : (currentEditingLayer === 1 ? 0 : 1); + mac = editingBinding.long_mac || ''; } else if (tab === 'double') { longHeader.style.display = 'none'; doubleHeader.style.display = 'block'; @@ -1606,8 +1690,12 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( key = editingBinding.double_key || 0; cons = editingBinding.double_cons || 0; target_layer = editingBinding.double_layer !== undefined ? editingBinding.double_layer : (currentEditingLayer === 1 ? 0 : 1); + mac = editingBinding.double_mac || ''; } + const macInput = document.getElementById('adv-wol-mac'); + if (macInput) macInput.value = mac; + selectedTargetLayer = target_layer; selectActionMode(mode); renderTriggerView(mode, mod, key, cons); @@ -1632,6 +1720,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const advContainer = document.getElementById('adv-config-container'); const layerBox = document.getElementById('layer-switch-config-box'); const transBox = document.getElementById('layer-trans-config-box'); + const advActionBox = document.getElementById('adv-action-config-box'); document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('selected')); const card = document.getElementById(`mode-card-${mode}`); @@ -1642,17 +1731,28 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( if (advContainer) advContainer.style.display = 'none'; if (layerBox) layerBox.style.display = 'block'; if (transBox) transBox.style.display = 'none'; + if (advActionBox) advActionBox.style.display = 'none'; renderTargetLayerButtons(); } else if (mode === 10) { if (recorderBox) recorderBox.style.display = 'none'; if (advContainer) advContainer.style.display = 'none'; if (layerBox) layerBox.style.display = 'none'; if (transBox) transBox.style.display = 'block'; + if (advActionBox) advActionBox.style.display = 'none'; + } else if (mode === 11) { + if (recorderBox) recorderBox.style.display = 'none'; + if (advContainer) advContainer.style.display = 'none'; + if (layerBox) layerBox.style.display = 'none'; + if (transBox) transBox.style.display = 'none'; + if (advActionBox) advActionBox.style.display = 'block'; + switchAdvSubTab('wol'); + renderTriggerView(11, 0, 0, 0); } else { if (recorderBox) recorderBox.style.display = 'block'; if (advContainer) advContainer.style.display = 'block'; if (layerBox) layerBox.style.display = 'none'; if (transBox) transBox.style.display = 'none'; + if (advActionBox) advActionBox.style.display = 'none'; let curMod = parseInt(document.getElementById('adv-mod').value) || 0; let curCode = parseInt(document.getElementById('adv-code').value) || 0; @@ -1681,6 +1781,95 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( } } + function switchAdvSubTab(sub) { + ['wol', 'macro', 'http', 'mqtt'].forEach(t => { + const btn = document.getElementById(`adv-tab-${t}`); + if (btn) { + if (t === sub) { + btn.classList.add('active'); + btn.style.opacity = '1'; + } else { + btn.classList.remove('active'); + if (t !== 'wol') btn.style.opacity = '0.7'; + } + } + }); + + const wolPanel = document.getElementById('adv-panel-wol'); + const phPanel = document.getElementById('adv-panel-placeholder'); + const phIcon = document.getElementById('adv-placeholder-icon'); + const phTitle = document.getElementById('adv-placeholder-title'); + const phDesc = document.getElementById('adv-placeholder-desc'); + + if (sub === 'wol') { + if (wolPanel) wolPanel.style.display = 'block'; + if (phPanel) phPanel.style.display = 'none'; + } else { + if (wolPanel) wolPanel.style.display = 'none'; + if (phPanel) { + phPanel.style.display = 'block'; + if (sub === 'macro') { + phIcon.innerText = '⌨️'; + phTitle.innerText = '按键宏序列 (Macro Sequence)'; + phDesc.innerText = '支持一键触发多步自定义按键连击与毫秒级延时操作,后续固件推出。'; + } else if (sub === 'http') { + phIcon.innerText = '🔗'; + phTitle.innerText = 'HTTP Webhook 网络请求'; + phDesc.innerText = '支持按键触发向局域网/互联网 REST API 发送 GET/POST 请求,后续固件推出。'; + } else if (sub === 'mqtt') { + phIcon.innerText = '📡'; + phTitle.innerText = 'MQTT 物联网消息推送'; + phDesc.innerText = '支持按键触发向 MQTT Broker 发布指定 Topic 与 Payload,后续固件推出。'; + } + } + } + } + + function onWolMacChange() { + const mac = (document.getElementById('adv-wol-mac') ? document.getElementById('adv-wol-mac').value : '').trim(); + if (editingBinding) { + if (currentTriggerTab === 'click') { + editingBinding.click_mac = mac; + editingBinding.has_click = (mac.length >= 12); + } else if (currentTriggerTab === 'long') { + editingBinding.long_mac = mac; + } else if (currentTriggerTab === 'double') { + editingBinding.double_mac = mac; + } + } + renderTriggerView(11, 0, 0, 0); + } + + async function testSendWol() { + const macInput = document.getElementById('adv-wol-mac'); + const mac = macInput ? macInput.value.trim() : ''; + const portInput = document.getElementById('adv-wol-port'); + const port = portInput ? (parseInt(portInput.value) || 9) : 9; + + if (!mac) { + showToast('请先输入目标计算机 MAC 地址', true); + if (macInput) macInput.focus(); + return; + } + + showToast('正在发送 WOL 唤醒魔术包...'); + try { + const res = await fetch('/api/wol/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mac: mac, port: port }) + }); + const data = await res.json(); + if (res.ok && data.status === 'ok') { + showToast(data.message || 'WOL 唤醒包已成功广播发送!'); + } else { + showToast(data.message || 'WOL 发送失败,请检查 MAC 地址格式', true); + } + } catch (e) { + showToast('发送请求失败: ' + e.message, true); + } + } + function renderTriggerView(type, mod, key, cons) { const isVoice = (editingKey === 0x04 || editingKey === 0x3E); if (isVoice && currentEditingLayer === 0) { @@ -1698,6 +1887,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( key = 0; mod = 0; cons = 0; + } else if (type === 11) { + key = 0; + mod = 0; + cons = 0; } currentSelectedMode = type; @@ -1727,6 +1920,19 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( return; } + if (type === 11) { + let mac = ''; + const macInput = document.getElementById('adv-wol-mac'); + if (macInput && macInput.value) mac = macInput.value.trim(); + if (!mac && editingBinding) { + if (currentTriggerTab === 'click') mac = editingBinding.click_mac || ''; + else if (currentTriggerTab === 'long') mac = editingBinding.long_mac || ''; + else if (currentTriggerTab === 'double') mac = editingBinding.double_mac || ''; + } + display.innerHTML = `🌐 WOL 唤醒: ${mac || '未配置MAC'}`; + return; + } + if (type === 0 || (!key && !cons && !mod)) { display.innerHTML = '未设置(敲击键盘录制,或在下方选择)'; return; @@ -1894,10 +2100,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( selectActionMode(4); renderTriggerView(4, 0, 0, code); } else if (prefix === 'm') { - if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10) selectActionMode(2); + if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10 || currentSelectedMode === 11) selectActionMode(2); renderTriggerView(currentSelectedMode, mod, 0, 0); } else if (prefix === 'k') { - if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10) selectActionMode(2); + if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10 || currentSelectedMode === 11) selectActionMode(2); renderTriggerView(currentSelectedMode, mod, code, 0); } } @@ -1911,7 +2117,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( renderTriggerView(7, mod, code, 0); } else if (currentSelectedMode === 4 || code >= 500) { renderTriggerView(4, 0, 0, code); - } else if (currentSelectedMode === 9 || currentSelectedMode === 10) { + } else if (currentSelectedMode === 9 || currentSelectedMode === 10 || currentSelectedMode === 11) { renderTriggerView(currentSelectedMode, 0, 0, 0); } else { renderTriggerView(currentSelectedMode, mod, code, 0); @@ -1931,8 +2137,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( const modal = document.getElementById('remap-modal'); if (modal.style.display !== 'flex') return; - // If user is in layer switch or transparent mode, ignore keypresses - if (currentSelectedMode === 9 || currentSelectedMode === 10) return; + // If user is in layer switch, transparent, or advanced extension mode, ignore keypresses + if (currentSelectedMode === 9 || currentSelectedMode === 10 || currentSelectedMode === 11) return; // If user is typing in advanced numeric inputs, let it through if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; @@ -2049,21 +2255,36 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( // Update top/side status const statBleState = document.getElementById('stat-ble-state'); const statBleName = document.getElementById('stat-ble-name'); + const statBleBat = document.getElementById('stat-ble-bat'); if (statBleState && statBleName) { if (d.connected) { statBleState.innerText = '已连接'; statBleState.style.color = 'var(--accent-green)'; statBleName.innerText = d.name || '小米蓝牙语音遥控器'; + if (statBleBat) { + if (d.battery_pct !== undefined && d.battery_pct >= 0) { + const isLow = (d.battery_pct <= 15); + statBleBat.innerHTML = `${isLow ? '🪫' : '🔋'} ${d.battery_pct}%`; + statBleBat.style.color = isLow ? '#f87171' : 'var(--accent-green)'; + statBleBat.title = isLow ? '低电量警示 (板载LED白闪提醒)' : '电量正常'; + } else { + statBleBat.innerText = '🔋 --'; + statBleBat.style.color = 'var(--text-muted)'; + } + } } else { statBleState.innerText = '扫描重连中...'; statBleState.style.color = 'var(--accent-orange)'; statBleName.innerText = (d.bound_mac && d.bound_mac.length > 0) ? `已绑定: ${d.bound_mac}` : '未绑定遥控器'; + if (statBleBat) statBleBat.innerText = ''; } } // Update Bluetooth Tab details const connStateEl = document.getElementById('ble-detail-conn-state'); const connDevEl = document.getElementById('ble-detail-conn-device'); + const batPctEl = document.getElementById('ble-detail-bat-pct'); + const batTipEl = document.getElementById('ble-detail-bat-tip'); const boundInfoEl = document.getElementById('ble-detail-bound-info'); const boundMacEl = document.getElementById('ble-detail-bound-mac'); if (connStateEl) { @@ -2077,6 +2298,27 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( connDevEl.innerText = '无活动物理连接'; } } + if (batPctEl && batTipEl) { + if (d.connected) { + if (d.battery_pct !== undefined && d.battery_pct >= 0) { + const isLow = (d.battery_pct <= 15); + batPctEl.innerHTML = `${isLow ? '🪫' : '🔋'} ${d.battery_pct}%`; + batPctEl.style.color = isLow ? '#f87171' : 'var(--accent-green)'; + batTipEl.innerText = isLow ? '低电量警告:请及时更换 7 号电池 (板载LED正闪烁白光提示)' : '电池状态良好,供电充足'; + batTipEl.style.color = isLow ? '#fca5a5' : 'var(--text-muted)'; + } else { + batPctEl.innerText = '读取中...'; + batPctEl.style.color = 'var(--text-muted)'; + batTipEl.innerText = '等待遥控器响应电量服务特征 (0x2A19)'; + batTipEl.style.color = 'var(--text-muted)'; + } + } else { + batPctEl.innerText = '未连接'; + batPctEl.style.color = 'var(--text-muted)'; + batTipEl.innerText = '设备离线时无法读取电量'; + batTipEl.style.color = 'var(--text-muted)'; + } + } if (boundInfoEl) { if (d.bound_mac && d.bound_mac.length > 0) { boundInfoEl.innerText = d.bound_name || '已绑定遥控器';