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:
1 parent
68badb5800
commit
86f5d801b2
19 files changed
+582
-39
No files matched your search
@@ -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
@@ -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) 中发起讨论!
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 || '已绑定遥控器';
|
||||
|
||||
Reference in new issue
Block a user