feat: add Chinese BLE name matching, Web manual scan, and 1-click pairing
This commit is contained in:
1 parent
39390838e2
commit
8ab8a9a681
8 files changed
+461
-59
No files matched your search
+178
-29
@@ -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"
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user