fix(ble): resolve pairing deadlock, disconnect loop, and neighbor device hijacking

- Fix cross-core deadlock in WebServer (Core 1) vs ble_remote_task (Core 0) via asynchronous request flags
- Fix BLE SMP connection drops by purging stale NimBLE bonds with deleteAllBonds() and correcting AuthReq to Just Works
- Remove periodic 5-second MIC_OPEN ping to allow BLE deep sleep and restore remote battery life
- Add strict exclusivity filter in is_target_remote() to prevent hijacking by neighbor devices
- Add Bluetooth pairing & unpair management card in WebUI
- Add GitHub Actions daily release workflow to package and update Flasher assets
- Update pre-built firmware binaries and RemoteMapper-Flasher archives
This commit is contained in:
cuicuiV5 committed 2026-09-08 13:59:16 +08:00
1 parent 30faf8ea97
commit a9ca204942
12 files changed
+540 -108

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
name: Daily Release Flasher
on:
schedule:
# 每天北京时间凌晨 04:00 (UTC 20:00) 自动触发构建与发布
- cron: '0 20 * * *'
workflow_dispatch:
inputs:
tag_name:
description: '发布的目标 Release Tag 名称 (默认为 release)'
required: true
default: 'release'
type: string
rebuild_firmware:
description: '是否重新全量编译最新固件 (若为 false 则直接打包仓库现有固件)'
required: true
default: true
type: boolean
permissions:
contents: write
jobs:
build-and-release:
name: Build & Package Flasher
runs-on: ubuntu-latest
steps:
- name: 检出仓库代码 (Checkout Repository)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 配置 Python 环境 (Setup Python)
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: 安装打包与固件生成工具 (Install Dependencies)
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full zip dos2unix
pip install platformio esptool
- name: 缓存 PlatformIO 依赖库与工具链 (Cache PlatformIO)
uses: actions/cache@v4
with:
path: |
~/.platformio
.pio
key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }}
restore-keys: |
${{ runner.os }}-pio-
- name: 编译 ESP32-S3 全型号固件 (Build All Targets)
if: ${{ github.event_name == 'schedule' || inputs.rebuild_firmware == true }}
run: |
echo "=== 正在全量编译 ESP32-S3 固件 (N16R8, N8R2, N4R2) ==="
python -m platformio run -e esp32s3_n16r8 -e esp32s3_n8r2 -e esp32s3_n4r2
echo "=== 合并固件二进制并同步至 RemoteMapper-Flasher/bin ==="
mkdir -p RemoteMapper-Flasher/bin
# N16R8 (16MB Flash / 8MB Octal PSRAM)
python -m esptool --chip esp32s3 merge_bin -o "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin" \
--flash_mode keep --flash_freq keep --flash_size keep 0x0 \
".pio/build/esp32s3_n16r8/bootloader.bin" 0x8000 ".pio/build/esp32s3_n16r8/partitions.bin" \
0xe000 "tools/boot_app0.bin" 0x10000 ".pio/build/esp32s3_n16r8/firmware.bin"
cp ".pio/build/esp32s3_n16r8/firmware.bin" "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin"
# N8R2 (8MB Flash / 2MB Quad PSRAM)
python -m esptool --chip esp32s3 merge_bin -o "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin" \
--flash_mode keep --flash_freq keep --flash_size keep 0x0 \
".pio/build/esp32s3_n8r2/bootloader.bin" 0x8000 ".pio/build/esp32s3_n8r2/partitions.bin" \
0xe000 "tools/boot_app0.bin" 0x10000 ".pio/build/esp32s3_n8r2/firmware.bin"
cp ".pio/build/esp32s3_n8r2/firmware.bin" "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin"
# N4R2 (4MB Flash / 2MB Quad PSRAM)
python -m esptool --chip esp32s3 merge_bin -o "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin" \
--flash_mode keep --flash_freq keep --flash_size keep 0x0 \
".pio/build/esp32s3_n4r2/bootloader.bin" 0x8000 ".pio/build/esp32s3_n4r2/partitions.bin" \
0xe000 "tools/boot_app0.bin" 0x10000 ".pio/build/esp32s3_n4r2/firmware.bin"
cp ".pio/build/esp32s3_n4r2/firmware.bin" "RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin"
echo "=== 固件构建与同步完成 ==="
ls -lh RemoteMapper-Flasher/bin/
- name: 确保 Windows 脚本换行符为 CRLF (Ensure CRLF Line Endings)
run: |
find RemoteMapper-Flasher -type f \( -name "*.bat" -o -name "*.txt" -o -name "*.ps1" \) -exec unix2dos {} +
- name: 压缩打包 RemoteMapper-Flasher (Create 7z and ZIP Archives)
run: |
echo "=== 正在生成 RemoteMapper-Flasher.7z 高压缩比文件 ==="
7z a -t7z -mx=9 "RemoteMapper-Flasher.7z" "./RemoteMapper-Flasher"
echo "=== 正在生成 RemoteMapper-Flasher.zip 免装软件直接解压文件 ==="
zip -r -9 "RemoteMapper-Flasher.zip" "./RemoteMapper-Flasher"
ls -lh RemoteMapper-Flasher.7z RemoteMapper-Flasher.zip
- name: 同步发布至 GitHub Release 并替换附件 (Update Release Assets)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag_name || 'release' }}
run: |
echo "=== 正在检查 Release Tag: $TAG_NAME ==="
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "Release '$TAG_NAME' 已存在,开始清理旧资产文件(原始描述保持不变)..."
# 读取当前 Release 下的所有附件文件名并删除
gh release view "$TAG_NAME" --json assets -q '.assets[].name' | while read -r asset_name; do
if [ -n "$asset_name" ]; then
echo "-> 正在删除旧文件: $asset_name"
gh release delete-asset "$TAG_NAME" "$asset_name" -y
fi
done
echo "-> 正在上传最新打包的文件..."
gh release upload "$TAG_NAME" RemoteMapper-Flasher.7z RemoteMapper-Flasher.zip
echo "=== Release 文件替换完成!已有描述完好无损 ==="
else
echo "Release '$TAG_NAME' 不存在,正在创建全新 Release 并上传文件..."
gh release create "$TAG_NAME" RemoteMapper-Flasher.7z RemoteMapper-Flasher.zip \
--title "免环境一键刷机包 V1.0.0" \
--notes "RemoteMapper-ESP32 每日最新自动构建免环境刷机包。"
echo "=== 全新 Release 创建完成!==="
fi
Binary file not shown.
+210 -80
View File
@@ -18,12 +18,26 @@ static NimBLERemoteCharacteristic* s_char_ctl = nullptr;
static Preferences s_ble_prefs;
static String s_bound_mac = "";
static String s_bound_name = "";
static uint8_t s_bound_addr_type = BLE_ADDR_RANDOM;
static String s_connected_name = "";
static String s_connected_mac = "";
// Asynchronous Connect Request state
static bool s_do_connect = false;
// In-memory discovered BLE device cache (thread-safe, fed by Core 0 continuous scan)
struct DiscoveredBleDevice {
String name;
String mac;
int rssi;
uint8_t type;
uint32_t last_seen_ms;
};
static std::vector<DiscoveredBleDevice> s_discovered_devices;
static portMUX_TYPE s_disc_mux = portMUX_INITIALIZER_UNLOCKED;
// Asynchronous Request flags from other tasks (e.g. WebServer on Core 1)
static volatile bool s_req_unpair = false;
static volatile bool s_req_reconnect = false;
static volatile bool s_do_connect = false;
static NimBLEAdvertisedDevice* s_pending_adv_device = nullptr;
static String s_pending_mac = "";
static uint8_t s_pending_addr_type = BLE_ADDR_RANDOM;
@@ -193,26 +207,35 @@ static bool is_target_remote(NimBLEAdvertisedDevice* dev) {
String addr = dev->getAddress().toString().c_str();
addr.toLowerCase();
// 1. Exact match with previously bound remote (case-insensitive)
// 1. If a remote was previously bound: STRICT EXCLUSIVITY!
// ONLY reconnect to this specific bound remote! NEVER connect to stranger devices!
if (s_bound_mac.length() > 0) {
String bound = s_bound_mac;
bound.toLowerCase();
if (addr.equals(bound)) {
return true;
}
// If MAC rotated (e.g. RPA) but device name matches our bound remote:
if (s_bound_name.length() > 0 && name.length() > 0 && name.equalsIgnoreCase(s_bound_name)) {
app_log("BLE", "Bound remote name matched (%s), updating target MAC to %s", name.c_str(), addr.c_str());
return true;
}
// STRICT: If bound, NEVER match any other stranger devices!
return false;
}
// 2. Name contains Xiaomi / Remote keywords in Chinese & English
// 2. Unbound state (initial pairing only):
// Name MUST contain explicit Xiaomi / Remote keywords (dropped loose "RC" to avoid false positives)
if (name.indexOf("小米") >= 0 || name.indexOf("遥控") >= 0 ||
name.indexOf("MI RC") >= 0 || name.indexOf("Xiaomi") >= 0 ||
name.indexOf("Remote") >= 0 || name.indexOf("RC") >= 0) {
name.indexOf("Remote") >= 0) {
return true;
}
// 3. Service UUID matches ATVV or HID
// 3. Service UUID matches proprietary ATVV (0xab5e0001)
// (Note: Generic 0x1812 HID is explicitly removed to prevent hijacking neighbor devices!)
if (dev->haveServiceUUID()) {
if (dev->isAdvertisingService(NimBLEUUID(ATVV_SVC_UUID)) ||
dev->isAdvertisingService(NimBLEUUID((uint16_t)HOGP_SVC_UUID))) {
if (dev->isAdvertisingService(NimBLEUUID(ATVV_SVC_UUID))) {
return true;
}
}
@@ -233,6 +256,43 @@ class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice* advertisedDevice) override {
String name = advertisedDevice->getName().c_str();
String addr = advertisedDevice->getAddress().toString().c_str();
uint32_t now = millis();
// Update in-memory discovered BLE devices cache for WebUI (thread-safe, non-blocking)
portENTER_CRITICAL(&s_disc_mux);
bool found = false;
for (auto& item : s_discovered_devices) {
if (item.mac.equalsIgnoreCase(addr)) {
if (name.length() > 0) item.name = name;
item.rssi = advertisedDevice->getRSSI();
item.type = (uint8_t)advertisedDevice->getAddress().getType();
item.last_seen_ms = now;
found = true;
break;
}
}
if (!found) {
if (s_discovered_devices.size() >= 30) {
s_discovered_devices.erase(s_discovered_devices.begin());
}
DiscoveredBleDevice d;
d.name = (name.length() > 0) ? name : "Unnamed BLE Device";
d.mac = addr;
d.rssi = advertisedDevice->getRSSI();
d.type = (uint8_t)advertisedDevice->getAddress().getType();
d.last_seen_ms = now;
s_discovered_devices.push_back(d);
}
// Purge devices not seen for over 20 seconds
for (auto it = s_discovered_devices.begin(); it != s_discovered_devices.end(); ) {
if (now - it->last_seen_ms > 20000) {
it = s_discovered_devices.erase(it);
} else {
++it;
}
}
portEXIT_CRITICAL(&s_disc_mux);
if (name.length() > 0) {
app_log("BLE_SCAN", "Device: %s (%s, RSSI: %d, Type: %d)",
@@ -255,11 +315,12 @@ class ClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient* pClient) override {
app_log("BLE", "Remote GATT Connected!");
s_ble_state = BLE_STATE_CONNECTING;
pClient->secureConnection();
// NOTE: Do NOT call secureConnection() here. It blocks the NimBLE host task thread.
}
void onDisconnect(NimBLEClient* pClient) override {
app_log("BLE", "Remote Disconnected");
int err = pClient ? pClient->getLastError() : 0;
app_log("BLE", "Remote Disconnected (lastErr: %d, %s)", err, NimBLEUtils::returnCodeToString(err));
s_ble_state = BLE_STATE_DISCONNECTED;
s_do_connect = false;
s_char_cmd = nullptr;
@@ -271,7 +332,8 @@ class ClientCallbacks : public NimBLEClientCallbacks {
usb_hid_consumer_release();
audio_pipeline_stop_session(&g_audio_pipeline);
led_indicator_set(LED_STATE_WAIT_CONNECTION);
start_scan();
// NOTE: Do NOT call start_scan() directly inside GAP disconnect callback.
// ble_remote_task() on Core 0 automatically restarts scanning on the next tick.
}
bool onConnParamsUpdateRequest(NimBLEClient* pClient, const ble_gap_upd_params* params) override {
@@ -305,14 +367,16 @@ static void start_scan() {
static bool setup_services_and_handshake() {
if (!s_client || !s_client->isConnected()) return false;
// 1. Security & Bonding
s_client->secureConnection();
// 1. Security & Bonding (executed safely on Core 0 task context)
app_log("BLE_SEC", "Initiating secure connection / bonding...");
if (!s_client->secureConnection()) {
app_log("BLE_SEC", "secureConnection returned %d, continuing service discovery...", s_client->getLastError());
} else {
app_log("BLE_SEC", "secureConnection established successfully");
}
vTaskDelay(pdMS_TO_TICKS(50));
// 2. Fast connection parameters (15ms interval)
s_client->setDataLen(251);
s_client->updateConnParams(12, 12, 0, 400);
// 3. Discover services
// 2. Discover services
std::vector<NimBLERemoteService*>* pServices = s_client->getServices(true);
if (!pServices) {
app_log("BLE", "No GATT services found");
@@ -395,6 +459,10 @@ static bool setup_services_and_handshake() {
app_log("BLE", "Total Subscribed Characteristic(s): %d", sub_count);
// 3. Negotiate data length and connection parameters
s_client->setDataLen(251);
s_client->updateConnParams(12, 12, 0, 400);
// 4. ATVV Handshake
if (s_char_cmd) {
uint8_t cmd_caps[] = { 0x0A, 0x01, 0x00, 0x00, 0x03, 0x03 };
@@ -418,9 +486,11 @@ static bool do_connect_adv_device(NimBLEAdvertisedDevice* advDevice) {
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
s_client->setConnectTimeout(4);
} else if (s_client->isConnected()) {
s_client->disconnect();
}
s_client->setConnectTimeout(4);
app_log("BLE", "Connecting to Advertised Device: %s (%s, Type: %d)...",
advDevice->getName().c_str(), advDevice->getAddress().toString().c_str(),
@@ -436,14 +506,20 @@ static bool do_connect_adv_device(NimBLEAdvertisedDevice* advDevice) {
s_connected_name = advDevice->getName().c_str();
s_connected_mac = advDevice->getAddress().toString().c_str();
s_bound_addr_type = advDevice->getAddress().getType();
if (s_connected_name.length() == 0) s_connected_name = "Xiaomi Voice Remote";
if (s_connected_name.length() == 0) {
s_connected_name = (s_bound_name.length() > 0) ? s_bound_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);
s_ble_prefs.putUChar("bound_type", s_bound_addr_type);
app_log("BLE", "Bound and saved remote: %s (%s, Type: %d)", s_connected_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
// Save bound MAC to NVS:
// Only update NVS if not previously bound, or if reconnecting to our bound remote whose MAC rotated
if (s_bound_mac.length() == 0 || !s_bound_mac.equalsIgnoreCase(s_connected_mac)) {
s_bound_mac = s_connected_mac;
s_bound_name = s_connected_name;
s_ble_prefs.putString("bound_mac", s_bound_mac);
s_ble_prefs.putString("bound_name", s_bound_name);
s_ble_prefs.putUChar("bound_type", s_bound_addr_type);
app_log("BLE", "Bound and saved remote: %s (%s, Type: %d)", s_bound_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
}
return setup_services_and_handshake();
}
@@ -455,9 +531,11 @@ static bool do_connect_mac(const String& mac_str, uint8_t addr_type) {
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
s_client->setConnectTimeout(4);
} else if (s_client->isConnected()) {
s_client->disconnect();
}
s_client->setConnectTimeout(4);
// Try primary addr_type
NimBLEAddress addr1(mac_str.c_str(), addr_type);
@@ -474,7 +552,7 @@ static bool do_connect_mac(const String& mac_str, uint8_t addr_type) {
}
if (!ok) {
app_log("BLE", "Direct connection to %s failed on both address types", mac_str.c_str());
app_log("BLE", "Direct link to %s timed out. Background scanner active, awaiting remote broadcast...", mac_str.c_str());
s_ble_state = BLE_STATE_DISCONNECTED;
start_scan();
return false;
@@ -482,12 +560,22 @@ static bool do_connect_mac(const String& mac_str, uint8_t addr_type) {
s_connected_mac = mac_str;
s_connected_name = "Xiaomi Voice Remote";
portENTER_CRITICAL(&s_disc_mux);
for (const auto& d : s_discovered_devices) {
if (d.mac.equalsIgnoreCase(mac_str) && d.name.length() > 0 && d.name != "Unnamed BLE Device") {
s_connected_name = d.name;
break;
}
}
portEXIT_CRITICAL(&s_disc_mux);
s_bound_mac = s_connected_mac;
s_bound_name = s_connected_name;
s_bound_addr_type = addr_type;
s_ble_prefs.putString("bound_mac", s_bound_mac);
s_ble_prefs.putString("bound_name", s_connected_name);
s_ble_prefs.putString("bound_name", s_bound_name);
s_ble_prefs.putUChar("bound_type", s_bound_addr_type);
app_log("BLE", "Manually paired and saved: %s (%s, Type: %d)", s_connected_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
app_log("BLE", "Manually paired and saved: %s (%s, Type: %d)", s_bound_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
return setup_services_and_handshake();
}
@@ -497,16 +585,18 @@ 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", "");
s_bound_name = s_ble_prefs.getString("bound_name", "");
s_bound_addr_type = s_ble_prefs.getUChar("bound_type", BLE_ADDR_RANDOM);
if (s_bound_mac.length() > 0) {
app_log("BLE", "Loaded previously bound remote: %s (%s, Type: %d)", bound_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
app_log("BLE", "Loaded previously bound remote: %s (%s, Type: %d)", s_bound_name.c_str(), s_bound_mac.c_str(), (int)s_bound_addr_type);
}
NimBLEDevice::init("ESP32-RemoteBridge");
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
NimBLEDevice::setSecurityAuth(BLE_SM_PAIR_AUTHREQ_BOND | BLE_SM_PAIR_AUTHREQ_MITM | BLE_SM_PAIR_AUTHREQ_SC);
// Just Works bonding: bonding=true, mitm=false, sc=true
// (MITM must be false because BLE_HS_IO_NO_INPUT_OUTPUT cannot support MITM authentication)
NimBLEDevice::setSecurityAuth(true, false, true);
NimBLEDevice::setSecurityIOCap(BLE_HS_IO_NO_INPUT_OUTPUT);
NimBLEDevice::setSecurityInitKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID);
NimBLEDevice::setSecurityRespKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID);
@@ -521,27 +611,56 @@ void ble_remote_init(void) {
void ble_remote_task(void) {
uint32_t now = millis();
// 1. Process asynchronous connection requests from FreeRTOS task
if (s_do_connect) {
s_do_connect = false;
// 1. Process asynchronous unpair / reconnect requests from Core 1
if (s_req_unpair || s_req_reconnect) {
bool is_unpair = s_req_unpair;
s_req_unpair = false;
s_req_reconnect = false;
if (s_client && s_client->isConnected()) {
s_client->disconnect();
}
if (s_pending_adv_device) {
do_connect_adv_device(s_pending_adv_device);
delete s_pending_adv_device;
s_pending_adv_device = nullptr;
}
s_pending_mac = "";
s_do_connect = false;
if (is_unpair) {
s_connected_name = "";
s_connected_mac = "";
}
s_ble_state = BLE_STATE_DISCONNECTED;
start_scan();
}
// 2. Process asynchronous connection requests from FreeRTOS task
if (s_do_connect) {
s_do_connect = false;
if (s_client && s_client->isConnected()) {
s_client->disconnect();
vTaskDelay(pdMS_TO_TICKS(50));
}
if (s_pending_adv_device) {
NimBLEAdvertisedDevice* adv = s_pending_adv_device;
s_pending_adv_device = nullptr;
do_connect_adv_device(adv);
delete adv;
} else if (s_pending_mac.length() > 0) {
do_connect_mac(s_pending_mac, s_pending_addr_type);
String mac = s_pending_mac;
uint8_t type = s_pending_addr_type;
s_pending_mac = "";
do_connect_mac(mac, type);
}
}
// 2. Auto Re-scan: If not connected, not connecting, and scan is inactive, restart continuous scan
if (s_ble_state < BLE_STATE_CONNECTING && !s_do_connect) {
// 3. Auto Re-scan: If not connected, not connecting, and scan is inactive, restart continuous scan
if (s_ble_state < BLE_STATE_CONNECTING && !s_do_connect && !s_req_unpair && !s_req_reconnect) {
if (!NimBLEDevice::getScan()->isScanning()) {
start_scan();
}
}
// 3. Audio silence watchdog (if voice key released packet dropped over BLE)
// 4. Audio silence watchdog (if voice key released packet dropped over BLE)
if (s_ble_state == BLE_STATE_TALKING) {
if (now - s_last_audio_ms > BLE_SILENCE_WATCHDOG_MS) {
app_log("ATVV", "Silence watchdog expired -> force stopping speech");
@@ -555,17 +674,6 @@ void ble_remote_task(void) {
}
}
}
// 4. Periodic keep-alive for remote connection retention
if (s_ble_state == BLE_STATE_CONNECTED) {
if (now - s_last_keepalive_ms > 5000) {
s_last_keepalive_ms = now;
if (s_char_cmd != nullptr) {
uint8_t ping[] = { 0x0C, 0x00 }; // Re-arm HTT standby
s_char_cmd->writeValue(ping, sizeof(ping), false);
}
}
}
}
ble_remote_state_t ble_remote_get_state(void) {
@@ -573,69 +681,91 @@ ble_remote_state_t ble_remote_get_state(void) {
}
void ble_remote_trigger_reconnect(void) {
if (s_client && s_client->isConnected()) {
s_client->disconnect();
}
s_do_connect = false;
start_scan();
app_log("BLE", "Triggering BLE reconnection on Core 0...");
s_req_reconnect = true;
}
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);
portENTER_CRITICAL(&s_disc_mux);
for (const auto& dev : s_discovered_devices) {
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();
obj["type"] = (int)dev.getAddress().getType();
obj["name"] = dev.name;
obj["mac"] = dev.mac;
obj["rssi"] = dev.rssi;
obj["type"] = (int)dev.type;
}
portEXIT_CRITICAL(&s_disc_mux);
String out;
serializeJson(doc, out);
// Resume continuous background scan if not connected
if (s_ble_state < BLE_STATE_CONNECTED && !s_do_connect) {
start_scan();
}
return out;
}
bool ble_remote_connect_mac(const String& mac_str) {
bool ble_remote_connect_target(const String& mac_str, uint8_t addr_type, const String& dev_name) {
if (mac_str.length() == 0) return false;
app_log("BLE", "Manual target requested: %s (%s, Type: %d)",
dev_name.length() > 0 ? dev_name.c_str() : "Unknown",
mac_str.c_str(), (int)addr_type);
// Delete any old bond for this target address so fresh pairing can proceed cleanly
NimBLEDevice::deleteBond(NimBLEAddress(mac_str.c_str(), addr_type));
// Save target as bound remote immediately in memory and NVS
s_bound_mac = mac_str;
s_bound_name = (dev_name.length() > 0 && dev_name != "Unnamed BLE Device") ? dev_name : "Xiaomi Voice Remote";
s_bound_addr_type = addr_type;
s_ble_prefs.putString("bound_mac", s_bound_mac);
s_ble_prefs.putString("bound_name", s_bound_name);
s_ble_prefs.putUChar("bound_type", s_bound_addr_type);
app_log("BLE", "Bound target saved to NVS: %s (%s)", s_bound_name.c_str(), s_bound_mac.c_str());
// Queue connection to be handled safely on Core 0
s_pending_mac = mac_str;
s_pending_addr_type = BLE_ADDR_RANDOM;
s_pending_addr_type = addr_type;
s_do_connect = true;
return true;
}
bool ble_remote_connect_mac(const String& mac_str) {
uint8_t addr_type = BLE_ADDR_PUBLIC;
String dev_name = "Xiaomi Voice Remote";
portENTER_CRITICAL(&s_disc_mux);
for (const auto& d : s_discovered_devices) {
if (d.mac.equalsIgnoreCase(mac_str)) {
addr_type = d.type;
if (d.name.length() > 0 && d.name != "Unnamed BLE Device") {
dev_name = d.name;
}
break;
}
}
portEXIT_CRITICAL(&s_disc_mux);
return ble_remote_connect_target(mac_str, addr_type, dev_name);
}
void ble_remote_unpair(void) {
s_bound_mac = "";
s_connected_name = "";
s_connected_mac = "";
s_bound_name = "";
s_ble_prefs.remove("bound_mac");
s_ble_prefs.remove("bound_name");
s_ble_prefs.remove("bound_type");
app_log("BLE", "Unpaired and cleared saved remote MAC");
ble_remote_trigger_reconnect();
NimBLEDevice::deleteAllBonds();
app_log("BLE", "Unpaired: deleted all NimBLE bonds and cleared saved MAC from NVS");
s_req_unpair = true;
}
String ble_remote_get_connected_info(void) {
JsonDocument doc;
doc["connected"] = (s_ble_state >= BLE_STATE_CONNECTED);
doc["state"] = (int)s_ble_state;
doc["name"] = s_connected_name;
doc["mac"] = s_connected_mac;
doc["bound_mac"] = s_bound_mac;
doc["bound_name"] = s_bound_name;
String out;
serializeJson(doc, out);
return out;
+5
View File
@@ -43,6 +43,11 @@ void ble_remote_trigger_reconnect(void);
*/
String ble_remote_scan_devices_json(void);
/**
* @brief Manually connect and pair to specific BLE device by MAC address, address type, and optional name
*/
bool ble_remote_connect_target(const String& mac_str, uint8_t addr_type, const String& dev_name);
/**
* @brief Manually connect and pair to specific BLE device by MAC address
*/
+13 -3
View File
@@ -145,29 +145,39 @@ static void handle_ble_scan() {
static void handle_ble_connect() {
if (!s_server.hasArg("plain")) {
app_log("WEB", "BLE connect rejected: missing POST body");
s_server.send(400, "application/json", "{\"error\":\"missing_body\"}");
return;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s_server.arg("plain"));
if (err) {
app_log("WEB", "BLE connect rejected: invalid JSON payload");
s_server.send(400, "application/json", "{\"error\":\"invalid_json\"}");
return;
}
String mac = doc["mac"] | "";
String name = doc["name"] | "";
uint8_t type = doc["type"] | 0;
if (mac.length() == 0) {
app_log("WEB", "BLE connect rejected: empty MAC address");
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\"}");
app_log("WEB", "Connecting to BLE target: %s (%s, Type: %d)",
name.length() > 0 ? name.c_str() : "Unknown", mac.c_str(), (int)type);
bool ok = ble_remote_connect_target(mac, type, name);
s_server.send(200, "application/json", ok ? "{\"status\":\"ok\"}" : "{\"status\":\"failed\"}");
}
static void handle_ble_unpair() {
app_log("WEB", "BLE unpair requested via Web API");
ble_remote_unpair();
s_server.send(200, "application/json", "{\"status\":\"unpaired\"}");
s_server.send(200, "application/json", "{\"status\":\"ok\"}");
}
static void handle_ble_info() {
+182 -25
View File
@@ -486,12 +486,46 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<!-- TAB 2: BLE Device Radar -->
<!-- TAB 2: BLE Device Radar & Pairing Management -->
<div id="tab-ble" class="tab-content">
<!-- Section 1: Current BLE Pairing & Connection Status -->
<div class="card">
<div class="card-header">
<span>蓝牙设备配对</span>
<button class="btn" onclick="scanBleDevices()">扫描蓝牙设备</button>
<div>
<span style="font-size: 16px;">蓝牙遥控器配对与绑定管理</span>
<div style="font-size: 12px; color: var(--text-muted); font-weight: normal; margin-top: 4px;">
管理当前 ESP32 记忆的唯一配对目标。如果更换了遥控器或需要重新配对,可在此清空配对信息。
</div>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-outline" style="font-size: 12px;" onclick="reconnectBle()">重新搜索连接</button>
<button class="btn btn-danger" style="font-size: 12px;" onclick="unpairBleDevice()">清空蓝牙配对信息</button>
</div>
</div>
<div class="grid-2" style="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;">NVS 固化绑定目标 (专属独占)</div>
<div id="ble-detail-bound-info" style="font-size: 16px; font-weight: bold; color: var(--accent-cyan);">正在获取...</div>
<div id="ble-detail-bound-mac" style="font-size: 13px; color: var(--text-muted); margin-top: 4px; font-family: monospace;">-</div>
</div>
</div>
</div>
<!-- Section 2: Nearby BLE Radar Scanner -->
<div class="card">
<div class="card-header">
<div>
<span style="font-size: 16px;">周围蓝牙设备扫描与手动绑定</span>
<div style="font-size: 12px; color: var(--text-muted); font-weight: normal; margin-top: 4px;">
点击扫描瞬时检索附近设备,点击“连接并绑定”即可立即锁定目标遥控器。
</div>
</div>
<button class="btn" id="btn-ble-scan" onclick="scanBleDevices()">扫描蓝牙设备</button>
</div>
<div id="ble-dev-list" style="margin-top: 14px;">点击上方按钮扫描附近的蓝牙遥控器...</div>
</div>
@@ -1009,8 +1043,14 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
function switchTab(id) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(id).classList.add('active');
if (event && event.target) event.target.classList.add('active');
const targetEl = document.getElementById(id);
if (targetEl) targetEl.classList.add('active');
if (id === 'tab-ble') {
refreshBleInfo();
} else if (id === 'tab-logs') {
refreshLogs();
}
}
async function fetchStatus() {
@@ -1023,17 +1063,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
document.getElementById('stat-audio-frames').innerText = `${d.frames_decoded} 帧`;
document.getElementById('stat-mem').innerText = `Heap: ${Math.round(d.free_heap/1024)}KB | PSRAM: ${Math.round(d.free_psram/1024/1024)}MB`;
const bleInfoRes = await fetch('/api/ble/info');
const bleInfo = await bleInfoRes.json();
if (bleInfo.connected) {
document.getElementById('stat-ble-state').innerText = '已连接';
document.getElementById('stat-ble-state').style.color = 'var(--accent-green)';
document.getElementById('stat-ble-name').innerText = bleInfo.name || '小米蓝牙语音遥控器';
} else {
document.getElementById('stat-ble-state').innerText = '扫描重连中...';
document.getElementById('stat-ble-state').style.color = 'var(--accent-orange)';
document.getElementById('stat-ble-name').innerText = bleInfo.bound_mac ? `已绑定: ${bleInfo.bound_mac}` : '未绑定遥控器';
}
await refreshBleInfo();
const apStatEl = document.getElementById('stat-ap-status');
if (apStatEl && d.ap_ip) {
@@ -2011,31 +2041,158 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
showToast('已恢复出厂默认层级映射');
}
async function refreshBleInfo() {
try {
const res = await fetch('/api/ble/info');
const d = await res.json();
// Update top/side status
const statBleState = document.getElementById('stat-ble-state');
const statBleName = document.getElementById('stat-ble-name');
if (statBleState && statBleName) {
if (d.connected) {
statBleState.innerText = '已连接';
statBleState.style.color = 'var(--accent-green)';
statBleName.innerText = d.name || '小米蓝牙语音遥控器';
} else {
statBleState.innerText = '扫描重连中...';
statBleState.style.color = 'var(--accent-orange)';
statBleName.innerText = (d.bound_mac && d.bound_mac.length > 0) ? `已绑定: ${d.bound_mac}` : '未绑定遥控器';
}
}
// Update Bluetooth Tab details
const connStateEl = document.getElementById('ble-detail-conn-state');
const connDevEl = document.getElementById('ble-detail-conn-device');
const boundInfoEl = document.getElementById('ble-detail-bound-info');
const boundMacEl = document.getElementById('ble-detail-bound-mac');
if (connStateEl) {
if (d.connected) {
connStateEl.innerText = '已建立物理连接 (在线)';
connStateEl.style.color = 'var(--accent-green)';
connDevEl.innerText = `${d.name || '小米蓝牙语音遥控器'} (${d.mac})`;
} else {
connStateEl.innerText = '未连接 (等待遥控器唤醒广播)';
connStateEl.style.color = 'var(--accent-orange)';
connDevEl.innerText = '无活动物理连接';
}
}
if (boundInfoEl) {
if (d.bound_mac && d.bound_mac.length > 0) {
boundInfoEl.innerText = d.bound_name || '已绑定遥控器';
boundInfoEl.style.color = 'var(--accent-cyan)';
boundMacEl.innerText = `MAC: ${d.bound_mac} (已固化,仅响应此遥控器)`;
} else {
boundInfoEl.innerText = '未绑定任何遥控器';
boundInfoEl.style.color = 'var(--text-muted)';
boundMacEl.innerText = '自由配对模式:按下遥控器组合键将自动配对并绑定';
}
}
} catch (e) {}
}
async function unpairBleDevice() {
if (!confirm('确定要清空已保存的蓝牙遥控器配对信息吗?\n\n清空后将解除与当前遥控器的专属绑定,ESP32 将恢复为自由配对模式,允许重新配对任意小米遥控器。')) return;
try {
const res = await fetch('/api/ble/unpair', { method: 'POST' });
const d = await res.json();
showToast('蓝牙配对信息已成功清空,已恢复自由配对状态!');
await refreshBleInfo();
refreshLogs();
} catch (e) {
showToast('清空配对信息失败: ' + e);
}
}
async function reconnectBle() {
try {
await fetch('/api/ble/reconnect', { method: 'POST' });
showToast('已触发重新扫描与连接');
await refreshBleInfo();
refreshLogs();
} catch (e) {
showToast('操作失败: ' + e);
}
}
async function scanBleDevices() {
const container = document.getElementById('ble-dev-list');
container.innerHTML = '正在扫描周围蓝牙设备 (4秒)...';
const scanBtn = document.getElementById('btn-ble-scan');
if (scanBtn) {
scanBtn.disabled = true;
scanBtn.innerText = '正在扫描...';
}
container.innerHTML = '<div style="color:var(--text-muted);">正在检索周围蓝牙广播信号...</div>';
try {
const res = await fetch('/api/ble/scan');
const d = await res.json();
if (!d.devices || d.devices.length === 0) {
container.innerHTML = '<div style="color:var(--text-muted);">未发现附近设备,请确保遥控器处于配对广播状态。</div>';
container.innerHTML = '<div style="color:var(--text-muted); padding:12px; background:#090d16; border-radius:8px;">未发现附近活跃广播的蓝牙设备。<br><small style="color:var(--text-muted); margin-top:4px; display:block;">提示:遥控器休眠时不会发送广播,请按住遥控器「主页键 + 菜单键」或按任意键唤醒广播后重新点击扫描。</small></div>';
return;
}
// Get current bound mac
let curBoundMac = '';
try {
const infoRes = await fetch('/api/ble/info');
const infoData = await infoRes.json();
curBoundMac = (infoData.bound_mac || '').toLowerCase();
} catch(e) {}
let html = '<div style="display:grid; gap:10px;">';
d.devices.forEach(dev => {
html += `<div style="display:flex; justify-content:space-between; align-items:center; background:#0b0f17; padding:12px; border-radius:8px; border:1px solid #243247;">
<div><b>${dev.name}</b> <span style="font-size:12px; color:var(--text-muted); font-family:monospace;">(${dev.mac}) RSSI: ${dev.rssi}dBm</span></div>
<button class="btn" style="padding:6px 14px; font-size:12px;" onclick="connectMac('${dev.mac}')">连接</button>
const isBound = curBoundMac.length > 0 && dev.mac.toLowerCase() === curBoundMac;
const safeName = (dev.name || 'Unnamed BLE Device').replace(/'/g, "\\'").replace(/"/g, "&quot;");
html += `<div style="display:flex; justify-content:space-between; align-items:center; background:#0b0f17; padding:12px; border-radius:8px; border:1px solid ${isBound ? 'var(--accent-cyan)' : '#243247'};">
<div>
<b>${dev.name}</b> ${isBound ? '<span class="badge" style="background:rgba(6,182,212,0.2); color:var(--accent-cyan); margin-left:6px;">当前绑定</span>' : ''}
<div style="font-size:12px; color:var(--text-muted); font-family:monospace; margin-top:2px;">
${dev.mac} | RSSI: ${dev.rssi}dBm | 类型: ${dev.type === 0 ? 'Public' : 'Random'}
</div>
</div>
<button class="btn ${isBound ? 'btn-outline' : ''}" style="padding:6px 14px; font-size:12px;" onclick="connectMac('${dev.mac}', '${safeName}', ${dev.type}, this)">${isBound ? '重新连接' : '连接并绑定'}</button>
</div>`;
});
html += '</div>';
container.innerHTML = html;
} catch(e){ container.innerText = '扫描出错: ' + e; }
} catch(e) {
container.innerText = '扫描出错: ' + e;
} finally {
if (scanBtn) {
scanBtn.disabled = false;
scanBtn.innerText = '扫描蓝牙设备';
}
}
}
async function connectMac(mac) {
const res = await fetch('/api/ble/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mac }) });
alert('正在连接目标蓝牙遥控器,请查看运行日志...');
async function connectMac(mac, name, type, btnEl) {
if (btnEl) {
btnEl.disabled = true;
btnEl.innerText = '正在连接...';
}
showToast(`正在发起连接至 ${name || mac}...`);
try {
const res = await fetch('/api/ble/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac: mac, name: name, type: type })
});
const d = await res.json();
if (d.status === 'ok') {
showToast(`已成功锁定并下发连接指令!已将【${name || mac}】保存为绑定遥控器。`);
} else {
showToast('连接指令下发失败: ' + (d.error || '未知错误'));
}
} catch (e) {
showToast('请求发生错误: ' + e);
} finally {
setTimeout(refreshBleInfo, 500);
setTimeout(refreshLogs, 500);
if (btnEl) {
btnEl.disabled = false;
btnEl.innerText = '连接并绑定';
}
}
}
async function scanWifiNetworks() {