feat: add WOL network wake-up and remote battery monitoring with low-battery LED alert

- Feature 1: Remote Battery Monitoring & Low-Battery LED Alert (Closes #5)
  * Parse BLE Battery Service (0x180F) & Characteristic (0x2A19) with notifications and polling
  * Double-pulse white LED blink on onboard NeoPixel when battery <= 15%
  * Display real-time battery percentage in Web UI status bar and BLE tab

- Feature 2: Wake-on-LAN (WOL) & Advanced Actions Architecture (Closes #1)
  * Implement UDP broadcast WOL Magic Packet builder and sender (ACTION_WOL = 11)
  * Support MAC parsing in colon, dash, or 12-char hex formats, with port configuration
  * Add 'Advanced Extension' mode in Keymap UI with WOL tester and placeholders for Macro, Webhook, and MQTT
  * Persist wol_mac in NVS and handle immediate trigger on keypress

- Docs: Update README.md and ROADMAP.md reflecting new features and future plans
- Build: Update prebuilt firmware binaries in RemoteMapper-Flasher/bin
This commit is contained in:
cuicuiV5 committed 2026-09-12 16:44:29 +08:00
1 parent 68badb5800
commit 86f5d801b2
19 files changed
+582 -39

No files matched your search

+14 -1
View File
@@ -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. 输入法语音配置
+28 -23
View File
@@ -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) 中发起讨论!
+1
View File
@@ -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
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#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
+75
View File
@@ -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<NimBLERemoteCharacteristic*>* 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"
+5
View File
@@ -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
+25
View File
@@ -1,5 +1,6 @@
#include "key_config_storage.h"
#include "log/app_log.h"
#include "wol_manager.h"
#include <Preferences.h>
#include <ArduinoJson.h>
@@ -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<String>().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<String>().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<String>().c_str(), b.double_action.wol_mac);
}
layer->bindings[layer->binding_count++] = b;
}
+9 -2
View File
@@ -1,4 +1,5 @@
#include "key_state_machine.h"
#include "wol_manager.h"
#include "app_config.h"
#include <string.h>
#include <freertos/FreeRTOS.h>
@@ -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 {
+6 -1
View File
@@ -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 {
+14
View File
@@ -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;
}
+96
View File
@@ -0,0 +1,96 @@
#include "wol_manager.h"
#include "log/app_log.h"
#include "wifi/wifi_manager.h"
#include "led_indicator.h"
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
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"
+37
View File
@@ -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 <WebServer.h>
#include <ArduinoJson.h>
@@ -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<const char*>()) mac = doc["mac"].as<String>();
if (doc["port"].is<int>()) port = (uint16_t)doc["port"].as<int>();
}
}
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);
+254 -12
View File
@@ -305,7 +305,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div class="stat-card">
<div class="stat-title">蓝牙遥控器连接状态</div>
<div class="stat-val" id="stat-ble-state" style="color: var(--accent-green);">已连接</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;" id="stat-ble-name">小米蓝牙语音遥控器</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px; display: flex; justify-content: space-between; align-items: center;">
<span id="stat-ble-name" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 130px;">小米蓝牙语音遥控器</span>
<span id="stat-ble-bat" style="font-weight: 600; color: var(--accent-green);">--</span>
</div>
</div>
<div class="stat-card">
<div class="stat-title">Wi-Fi 局域网 IP</div>
@@ -502,12 +505,17 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<button class="btn btn-danger" style="font-size: 12px;" onclick="unpairBleDevice()">清空蓝牙配对信息</button>
</div>
</div>
<div class="grid-2" style="margin-top: 14px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; margin-top: 14px;">
<div style="background: #090d16; border: 1px solid var(--border-color); border-radius: 10px; padding: 14px;">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">实时物理连接</div>
<div id="ble-detail-conn-state" style="font-size: 16px; font-weight: bold; color: var(--text-muted);">正在获取...</div>
<div id="ble-detail-conn-device" style="font-size: 13px; color: var(--text-muted); margin-top: 4px; font-family: monospace;">-</div>
</div>
<div style="background: #090d16; border: 1px solid var(--border-color); border-radius: 10px; padding: 14px;">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">遥控器电池电量</div>
<div id="ble-detail-bat-pct" style="font-size: 16px; font-weight: bold; color: var(--text-muted);">正在获取...</div>
<div id="ble-detail-bat-tip" style="font-size: 13px; color: var(--text-muted); margin-top: 4px;">-</div>
</div>
<div style="background: #090d16; border: 1px solid var(--border-color); border-radius: 10px; padding: 14px;">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">NVS 固化绑定目标 (专属独占)</div>
<div id="ble-detail-bound-info" style="font-size: 16px; font-weight: bold; color: var(--accent-cyan);">正在获取...</div>
@@ -788,6 +796,10 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">切换层级</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">切换目标层</div>
</div>
<div class="mode-card" id="mode-card-11" onclick="selectActionMode(11)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">高级扩展</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">WOL网络唤醒等</div>
</div>
<div class="mode-card" id="mode-card-10" onclick="selectActionMode(10)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">穿透继承</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">继承默认层配置</div>
@@ -814,6 +826,60 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<!-- Advanced Extension Box (Mode 11) -->
<div id="adv-action-config-box" style="display:none; background: #090d16; border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; margin-bottom: 18px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:8px; font-weight:600;">高级扩展功能选择</label>
<!-- Sub tabs for Advanced Features -->
<div style="display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap;">
<button type="button" class="tab-btn active" id="adv-tab-wol" onclick="switchAdvSubTab('wol')" style="padding: 6px 14px; font-size: 12px; display: flex; align-items: center; gap: 6px;">
<span>🌐 局域网唤醒 (WOL)</span>
</button>
<button type="button" class="tab-btn" id="adv-tab-macro" onclick="switchAdvSubTab('macro')" style="padding: 6px 14px; font-size: 12px; display: flex; align-items: center; gap: 6px; opacity: 0.7;">
<span>⌨️ 按键宏序列</span>
<span style="font-size: 10px; background: rgba(245, 158, 11, 0.2); color: #f59e0b; padding: 1px 6px; border-radius: 4px; border: 1px solid rgba(245, 158, 11, 0.4);">即将推出</span>
</button>
<button type="button" class="tab-btn" id="adv-tab-http" onclick="switchAdvSubTab('http')" style="padding: 6px 14px; font-size: 12px; display: flex; align-items: center; gap: 6px; opacity: 0.7;">
<span>🔗 HTTP Webhook</span>
<span style="font-size: 10px; background: rgba(245, 158, 11, 0.2); color: #f59e0b; padding: 1px 6px; border-radius: 4px; border: 1px solid rgba(245, 158, 11, 0.4);">即将推出</span>
</button>
<button type="button" class="tab-btn" id="adv-tab-mqtt" onclick="switchAdvSubTab('mqtt')" style="padding: 6px 14px; font-size: 12px; display: flex; align-items: center; gap: 6px; opacity: 0.7;">
<span>📡 MQTT 物联网</span>
<span style="font-size: 10px; background: rgba(245, 158, 11, 0.2); color: #f59e0b; padding: 1px 6px; border-radius: 4px; border: 1px solid rgba(245, 158, 11, 0.4);">即将推出</span>
</button>
</div>
<!-- WOL Configuration Panel -->
<div id="adv-panel-wol" style="display: block;">
<div style="margin-bottom: 12px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px;">目标计算机 MAC 地址</label>
<input type="text" id="adv-wol-mac" placeholder="例如: 34:97:F6:88:99:AA" style="width:100%; padding:9px 12px; background:#151d2a; border:1px solid #243247; color:#fff; border-radius:8px; font-size:13px; font-family:monospace; outline:none;" oninput="onWolMacChange()">
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">支持冒号分隔 (AA:BB:CC...)、减号分隔 (AA-BB-CC...) 或连续 12 位字符。</div>
</div>
<div style="display: flex; gap: 12px; align-items: flex-end;">
<div style="width: 120px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px;">唤醒端口 (UDP)</label>
<input type="number" id="adv-wol-port" value="9" min="1" max="65535" style="width:100%; padding:9px 12px; background:#151d2a; border:1px solid #243247; color:#fff; border-radius:8px; font-size:13px; outline:none;">
</div>
<button type="button" class="tab-btn" onclick="testSendWol()" style="flex: 1; padding: 9px 16px; background: rgba(6, 182, 212, 0.15); border: 1px solid var(--accent-cyan); color: var(--accent-cyan); cursor: pointer; border-radius: 8px; font-weight: 600;">
⚡ 发送测试唤醒包
</button>
</div>
<div style="margin-top:12px; padding:10px 14px; background:rgba(6,182,212,0.1); border-radius:8px; border:1px dashed rgba(6,182,212,0.3); font-size:12px; color:var(--text-muted); line-height:1.5;">
💡 提示:按下按键时,ESP32 将向目标计算机广播 Wake-on-LAN 唤醒魔术包(Magic Packet)。请确保被唤醒主机的主板 BIOS / 网卡驱动已开启 WOL 唤醒支持。
</div>
</div>
<!-- Placeholder Panels -->
<div id="adv-panel-placeholder" style="display: none; text-align: center; padding: 24px 16px; background: #151d2a; border-radius: 8px; border: 1px dashed var(--border-color);">
<div id="adv-placeholder-icon" style="font-size: 28px; margin-bottom: 8px;">🚀</div>
<div id="adv-placeholder-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin-bottom: 6px;">功能规划中</div>
<div id="adv-placeholder-desc" style="font-size: 12px; color: var(--text-muted); max-width: 420px; margin: 0 auto; line-height: 1.6;">
此高级扩展功能已在产品规划路线图中,将在后续固件版本中逐步推送更新!
</div>
</div>
</div>
<!-- Keyboard Direct Capture Box -->
<div class="key-recorder-box" id="key-recorder-box" tabindex="0" onclick="startKeyboardRecording()">
<div style="font-size: 13px; color: var(--text-muted);" id="recorder-instruction">
@@ -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 = `<span class="kbd-chip" style="border-color:var(--accent-orange); color:var(--accent-orange);">🌐 WOL 唤醒: ${mac || '未配置MAC'}</span>`;
return;
}
if (type === 0 || (!key && !cons && !mod)) {
display.innerHTML = '<span style="color: var(--text-muted); font-size: 14px; font-weight: normal;">未设置(敲击键盘录制,或在下方选择)</span>';
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 || '已绑定遥控器';