feat: add Chinese BLE name matching, Web manual scan, and 1-click pairing

This commit is contained in:
cuicuiV5 committed 2026-08-28 13:52:06 +08:00
1 parent 39390838e2
commit 8ab8a9a681
8 files changed
+461 -59

No files matched your search

+178 -29
View File
@@ -2,8 +2,12 @@
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include "usb/usb_composite.h"
#include "log/app_log.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <vector>
static ble_remote_state_t s_ble_state = BLE_STATE_DISCONNECTED;
static NimBLEClient* s_client = nullptr;
@@ -11,6 +15,11 @@ static NimBLERemoteCharacteristic* s_char_cmd = nullptr;
static NimBLERemoteCharacteristic* s_char_aud = nullptr;
static NimBLERemoteCharacteristic* s_char_ctl = nullptr;
static Preferences s_ble_prefs;
static String s_bound_mac = "";
static String s_connected_name = "";
static String s_connected_mac = "";
static uint8_t s_session_id = 0;
static uint32_t s_last_audio_ms = 0;
static uint32_t s_last_extend_ms = 0;
@@ -22,6 +31,7 @@ extern key_mapper_engine_t g_key_engine;
// Forward Declarations
static void start_scan();
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice);
static bool connect_to_mac_internal(const NimBLEAddress& address, const String& dev_name);
// Audio Notification Callback (ATVV Char 0x03)
static void on_audio_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
@@ -45,7 +55,7 @@ static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, siz
// Trigger Voice Hold action (hold hotkey & reset audio DSP)
key_action_t act = { ACTION_VOICE_HOLD, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
Serial.printf("[ATVV] >>> Voice button PRESSED (session %d)\n", s_session_id);
app_log("ATVV", ">>> Voice button PRESSED (session %d)", s_session_id);
}
// AUDIO_STOP / MIC_CLOSED / release op:
else if (op == 0x00) {
@@ -53,7 +63,7 @@ static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, siz
s_ble_state = BLE_STATE_CONNECTED;
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
Serial.println("[ATVV] <<< Voice button RELEASED");
app_log("ATVV", "<<< Voice button RELEASED");
// Re-arm remote HTT standby
if (s_char_cmd != nullptr) {
@@ -67,14 +77,14 @@ static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, siz
uint16_t ver = (pData[1] << 8) | pData[2];
uint16_t fs = (pData[5] << 8) | pData[6];
if (fs > 0) s_frame_size = fs;
Serial.printf("[ATVV] CAPS: ver=0x%04X, frame_size=%d\n", ver, (int)s_frame_size);
app_log("ATVV", "CAPS: ver=0x%04X, frame_size=%d", ver, (int)s_frame_size);
}
// AUDIO_SYNC: op == 0x0A
else if (op == 0x0A && length >= 7) {
int16_t pred = (int16_t)((pData[4] << 8) | pData[5]);
int8_t step_idx = (int8_t)pData[6];
audio_pipeline_sync(&g_audio_pipeline, pred, step_idx);
Serial.printf("[ATVV] SYNC: pred=%d, step=%d\n", pred, step_idx);
app_log("ATVV", "SYNC: pred=%d, step=%d", pred, step_idx);
}
}
@@ -84,22 +94,58 @@ static void on_hogp_report_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pD
uint8_t raw_key = pData[0];
bool is_pressed = (length > 1) ? (pData[1] != 0) : (raw_key != 0);
Serial.printf("[HOGP] Key byte: 0x%02X (%s)\n", raw_key, is_pressed ? "DOWN" : "UP");
app_log("HOGP", "Key byte: 0x%02X (%s)", raw_key, is_pressed ? "DOWN" : "UP");
key_engine_feed_key(&g_key_engine, raw_key, is_pressed, millis());
}
static bool is_target_remote(NimBLEAdvertisedDevice* dev) {
String name = dev->getName().c_str();
String addr = dev->getAddress().toString().c_str();
addr.toLowerCase();
// 1. Exact match with previously bound remote
if (s_bound_mac.length() > 0 && addr.equalsIgnoreCase(s_bound_mac)) {
return true;
}
// 2. Name contains Xiaomi / Remote keywords in Chinese & English
if (name.indexOf("小米") >= 0 || name.indexOf("遥控") >= 0 ||
name.indexOf("MI RC") >= 0 || name.indexOf("Xiaomi") >= 0 ||
name.indexOf("Remote") >= 0 || name.indexOf("RC") >= 0) {
return true;
}
// 3. Service UUID matches ATVV or HID
if (dev->haveServiceUUID()) {
if (dev->isAdvertisingService(NimBLEUUID(ATVV_SVC_UUID)) ||
dev->isAdvertisingService(NimBLEUUID((uint16_t)HOGP_SVC_UUID))) {
return true;
}
}
// 4. Common Xiaomi Bluetooth OUI prefixes
if (addr.startsWith("c0:5d:39") || addr.startsWith("64:90:c1") ||
addr.startsWith("7c:49:eb") || addr.startsWith("50:ec:50") ||
addr.startsWith("04:cf:8c") || addr.startsWith("28:6c:07") ||
addr.startsWith("34:ce:00") || addr.startsWith("5c:c3:06")) {
return true;
}
return false;
}
// Advertised Device Scan Callbacks
class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice* advertisedDevice) override {
String name = advertisedDevice->getName().c_str();
String addr = advertisedDevice->getAddress().toString().c_str();
addr.toLowerCase();
bool match_name = name.indexOf(BLE_REMOTE_NAME_PREFIX) >= 0;
bool match_mac = addr.startsWith(BLE_REMOTE_MAC_PREFIX);
if (name.length() > 0) {
app_log("BLE_SCAN", "Device: %s (%s, RSSI: %d)", name.c_str(), addr.c_str(), advertisedDevice->getRSSI());
}
if (match_name || match_mac) {
Serial.printf("[BLE] Found Target Remote: %s (%s), RSSI: %d\n", name.c_str(), addr.c_str(), advertisedDevice->getRSSI());
if (is_target_remote(advertisedDevice)) {
app_log("BLE", "Matching Target Remote: %s (%s), connecting...", name.c_str(), addr.c_str());
NimBLEDevice::getScan()->stop();
connect_to_remote(advertisedDevice);
}
@@ -109,12 +155,12 @@ class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
// Client Connection Callbacks
class ClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient* pClient) override {
Serial.println("[BLE] Remote Connected");
app_log("BLE", "Remote GATT Connected!");
s_ble_state = BLE_STATE_CONNECTED;
}
void onDisconnect(NimBLEClient* pClient) override {
Serial.println("[BLE] Remote Disconnected");
app_log("BLE", "Remote Disconnected");
s_ble_state = BLE_STATE_DISCONNECTED;
s_char_cmd = nullptr;
s_char_aud = nullptr;
@@ -129,22 +175,12 @@ static void start_scan() {
pScan->setActiveScan(true);
pScan->setInterval(BLE_SCAN_INTERVAL_MS);
pScan->setWindow(BLE_SCAN_WINDOW_MS);
pScan->start(5, false);
Serial.println("[BLE] Scanning for Xiaomi Remote (MI RC)...");
pScan->start(6, false);
app_log("BLE", "Scanning for Xiaomi Bluetooth Remote...");
}
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
s_ble_state = BLE_STATE_CONNECTING;
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
}
if (!s_client->connect(advDevice)) {
Serial.println("[BLE] Connection Failed");
s_ble_state = BLE_STATE_DISCONNECTED;
return false;
}
static bool setup_services_and_handshake() {
if (!s_client || !s_client->isConnected()) return false;
// Discover ATVV Service
NimBLERemoteService* pAtvvSvc = s_client->getService(NimBLEUUID(ATVV_SVC_UUID));
@@ -167,7 +203,7 @@ static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
delay(150);
uint8_t cmd_open[] = { 0x0C, 0x00 };
s_char_cmd->writeValue(cmd_open, sizeof(cmd_open), false);
Serial.println("[ATVV] Handshake completed successfully");
app_log("ATVV", "Handshake completed successfully (MIC_OPEN active)");
}
}
@@ -177,7 +213,7 @@ static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
NimBLERemoteCharacteristic* pReportChar = pHogpSvc->getCharacteristic(NimBLEUUID((uint16_t)HOGP_REPORT_CHAR_UUID));
if (pReportChar && pReportChar->canNotify()) {
pReportChar->subscribe(true, on_hogp_report_notify);
Serial.println("[HOGP] Subscribed to HID Key Reports");
app_log("HOGP", "Subscribed to HID Key Reports");
}
}
@@ -185,9 +221,71 @@ static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
return true;
}
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
s_ble_state = BLE_STATE_CONNECTING;
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
}
if (!s_client->connect(advDevice)) {
app_log("BLE", "Connection Failed to %s", advDevice->getAddress().toString().c_str());
s_ble_state = BLE_STATE_DISCONNECTED;
return false;
}
s_connected_name = advDevice->getName().c_str();
s_connected_mac = advDevice->getAddress().toString().c_str();
if (s_connected_name.length() == 0) s_connected_name = "Xiaomi Voice Remote";
// Save bound MAC to NVS
s_bound_mac = s_connected_mac;
s_ble_prefs.putString("bound_mac", s_bound_mac);
s_ble_prefs.putString("bound_name", s_connected_name);
app_log("BLE", "Bound and saved remote: %s (%s)", s_connected_name.c_str(), s_bound_mac.c_str());
return setup_services_and_handshake();
}
static bool connect_to_mac_internal(const NimBLEAddress& address, const String& dev_name) {
s_ble_state = BLE_STATE_CONNECTING;
NimBLEDevice::getScan()->stop();
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
} else if (s_client->isConnected()) {
s_client->disconnect();
}
app_log("BLE", "Connecting directly to MAC: %s...", address.toString().c_str());
if (!s_client->connect(address)) {
app_log("BLE", "Direct connection to %s failed", address.toString().c_str());
s_ble_state = BLE_STATE_DISCONNECTED;
return false;
}
s_connected_mac = address.toString().c_str();
s_connected_name = dev_name.length() > 0 ? dev_name : "Xiaomi Voice Remote";
s_bound_mac = s_connected_mac;
s_ble_prefs.putString("bound_mac", s_bound_mac);
s_ble_prefs.putString("bound_name", s_connected_name);
app_log("BLE", "Manually paired and saved: %s (%s)", s_connected_name.c_str(), s_bound_mac.c_str());
return setup_services_and_handshake();
}
extern "C" {
void ble_remote_init(void) {
s_ble_prefs.begin("ble_conf", false);
s_bound_mac = s_ble_prefs.getString("bound_mac", "");
String bound_name = s_ble_prefs.getString("bound_name", "");
if (s_bound_mac.length() > 0) {
app_log("BLE", "Loaded previously bound remote: %s (%s)", bound_name.c_str(), s_bound_mac.c_str());
}
NimBLEDevice::init("ESP32-RemoteBridge");
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
NimBLEDevice::setSecurityAuth(true, true, true);
@@ -209,7 +307,7 @@ void ble_remote_task(void) {
// 2. Audio silence watchdog (if voice key released packet dropped)
if (s_ble_state == BLE_STATE_TALKING) {
if (now - s_last_audio_ms > BLE_SILENCE_WATCHDOG_MS) {
Serial.println("[ATVV] Silence watchdog expired -> force stopping speech");
app_log("ATVV", "Silence watchdog expired -> force stopping speech");
s_ble_state = BLE_STATE_CONNECTED;
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
@@ -235,4 +333,55 @@ void ble_remote_trigger_reconnect(void) {
start_scan();
}
String ble_remote_scan_devices_json(void) {
app_log("BLE", "Performing full 4s BLE scan for nearby devices...");
NimBLEScan* pScan = NimBLEDevice::getScan();
pScan->stop();
NimBLEScanResults results = pScan->start(4, false);
JsonDocument doc;
JsonArray arr = doc["devices"].to<JsonArray>();
for (int i = 0; i < results.getCount(); i++) {
NimBLEAdvertisedDevice dev = results.getDevice(i);
JsonObject obj = arr.add<JsonObject>();
String name = dev.getName().c_str();
if (name.length() == 0) name = "Unnamed BLE Device";
obj["name"] = name;
obj["mac"] = dev.getAddress().toString().c_str();
obj["rssi"] = dev.getRSSI();
}
String out;
serializeJson(doc, out);
return out;
}
bool ble_remote_connect_mac(const String& mac_str) {
if (mac_str.length() == 0) return false;
NimBLEAddress addr(mac_str.c_str());
return connect_to_mac_internal(addr, "Xiaomi Remote");
}
void ble_remote_unpair(void) {
s_bound_mac = "";
s_connected_name = "";
s_connected_mac = "";
s_ble_prefs.remove("bound_mac");
s_ble_prefs.remove("bound_name");
app_log("BLE", "Unpaired and cleared saved remote MAC");
ble_remote_trigger_reconnect();
}
String ble_remote_get_connected_info(void) {
JsonDocument doc;
doc["connected"] = (s_ble_state >= BLE_STATE_CONNECTED);
doc["name"] = s_connected_name;
doc["mac"] = s_connected_mac;
doc["bound_mac"] = s_bound_mac;
String out;
serializeJson(doc, out);
return out;
}
} // extern "C"
+21
View File
@@ -3,6 +3,7 @@
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include <Arduino.h>
#include "app_config.h"
#ifdef __cplusplus
@@ -37,6 +38,26 @@ ble_remote_state_t ble_remote_get_state(void);
*/
void ble_remote_trigger_reconnect(void);
/**
* @brief Scan for nearby BLE devices and return JSON list
*/
String ble_remote_scan_devices_json(void);
/**
* @brief Manually connect and pair to specific BLE device by MAC address
*/
bool ble_remote_connect_mac(const String& mac_str);
/**
* @brief Unpair and clear saved remote MAC address
*/
void ble_remote_unpair(void);
/**
* @brief Get connected remote name and MAC
*/
String ble_remote_get_connected_info(void);
#ifdef __cplusplus
}
#endif
+6 -8
View File
@@ -24,28 +24,26 @@ static void ble_task_core0(void* param) {
}
void setup() {
// 1. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
usb_composite_init();
Serial.begin(115200);
delay(400);
delay(500);
// 1. Initialize Global Log System
// 2. Initialize Global Log System
app_log_init();
app_log("SYSTEM", "==================================================");
app_log("SYSTEM", " %s v%s (%s)", FIRMWARE_NAME, FIRMWARE_VERSION, HARDWARE_TARGET);
app_log("SYSTEM", " Xiaomi Remote Hardware Bridge (BLE -> USB + Web)");
app_log("SYSTEM", "==================================================");
// 2. Initialize Audio Pipeline
// 3. Initialize Audio Pipeline
audio_pipeline_init(&g_audio_pipeline);
app_log("INIT", "Audio Pipeline initialized (16kHz 16-bit Mono UAC 1.0)");
// 3. Initialize Key Engine with USB HID dispatcher callback
// 4. Initialize Key Engine with USB HID dispatcher callback
key_engine_init(&g_key_engine, usb_hid_dispatch_action);
app_log("INIT", "Key Engine initialized with %u mappings", (unsigned int)g_key_engine.binding_count);
// 4. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
usb_composite_init();
app_log("INIT", "USB Composite Device ready");
// 5. Initialize Serial / CDC CLI Manager
cli_manager_init();
+41
View File
@@ -81,6 +81,43 @@ static void handle_keymap_reset() {
s_server.send(200, "application/json", "{\"status\":\"reset_ok\"}");
}
static void handle_ble_scan() {
String json = ble_remote_scan_devices_json();
s_server.send(200, "application/json", json);
}
static void handle_ble_connect() {
if (!s_server.hasArg("plain")) {
s_server.send(400, "application/json", "{\"error\":\"missing_body\"}");
return;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s_server.arg("plain"));
if (err) {
s_server.send(400, "application/json", "{\"error\":\"invalid_json\"}");
return;
}
String mac = doc["mac"] | "";
if (mac.length() == 0) {
s_server.send(400, "application/json", "{\"error\":\"empty_mac\"}");
return;
}
bool ok = ble_remote_connect_mac(mac);
s_server.send(200, "application/json", ok ? "{\"status\":\"connected\"}" : "{\"status\":\"failed\"}");
}
static void handle_ble_unpair() {
ble_remote_unpair();
s_server.send(200, "application/json", "{\"status\":\"unpaired\"}");
}
static void handle_ble_info() {
String json = ble_remote_get_connected_info();
s_server.send(200, "application/json", json);
}
static void handle_ble_reconnect() {
app_log("BLE", "Triggered manual reconnect scan via Web API");
ble_remote_trigger_reconnect();
@@ -113,6 +150,10 @@ void web_server_init(void) {
s_server.on("/api/wifi/scan", HTTP_GET, handle_wifi_scan);
s_server.on("/api/wifi/config", HTTP_POST, handle_wifi_config);
s_server.on("/api/keymap/reset", HTTP_POST, handle_keymap_reset);
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);
s_server.on("/api/ble/info", HTTP_GET, handle_ble_info);
s_server.on("/api/ble/reconnect", HTTP_POST, handle_ble_reconnect);
s_server.on("/api/system/restart", HTTP_POST, handle_system_restart);
+120 -22
View File
@@ -49,7 +49,7 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
/* Grid & Cards */
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 20px; }
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 16px; }
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
.card-title { font-size: 15px; color: var(--text-bright); font-weight: 600; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
.stat-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.stat-row:last-child { border-bottom: none; }
@@ -74,7 +74,10 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
.log-box { background: #010409; border: 1px solid var(--card-border); border-radius: var(--radius); height: 360px; overflow-y: auto; padding: 12px; font-family: Consolas, monospace; font-size: 12px; line-height: 1.6; color: #7ee787; }
.log-line { margin-bottom: 2px; word-break: break-all; }
/* Keymap list */
/* List items */
.dev-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #0d1117; border: 1px solid var(--card-border); border-radius: var(--radius); margin-bottom: 8px; }
.dev-name { font-weight: 600; color: var(--text-bright); }
.dev-mac { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.key-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; background: #0d1117; border: 1px solid var(--card-border); border-radius: var(--radius); margin-bottom: 8px; }
.key-badge { background: #21262d; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; color: var(--accent); }
.key-desc { font-size: 12px; color: var(--text-muted); }
@@ -91,7 +94,7 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<div class="status-pills">
<div class="pill green" id="pill-ble">BLE: 已连接</div>
<div class="pill yellow" id="pill-ble">BLE: 搜索中</div>
<div class="pill blue" id="pill-wifi">Wi-Fi: AP+STA</div>
<div class="pill yellow" id="pill-sta-ip">IP: 192.168.4.1</div>
</div>
@@ -99,8 +102,8 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div class="tabs">
<button class="tab-btn active" onclick="showTab('dashboard')">📊 状态仪表盘</button>
<button class="tab-btn" onclick="showTab('ble')">📡 蓝牙管理与配对</button>
<button class="tab-btn" onclick="showTab('keymap')">🎮 按键配置</button>
<button class="tab-btn" onclick="showTab('ble')">📡 蓝牙管理</button>
<button class="tab-btn" onclick="showTab('wifi')">📶 Wi-Fi 配网</button>
<button class="tab-btn" onclick="showTab('logs')">📜 实时日志</button>
</div>
@@ -110,12 +113,14 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div class="grid-2">
<div class="card">
<div class="card-title">🎙️ BLE 遥控器与音频流</div>
<div class="stat-row"><span class="stat-label">连接状态</span><span class="stat-val" id="stat-ble-state">Connected</span></div>
<div class="stat-row"><span class="stat-label">连接状态</span><span class="stat-val" id="stat-ble-state">Scanning...</span></div>
<div class="stat-row"><span class="stat-label">已绑定遥控器</span><span class="stat-val" id="stat-bound-remote">未绑定</span></div>
<div class="stat-row"><span class="stat-label">音频规格</span><span class="stat-val">16kHz 16-bit Mono (UAC 1.0)</span></div>
<div class="stat-row"><span class="stat-label">已解码音频帧</span><span class="stat-val" id="stat-frames">0 帧</span></div>
<div class="stat-row"><span class="stat-label">已推流采样点</span><span class="stat-val" id="stat-samples">0 点</span></div>
<div style="margin-top: 14px; display: flex; gap: 8px;">
<button class="btn btn-primary" onclick="apiAction('/api/ble/reconnect')">🔄 重新扫描连接</button>
<button class="btn btn-primary" onclick="showTab('ble')">📡 前往蓝牙配对</button>
<button class="btn" onclick="apiAction('/api/ble/reconnect')">🔄 重新扫描</button>
</div>
</div>
@@ -139,11 +144,36 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<!-- 2. Keymap Tab -->
<!-- 2. BLE Management Tab -->
<div id="tab-ble" class="tab-content">
<div class="card">
<div class="card-title">
<span>🔗 当前绑定设备</span>
<button class="btn btn-danger" onclick="unpairBle()">❌ 解除绑定</button>
</div>
<div class="stat-row"><span class="stat-label">设备名称</span><span class="stat-val" id="ble-info-name">未连接</span></div>
<div class="stat-row"><span class="stat-label">MAC 地址</span><span class="stat-val" id="ble-info-mac">--</span></div>
</div>
<div class="card">
<div class="card-title">
<span>📡 扫描周围蓝牙设备 (手动配对)</span>
<button class="btn btn-primary" id="btn-scan-ble" onclick="scanBle()">🔍 开始扫描 (4秒)</button>
</div>
<p style="color:var(--text-muted); margin-bottom: 10px;">
提示:请先长按遥控器 <strong>主页键 + 菜单键</strong> 约 3 秒(指示灯闪烁),然后点击上方扫描按钮,在下方列表中点击“连接”。
</p>
<div id="ble-list">
<p style="color:var(--text-muted); padding: 12px; text-align: center;">暂未扫描,请点击上方按钮扫描周围蓝牙遥控器...</p>
</div>
</div>
</div>
<!-- 3. Keymap Tab -->
<div id="tab-keymap" class="tab-content">
<div class="card">
<div class="card-title">
<span>🎮 默认物理按键映射规则</span>
<span>🎮 物理按键映射规则</span>
<button class="btn btn-primary" onclick="resetKeymap()">恢复默认映射</button>
</div>
<div class="key-item">
@@ -177,19 +207,6 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<!-- 3. BLE Tab -->
<div id="tab-ble" class="tab-content">
<div class="card">
<div class="card-title">
<span>📡 扫描周围蓝牙遥控器</span>
<button class="btn btn-primary" onclick="scanBle()">🔍 开始扫描</button>
</div>
<div id="ble-list" style="margin-top: 10px;">
<p style="color:var(--text-muted);">点击上方按钮扫描周围的小米蓝牙语音遥控器 (MI RC)...</p>
</div>
</div>
</div>
<!-- 4. Wi-Fi Tab -->
<div id="tab-wifi" class="tab-content">
<div class="card">
@@ -238,13 +255,15 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
event.target.classList.add('active');
document.getElementById('tab-' + id).classList.add('active');
if (id === 'logs') refreshLogs();
if (id === 'ble') fetchBleInfo();
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('stat-ble-state').innerText = data.ble_state === 3 ? 'Connected (已连接)' : (data.ble_state === 4 ? 'Talking (语音推流中)' : 'Scanning / Disconnected');
const stateText = data.ble_state === 3 ? 'Connected (已连接)' : (data.ble_state === 4 ? 'Talking (语音推流中)' : 'Scanning / Disconnected');
document.getElementById('stat-ble-state').innerText = stateText;
document.getElementById('pill-ble').innerText = 'BLE: ' + (data.ble_state >= 3 ? '已连接' : '未连接');
document.getElementById('pill-ble').className = 'pill ' + (data.ble_state >= 3 ? 'green' : 'yellow');
document.getElementById('stat-frames').innerText = (data.frames_decoded || 0) + ' 帧';
@@ -257,6 +276,79 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
} catch (e) {}
}
async function fetchBleInfo() {
try {
const res = await fetch('/api/ble/info');
const data = await res.json();
document.getElementById('ble-info-name').innerText = (data.name || '未连接') + (data.connected ? ' (在线)' : '');
document.getElementById('ble-info-mac').innerText = data.mac || (data.bound_mac ? data.bound_mac + ' (已保存)' : '--');
document.getElementById('stat-bound-remote').innerText = data.name ? (data.name + ' (' + data.mac + ')') : '未绑定';
} catch (e) {}
}
async function scanBle() {
const btn = document.getElementById('btn-scan-ble');
const box = document.getElementById('ble-list');
btn.innerText = '⏳ 正在扫描中...';
btn.disabled = true;
box.innerHTML = '<p style="color:var(--accent); padding:12px; text-align:center;">正在扫描周围蓝牙设备 (4秒),请确保遥控器处于配对闪烁状态...</p>';
try {
const res = await fetch('/api/ble/scan');
const data = await res.json();
box.innerHTML = '';
if (!data.devices || data.devices.length === 0) {
box.innerHTML = '<p style="color:var(--warning); padding:12px; text-align:center;">未发现蓝牙设备,请长按遥控器 主页+菜单 键后重试!</p>';
} else {
data.devices.forEach(dev => {
const isMi = dev.name.includes('小米') || dev.name.includes('MI') || dev.name.includes('Xiaomi') || dev.name.includes('Remote');
const div = document.createElement('div');
div.className = 'dev-item';
div.innerHTML = `
<div>
<div class="dev-name">${dev.name} ${isMi ? '⭐' : ''}</div>
<div class="dev-mac">${dev.mac} | 信号: ${dev.rssi} dBm</div>
</div>
<button class="btn btn-primary" onclick="connectBle('${dev.mac}')">🔗 连接此设备</button>
`;
box.appendChild(div);
});
}
} catch (e) {
box.innerHTML = '<p style="color:var(--danger); padding:12px; text-align:center;">扫描出错,请重试!</p>';
} finally {
btn.innerText = '🔍 开始扫描 (4秒)';
btn.disabled = false;
}
}
async function connectBle(mac) {
if (confirm('确定要连接并绑定 MAC: ' + mac + ' 吗?')) {
const res = await fetch('/api/ble/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac })
});
const data = await res.json();
if (data.status === 'connected') {
alert('连接成功并已保存绑定!');
fetchBleInfo();
fetchStatus();
} else {
alert('连接失败,请确保遥控器在旁边且处于配对状态!');
}
}
}
async function unpairBle() {
if (confirm('确定解除当前遥控器绑定?')) {
await fetch('/api/ble/unpair', { method: 'POST' });
alert('已清除绑定!');
fetchBleInfo();
fetchStatus();
}
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs');
@@ -273,6 +365,11 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
} catch (e) {}
}
async function clearLogs() {
await fetch('/api/logs/clear', { method: 'POST' });
refreshLogs();
}
async function apiAction(url) {
if (confirm('确定要执行此操作吗?')) {
await fetch(url, { method: 'POST' });
@@ -319,6 +416,7 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
setInterval(fetchStatus, 2000);
fetchStatus();
fetchBleInfo();
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
import serial
import time
import sys
def main():
try:
ser = serial.Serial('COM10', 115200, timeout=0.1)
except Exception as e:
print(f"Failed to open COM10: {e}")
return
print("==================================================")
print(" RemoteMapper ESP32-S3 Live Monitor Started")
print("==================================================")
last_status_time = 0
try:
while True:
# Poll status every 5 seconds if idle
now = time.time()
if now - last_status_time > 5:
ser.write(b'status\n')
last_status_time = now
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
# Print any log or status
print(line)
time.sleep(0.02)
except KeyboardInterrupt:
pass
finally:
ser.close()
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
import serial
import serial.tools.list_ports
import time
def find_esp32_port():
for p in serial.tools.list_ports.comports():
if "303A" in p.hwid.upper():
return p.device
return None
def monitor():
print("Waiting for ESP32-S3 port to appear...")
port = None
for _ in range(30):
port = find_esp32_port()
if port:
break
time.sleep(0.5)
if not port:
print("No ESP32 port found.")
return
print(f"Found ESP32 on {port}, connecting at 115200 baud...")
try:
ser = serial.Serial(port, 115200, timeout=0.2)
print("Connected! Listening for logs (Press Ctrl+C to exit):")
print("=" * 60)
t_end = time.time() + 15
while time.time() < t_end:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(line)
ser.close()
except Exception as e:
print(f"Serial error: {e}")
if __name__ == "__main__":
monitor()
+20
View File
@@ -0,0 +1,20 @@
import serial
import time
import sys
def stream():
ser = serial.Serial('COM10', 115200, timeout=0.1)
print("Listening to RemoteMapper ESP32-S3 (COM10)...")
ser.write(b'status\n')
t0 = time.time()
try:
while time.time() - t0 < 20:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(line)
time.sleep(0.01)
finally:
ser.close()
if __name__ == "__main__":
stream()