diff --git a/.github/workflows/daily-release.yml b/.github/workflows/daily-release.yml new file mode 100644 index 0000000..b64bf1f --- /dev/null +++ b/.github/workflows/daily-release.yml @@ -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 diff --git a/RemoteMapper-Flasher.7z b/RemoteMapper-Flasher.7z index f5c027b..5b82b2e 100644 Binary files a/RemoteMapper-Flasher.7z and b/RemoteMapper-Flasher.7z differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin index 32df122..8975913 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin index c4a7f79..5e0db27 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N16R8_full.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin index decb34d..710752d 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin index ad51531..da35494 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N4R2_full.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin index 6a12a4f..0181207 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_app.bin differ diff --git a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin index 67aac96..c01e897 100644 Binary files a/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin and b/RemoteMapper-Flasher/bin/RemoteMapper_ESP32S3_N8R2_full.bin differ diff --git a/src/ble/ble_remote_client.cpp b/src/ble/ble_remote_client.cpp index c2b7607..7199aa0 100644 --- a/src/ble/ble_remote_client.cpp +++ b/src/ble/ble_remote_client.cpp @@ -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 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* 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(); - 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(); - 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; diff --git a/src/ble/ble_remote_client.h b/src/ble/ble_remote_client.h index 6c9233f..1b342ed 100644 --- a/src/ble/ble_remote_client.h +++ b/src/ble/ble_remote_client.h @@ -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 */ diff --git a/src/web/web_server.cpp b/src/web/web_server.cpp index f0097d9..e7e1ad3 100644 --- a/src/web/web_server.cpp +++ b/src/web/web_server.cpp @@ -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() { diff --git a/src/web/web_ui.h b/src/web/web_ui.h index e99bb83..cab40b2 100644 --- a/src/web/web_ui.h +++ b/src/web/web_ui.h @@ -486,12 +486,46 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( - +
+
- 蓝牙设备配对 - +
+ 蓝牙遥控器配对与绑定管理 +
+ 管理当前 ESP32 记忆的唯一配对目标。如果更换了遥控器或需要重新配对,可在此清空配对信息。 +
+
+
+ + +
+
+
+
+
实时物理连接
+
正在获取...
+
-
+
+
+
NVS 固化绑定目标 (专属独占)
+
正在获取...
+
-
+
+
+
+ + +
+
+
+ 周围蓝牙设备扫描与手动绑定 +
+ 点击扫描瞬时检索附近设备,点击“连接并绑定”即可立即锁定目标遥控器。 +
+
+
点击上方按钮扫描附近的蓝牙遥控器...
@@ -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 = '
正在检索周围蓝牙广播信号...
'; try { const res = await fetch('/api/ble/scan'); const d = await res.json(); if (!d.devices || d.devices.length === 0) { - container.innerHTML = '
未发现附近设备,请确保遥控器处于配对广播状态。
'; + container.innerHTML = '
未发现附近活跃广播的蓝牙设备。
提示:遥控器休眠时不会发送广播,请按住遥控器「主页键 + 菜单键」或按任意键唤醒广播后重新点击扫描。
'; 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 = '
'; d.devices.forEach(dev => { - html += `
-
${dev.name} (${dev.mac}) RSSI: ${dev.rssi}dBm
- + const isBound = curBoundMac.length > 0 && dev.mac.toLowerCase() === curBoundMac; + const safeName = (dev.name || 'Unnamed BLE Device').replace(/'/g, "\\'").replace(/"/g, """); + html += `
+
+ ${dev.name} ${isBound ? '当前绑定' : ''} +
+ ${dev.mac} | RSSI: ${dev.rssi}dBm | 类型: ${dev.type === 0 ? 'Public' : 'Random'} +
+
+
`; }); html += '
'; 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() {