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:
1 parent
30faf8ea97
commit
a9ca204942
12 files changed
+540
-108
No files matched your search
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+210
-80
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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, """);
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user