diff --git a/Drivers/mock-drivers-module/source/mock_bluetooth.cpp b/Drivers/mock-drivers-module/source/mock_bluetooth.cpp index 186ed7837..390d1c3dc 100644 --- a/Drivers/mock-drivers-module/source/mock_bluetooth.cpp +++ b/Drivers/mock-drivers-module/source/mock_bluetooth.cpp @@ -44,6 +44,8 @@ struct MockBluetoothCtx { BtRadioState radioState = BT_RADIO_STATE_OFF; bool scanning = false; char deviceName[BLE_DEVICE_NAME_MAX + 1] = {}; + /** Empty means "use deviceName". */ + char identity[BLE_IDENTITY_MAX + 1] = {}; MockPeerState peers[MOCK_PEER_COUNT]; Mutex subscriptionsMutex {}; @@ -205,6 +207,14 @@ bool apiIsScanning(Device* device) { return scanning; } +bool apiIsPeripheralActive(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return false; + // The mock has no peripheral profiles, so nothing is ever serving. + (void)ctx; + return false; +} + error_t apiPair(Device* device, const BtAddr addr) { auto* ctx = GET_CTX(device); if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; @@ -267,7 +277,7 @@ error_t apiGetPairedPeers(Device* device, BtPeerRecord* out, size_t* count) { return ERROR_NONE; } -error_t apiConnect(Device* device, const BtAddr addr, BtProfileId profile) { +error_t apiHostConnect(Device* device, const BtAddr addr) { auto* ctx = GET_CTX(device); if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; @@ -282,17 +292,17 @@ error_t apiConnect(Device* device, const BtAddr addr, BtProfileId profile) { return ERROR_NOT_FOUND; } ctx->peers[index].paired = true; - ctx->peers[index].connectedProfiles |= 1 << profile; + ctx->peers[index].connectedProfiles |= 1 << BT_PROFILE_HID_HOST; mutex_unlock(&ctx->mutex); // No real peer to negotiate with, so the mock always succeeds instantly. - fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTING); - fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTED); + fireProfileState(ctx, index, BT_PROFILE_HID_HOST, BT_PROFILE_STATE_CONNECTING); + fireProfileState(ctx, index, BT_PROFILE_HID_HOST, BT_PROFILE_STATE_CONNECTED); fireConnectState(ctx, index); return ERROR_NONE; } -error_t apiDisconnect(Device* device, const BtAddr addr, BtProfileId profile) { +error_t apiHostDisconnect(Device* device, const BtAddr addr) { auto* ctx = GET_CTX(device); if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT; @@ -300,13 +310,13 @@ error_t apiDisconnect(Device* device, const BtAddr addr, BtProfileId profile) { if (index == MOCK_PEER_COUNT) return ERROR_NOT_FOUND; mutex_lock(&ctx->mutex); - const bool was_connected = (ctx->peers[index].connectedProfiles & (1 << profile)) != 0; - ctx->peers[index].connectedProfiles &= ~(1 << profile); + const bool was_connected = (ctx->peers[index].connectedProfiles & (1 << BT_PROFILE_HID_HOST)) != 0; + ctx->peers[index].connectedProfiles &= ~(1 << BT_PROFILE_HID_HOST); mutex_unlock(&ctx->mutex); if (!was_connected) return ERROR_NONE; - fireProfileState(ctx, index, profile, BT_PROFILE_STATE_DISCONNECTING); - fireProfileState(ctx, index, profile, BT_PROFILE_STATE_IDLE); + fireProfileState(ctx, index, BT_PROFILE_HID_HOST, BT_PROFILE_STATE_DISCONNECTING); + fireProfileState(ctx, index, BT_PROFILE_HID_HOST, BT_PROFILE_STATE_IDLE); fireConnectState(ctx, index); return ERROR_NONE; } @@ -381,21 +391,50 @@ error_t apiGetDeviceName(Device* device, char* buf, size_t buf_len) { return ERROR_NONE; } +error_t apiSetIdentity(Device* device, const char* identity) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_ARGUMENT; + if (identity != nullptr && strlen(identity) > BLE_IDENTITY_MAX) return ERROR_INVALID_ARGUMENT; + mutex_lock(&ctx->mutex); + if (identity == nullptr || identity[0] == '\0') { + ctx->identity[0] = '\0'; + } else { + strncpy(ctx->identity, identity, BLE_IDENTITY_MAX); + ctx->identity[BLE_IDENTITY_MAX] = '\0'; + } + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +error_t apiGetIdentity(Device* device, char* buf, size_t buf_len) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || buf == nullptr || buf_len == 0) return ERROR_INVALID_ARGUMENT; + mutex_lock(&ctx->mutex); + const char* identity = (ctx->identity[0] != '\0') ? ctx->identity : ctx->deviceName; + strncpy(buf, identity, buf_len - 1); + buf[buf_len - 1] = '\0'; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + const BluetoothApi mock_bluetooth_api = { .get_radio_state = apiGetRadioState, .set_radio_enabled = apiSetRadioEnabled, .scan_start = apiScanStart, .scan_stop = apiScanStop, .is_scanning = apiIsScanning, + .is_peripheral_active = apiIsPeripheralActive, .pair = apiPair, .unpair = apiUnpair, .get_paired_peers = apiGetPairedPeers, - .connect = apiConnect, - .disconnect = apiDisconnect, + .host_connect = apiHostConnect, + .host_disconnect = apiHostDisconnect, .event_subscribe = apiEventSubscribe, .event_unsubscribe = apiEventUnsubscribe, .set_device_name = apiSetDeviceName, .get_device_name = apiGetDeviceName, + .set_identity = apiSetIdentity, + .get_identity = apiGetIdentity, }; // ---- Driver lifecycle ---- diff --git a/Modules/bluetooth-module/source/bluetooth_service.cpp b/Modules/bluetooth-module/source/bluetooth_service.cpp index e4a0efb6f..3d509272d 100644 --- a/Modules/bluetooth-module/source/bluetooth_service.cpp +++ b/Modules/bluetooth-module/source/bluetooth_service.cpp @@ -155,13 +155,11 @@ static void auto_start_profiles(BluetoothService* data) { if (query.hid_host) { LOG_I(TAG, "HID host auto-connect peer found, starting scan"); bluetooth_scan_start(data->device); - } else if (query.hid_device) { - LOG_I(TAG, "HID device auto-start (bonded peer found)"); - if (Device* hid_device = bluetooth_hid_device_get()) { - bluetooth_hid_device_start(hid_device, BT_HID_DEVICE_MODE_KEYBOARD); - device_put(hid_device); - } } else { + // HID device profiles are not auto-started. The identity, address and bond set belong + // to the app that starts the profile, so a service start would expose the current + // identity as a keyboard with no app behind it. A paired host reconnects once an app + // starts the profile. if (bluetooth_settings_get_spp_auto_start()) { LOG_I(TAG, "Auto-starting SPP server"); if (Device* serial = bluetooth_serial_get()) { @@ -182,6 +180,12 @@ static void auto_start_profiles(BluetoothService* data) { // Connects to the first saved HID host peer that appeared in the last scan. // When none appeared, scans again so the search continues until the device powers on. static void auto_connect_hid_host(BluetoothService* data) { + // A peripheral profile owns the radio and its identity, and bonds are stored per identity, + // so a host connect here would authenticate against the wrong bond set. + if (bluetooth_is_peripheral_active(data->device)) { + return; + } + mutex_lock(&data->mutex); bool connected = data->hid_host_connected; tt::std::vector scanned = data->scan_results; @@ -196,7 +200,7 @@ static void auto_connect_hid_host(BluetoothService* data) { char addr_hex[BLUETOOTH_ADDR_HEX_LENGTH + 1]; bluetooth_addr_to_hex(peer.addr, addr_hex); LOG_I(TAG, "Auto-connecting HID host to %s", addr_hex); - bluetooth_connect(data->device, peer.addr, BT_PROFILE_HID_HOST); + bluetooth_host_connect(data->device, peer.addr); return; } } @@ -521,7 +525,7 @@ void bluetooth_service_connect(const BtAddr addr, enum BtProfileId profile) { LOG_I(TAG, "connect(profile=%d)", static_cast(profile)); switch (profile) { case BT_PROFILE_HID_HOST: - bluetooth_connect(service->device, addr, profile); + bluetooth_host_connect(service->device, addr); break; case BT_PROFILE_HID_DEVICE: if (Device* hid_device = bluetooth_hid_device_get()) { @@ -551,13 +555,28 @@ void bluetooth_service_disconnect(const BtAddr addr, enum BtProfileId profile) { return; } LOG_I(TAG, "disconnect(profile=%d)", static_cast(profile)); - if (profile == BT_PROFILE_HID_DEVICE) { - if (Device* hid_device = bluetooth_hid_device_get()) { - bluetooth_hid_device_stop(hid_device); - device_put(hid_device); - } - } else { - bluetooth_disconnect(service->device, addr, profile); + switch (profile) { + case BT_PROFILE_HID_HOST: + bluetooth_host_disconnect(service->device, addr); + break; + case BT_PROFILE_HID_DEVICE: + if (Device* hid_device = bluetooth_hid_device_get()) { + bluetooth_hid_device_stop(hid_device); + device_put(hid_device); + } + break; + case BT_PROFILE_SPP: + if (Device* serial = bluetooth_serial_get()) { + bluetooth_serial_stop(serial); + device_put(serial); + } + break; + case BT_PROFILE_MIDI: + if (Device* midi = bluetooth_midi_get()) { + bluetooth_midi_stop(midi); + device_put(midi); + } + break; } } diff --git a/Platforms/platform-esp32/private/bluetooth/esp32_ble_internal.h b/Platforms/platform-esp32/private/bluetooth/esp32_ble_internal.h index 64d69b878..1bc9794a6 100644 --- a/Platforms/platform-esp32/private/bluetooth/esp32_ble_internal.h +++ b/Platforms/platform-esp32/private/bluetooth/esp32_ble_internal.h @@ -61,6 +61,13 @@ struct BleCtx { std::atomic midi_use_indicate; // true when client subscribed for INDICATE (e.g. Windows) std::atomic hid_active; std::atomic link_encrypted; + // Established links in either role, maintained from the GAP connect and disconnect events. + // Tells an idle radio from one in use before swapping the bond set. + std::atomic conn_count; + // Blocks the background scan's name resolution from starting another central connection. + std::atomic name_res_abort; + // Link opened by name resolution to read a peer's GAP name, NONE when no read is in flight. + std::atomic name_res_conn_handle; std::atomic pending_reset_count; // Timers @@ -93,6 +100,19 @@ struct BleCtx { // BLE device name (set before or after radio enable; applied in dispatch_enable) char device_name[BLE_DEVICE_NAME_MAX + 1]; + // Explicit identity (bluetooth_set_identity()). Empty means "use device_name". + char identity[BLE_IDENTITY_MAX + 1]; + // Identity string that was resolved when the radio came up. A later rename does not change + // it, so this (not the possibly-newer device_name) is what bluetooth_get_identity() reports + // while the radio is on. + char identity_resolved[BLE_IDENTITY_MAX + 1]; + // Resolved at radio enable: hash of identity (or device_name). Selects the bond store + // slot and is folded into the advertising address. 0 while the radio has never been on. + uint32_t identity_hash; + // Address type used for advertising: BLE_OWN_ADDR_RANDOM once the identity address was + // installed in on_sync(), BLE_OWN_ADDR_PUBLIC if that failed (or never ran). + std::atomic own_addr_type; + // Scan data (guarded by scan_mutex), allocated only while the radio is on SemaphoreHandle_t scan_mutex; BtPeerRecord* scan_results; @@ -123,14 +143,65 @@ void ble_set_scan_active(struct Device* device, bool v); // ---- Scan data management (defined in esp32_ble_scan.cpp) ---- void ble_scan_clear_results(struct Device* device); +/** + * Stop the background scan and its name-resolution chain and cancel a connection attempt in + * flight. A profile start needs an idle GAP, and name resolution keeps opening short-lived + * central connections. Does not wait for the cancel, see ble_prepare_profile_start(). + */ +void ble_scan_abort(struct Device* device); + +/** + * Prepare the radio for a peripheral profile start. Aborts the background scan and name + * resolution, then waits off the host task until the GAP is idle. + * @return false when the GAP is still busy after the timeout, the caller may still try. + */ +bool ble_prepare_profile_start(struct Device* device); + // ---- Event publishing ---- void ble_publish_event(struct Device* device, struct BtEvent event); // ---- Advertising helpers (defined in esp32_ble.cpp) ---- -void ble_start_advertising(struct Device* device, const ble_uuid128_t* svc_uuid); // svc_uuid=nullptr → name-only +void ble_start_advertising(struct Device* device, const ble_uuid128_t* svc_uuid); void ble_start_advertising_hid(struct Device* device, uint16_t appearance); void ble_schedule_adv_restart(struct Device* device, uint64_t delay_us); +// ---- Per-identity bond store + advertising address (defined in esp32_ble_identity.cpp) ---- + +/** FNV-1a hash of the identity string, never returns 0 or OWNER_TRANSITION. */ +uint32_t ble_identity_hash(const char* identity); + +/** + * Derive a static random BLE address from the controller's public address and the identity + * hash. The same controller and identity always yield the same address, different identities + * yield different addresses. + */ +bool ble_identity_derive_addr(uint32_t identity_hash, const uint8_t base[BT_ADDR_LEN], uint8_t out[BT_ADDR_LEN]); + +/** + * Make @a identity_hash's bonds the active set. NimBLE keys bond records by peer address only, + * so identities sharing one namespace would collide on the same peer. This swaps whole sets + * between "nimble_bond" and one "btid_" archive namespace per identity, crash-safely. + * + * Touches NVS only. dispatch_enable() calls it with the host down, + * ble_identity_apply_pending() on an idle running host. + * + * @return false when the swap could not be committed, the previous set stays active. + */ +bool ble_identity_bond_slot_activate(uint32_t identity_hash); + +/** + * Apply the pending identity to a radio that is already on, so a profile start does not + * advertise under another app's identity. Called by the peripheral profile start functions + * before they take gap_mutex. + * + * The bond set and the identity address only change together and only while the host is idle. + * A busy radio keeps its identity for the session. + * + * @return true when the active identity afterwards is the pending one. Also true when there is + * nothing to do or when the radio is off and dispatch_enable() will resolve it. + */ +bool ble_identity_apply_pending(struct Device* device); + // ---- GAP scan callback (defined in esp32_ble_scan.cpp) ---- int ble_gap_disc_event_handler(struct ble_gap_event* event, void* arg); void ble_resolve_next_unnamed_peer(struct Device* device, size_t start_idx); diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp index ee9fda3a3..b9f79e87b 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ constexpr auto* TAG = "esp32_ble"; #include #include +#include #include #if defined(CONFIG_ESP_HOSTED_ENABLED) @@ -41,11 +43,6 @@ extern "C" { // ble_store_config_init() is not declared in the public header in some IDF versions. extern "C" void ble_store_config_init(void); -// Sub-module API structs (defined in their respective cpp files). -extern const BtHidDeviceApi nimble_hid_device_api; -extern const BtSerialApi nimble_serial_api; -extern const BtMidiApi nimble_midi_api; - // File-static BleCtx pointer used only by NimBLE host callbacks whose signature // is fixed by the NimBLE API (on_sync, on_reset) and cannot carry a Device*. // All other callbacks receive Device* via their void* arg parameter. @@ -110,9 +107,8 @@ void ble_publish_event(struct Device* device, struct BtEvent event) { } // ---- Advertising restart helper ---- -//TODO: Fix Restart name-only advertising after a rename. -// Delay before on_sync() falls back to name-only advertising - see its call site. +// Delay before on_sync() restarts advertising for an already-active profile. constexpr uint64_t ADV_ON_SYNC_DELAY_US = 100'000; static void adv_restart_callback(void* arg) { @@ -131,10 +127,9 @@ static void adv_restart_callback(void* arg) { ble_start_advertising(device, &NUS_SVC_UUID); } else if (ctx->hid_active.load() && hid_conn == BLE_HS_CONN_HANDLE_NONE) { ble_start_advertising_hid(device, hid_appearance); - } else if (!ctx->hid_active.load() && !ctx->spp_active.load() && !ctx->midi_active.load()) { - // Nothing profile-specific active - name-only keeps the radio discoverable/connectable. - ble_start_advertising(device, nullptr); } + // No active profile: stay silent. A profile-less advertisement would carry the last + // identity, so a bonded host would reconnect to a device with no app behind it. xSemaphoreGive(ctx->gap_mutex); } @@ -172,6 +167,18 @@ static int gap_event_handler(struct ble_gap_event* event, void* arg) { switch (event->type) { case BLE_GAP_EVENT_CONNECT: if (event->connect.status == 0) { + ctx->conn_count.fetch_add(1); + // Claim inbound links for HID immediately, so a stop can also terminate a + // discovery-only link that has no subscription yet. Only an inbound link can be + // HID here, and never while SPP or MIDI own the advertisement. + if (ctx->hid_active.load() && !ctx->spp_active.load() && !ctx->midi_active.load() && + ble_hid_get_conn_handle(ctx->hid_device_child) == BLE_HS_CONN_HANDLE_NONE) { + struct ble_gap_conn_desc desc = {}; + if (ble_gap_conn_find(event->connect.conn_handle, &desc) == 0 && + desc.role == BLE_GAP_ROLE_SLAVE) { + ble_hid_set_conn_handle(ctx->hid_device_child, event->connect.conn_handle); + } + } LOG_I(TAG, "Connected (handle=%u hid_active=%d hid_conn=%u)", event->connect.conn_handle, (int)ctx->hid_active.load(), @@ -190,6 +197,7 @@ static int gap_event_handler(struct ble_gap_event* event, void* arg) { case BLE_GAP_EVENT_DISCONNECT: { LOG_I(TAG, "Disconnected (reason=%d)", event->disconnect.reason); + if (ctx->conn_count.load() > 0) ctx->conn_count.fetch_sub(1); uint16_t hdl = event->disconnect.conn.conn_handle; bool was_spp = (ctx->spp_conn_handle.load() == hdl); bool was_midi = (ctx->midi_conn_handle.load() == hdl); @@ -426,6 +434,138 @@ static int gap_event_handler(struct ble_gap_event* event, void* arg) { // ---- NimBLE host sync / reset callbacks ---- +/** + * Install the advertising address of the active identity: the controller's public BD_ADDR + * with the identity hash mixed in, as a static random address. Also used when the identity + * changes on a running radio, before any advertising starts. + */ +static void apply_identity_own_addr(BleCtx* ctx) { + if (ctx->identity_hash == 0) { + ctx->own_addr_type.store(BLE_OWN_ADDR_PUBLIC); + return; + } + + uint8_t base[BT_ADDR_LEN] = {}; + if (ble_hs_id_copy_addr(BLE_ADDR_PUBLIC, base, nullptr) != 0) { + // Fallback for controllers that have not reported their BD_ADDR over HCI yet. On a + // hosted setup this is the host's MAC, which is still stable across reboots. + esp_read_mac(base, ESP_MAC_BT); + } + + uint8_t addr[BT_ADDR_LEN]; + if (!ble_identity_derive_addr(ctx->identity_hash, base, addr)) { + ctx->own_addr_type.store(BLE_OWN_ADDR_PUBLIC); + return; + } + + int rc = ble_hs_id_set_rnd(addr); + if (rc != 0) { + // Advertising falls back to the public address, so peers share one identity. + ctx->own_addr_type.store(BLE_OWN_ADDR_PUBLIC); + LOG_E(TAG, "identity: set random address failed (rc=%d), advertising with public address", rc); + return; + } + + ctx->own_addr_type.store(BLE_OWN_ADDR_RANDOM); + LOG_I(TAG, "identity %08lx advertising as %02x:%02x:%02x:%02x:%02x:%02x (static random)", + (unsigned long)ctx->identity_hash, + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]); +} + +/** + * Apply the pending identity to a radio that is already on, see esp32_ble_internal.h. Runs on + * the caller's task, never on the NimBLE host task. + */ +bool ble_identity_apply_pending(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + if (ctx == nullptr) return false; + + // radio_mutex then gap_mutex, the order dispatch_enable() and api_set_device_name() use. + xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY); + if (ctx->radio_state.load() != BT_RADIO_STATE_ON) { + // dispatch_enable() resolves the identity from the pending values when it comes up. + xSemaphoreGive(ctx->radio_mutex); + return true; + } + const char* pending = (ctx->identity[0] != '\0') ? ctx->identity : ctx->device_name; + const uint32_t pending_hash = ble_identity_hash(pending); + if (pending_hash == ctx->identity_hash) { + xSemaphoreGive(ctx->radio_mutex); + return true; + } + + xSemaphoreTake(ctx->gap_mutex, portMAX_DELAY); + + // A background scan is not a profile, abort it so it does not hold the GAP or the identity. + ble_scan_abort(device); + + // The bond set and the address only change while nothing is connected or active. A live + // link was authenticated against the old set, and an active profile owns the identity. + const bool idle = !ble_gap_adv_active() && !ble_gap_disc_active() && !ble_gap_conn_active() && + ctx->conn_count.load() == 0 && + ctx->name_res_conn_handle.load() == BLE_HS_CONN_HANDLE_NONE && + !ctx->spp_active.load() && !ctx->midi_active.load() && !ctx->hid_active.load() && + !ble_hid_get_host_active(device); + if (!idle) { + xSemaphoreGive(ctx->gap_mutex); + xSemaphoreGive(ctx->radio_mutex); + LOG_I(TAG, "identity: radio busy, '%s' joins the current identity '%s'", + pending, ctx->identity_resolved); + return false; + } + + if (!ble_identity_bond_slot_activate(pending_hash)) { + xSemaphoreGive(ctx->gap_mutex); + xSemaphoreGive(ctx->radio_mutex); + LOG_W(TAG, "identity: could not activate the bond set of '%s'; staying on '%s'", + pending, ctx->identity_resolved); + return false; + } + // Drop the cached NVS records and load the newly active bond set. + ble_store_config_init(); + + ctx->identity_hash = pending_hash; + strncpy(ctx->identity_resolved, pending, BLE_IDENTITY_MAX); + ctx->identity_resolved[BLE_IDENTITY_MAX] = '\0'; + // Install the identity address before any advertising starts. + apply_identity_own_addr(ctx); + + xSemaphoreGive(ctx->gap_mutex); + xSemaphoreGive(ctx->radio_mutex); + LOG_I(TAG, "identity switched to '%s' (%08lx) on the running radio", + ctx->identity_resolved, (unsigned long)pending_hash); + return true; +} + +bool ble_prepare_profile_start(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + // Name resolution keeps opening central connections to read peer names. + ble_scan_abort(device); + + // A cancelled connect attempt clears asynchronously on the NimBLE host task, and an open + // name-read link is terminated the same way. Runs on the caller's task, never on the host + // task. The GATT switch and the bond swap both need no link at all. + const int64_t deadline = esp_timer_get_time() + 500'000; + uint16_t terminated_link = BLE_HS_CONN_HANDLE_NONE; + while (ble_gap_conn_active() || ble_gap_disc_active() || + ctx->name_res_conn_handle.load() != BLE_HS_CONN_HANDLE_NONE) { + uint16_t name_res_link = ctx->name_res_conn_handle.load(); + if (name_res_link != BLE_HS_CONN_HANDLE_NONE && name_res_link != terminated_link) { + // The link may have completed after ble_scan_abort(), end it now. + ble_gap_terminate(name_res_link, BLE_ERR_REM_USER_CONN_TERM); + terminated_link = name_res_link; + } + if (esp_timer_get_time() > deadline) { + LOG_W(TAG, "profile start: GAP still busy after 500 ms (disc=%d conn=%d name_res=%d)", + (int)ble_gap_disc_active(), (int)ble_gap_conn_active(), + (int)(ctx->name_res_conn_handle.load() != BLE_HS_CONN_HANDLE_NONE)); + return false; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + return true; +} + static void on_sync() { LOG_I(TAG, "NimBLE synced (nus_tx=%u midi_io=%u hid_kb=%u hid_cs=%u hid_ms=%u hid_gp=%u)", nus_tx_handle, midi_io_handle, @@ -434,6 +574,10 @@ static void on_sync() { BleCtx* ctx = s_ctx; if (ctx == nullptr) return; + // Must happen before ble_hs_util_ensure_addr()/ble_hs_id_infer_auto() below: they cache + // which address type the host uses, and advertising additionally reads own_addr_type. + apply_identity_own_addr(ctx); + ctx->pending_reset_count.store(0); uint8_t own_addr_type; @@ -454,11 +598,12 @@ static void on_sync() { ble_publish_event(ctx->device, e); // Deferred, not synchronous: an app reacting to the event just published above (e.g. - // starting HID) races this to set hid/spp/midi_active, and immediately starting name-only - // advertising only to tear it down again a few ms later for a profile switch was observed to - // corrupt the NimBLE host's HCI event pool (crash in os_memblock_get, called from - // lld_adv_end_ind_handler_hack). The delay gives that app time to react first; - // adv_restart_callback() re-checks the active flags, so this is a no-op if one did. + // starting HID) races this to set hid/spp/midi_active. Starting advertising here and + // tearing it down again a few ms later for a profile switch was observed to corrupt the + // NimBLE host's HCI event pool (crash in os_memblock_get, called from + // lld_adv_end_ind_handler_hack). The delay gives that app time to react first. + // The callback only restarts advertising for profiles that are already active (it does + // nothing when none is), so this also recovers advertising after a controller reset. ble_schedule_adv_restart(ctx->device, ADV_ON_SYNC_DELAY_US); } @@ -522,70 +667,90 @@ static void host_task(void* param) { // ---- Advertising helpers ---- +// Address type advertising runs with: the identity's static random address once on_sync() +// installed it, else the controller's public address. +static uint8_t advertising_addr_type(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + return (ctx != nullptr) ? ctx->own_addr_type.load() : BLE_OWN_ADDR_PUBLIC; +} + +// One scan response holds a single name field: 31-byte payload minus the length and type bytes. +constexpr uint8_t SCAN_RSP_NAME_MAX = 29; + +/** Longest prefix of @a name that fits in @a max_len bytes without splitting a UTF-8 character. */ +static uint8_t utf8_prefix_length(const char* name, uint8_t max_len) { + size_t len = strlen(name); + if (len <= max_len) return (uint8_t)len; + len = max_len; + // The first excluded byte must not be a continuation byte. + while (len > 0 && (static_cast(name[len]) & 0xC0) == 0x80) { + --len; + } + return (uint8_t)len; +} + +/** + * Put the complete device name in the scan response. A name too long for one response is + * truncated and marked as a shortened name, otherwise the device would advertise without one. + */ +static void set_scan_response_name(const char* name) { + struct ble_hs_adv_fields rsp; + memset(&rsp, 0, sizeof(rsp)); + rsp.name = (const uint8_t*)name; + rsp.name_len = (uint8_t)strlen(name); + rsp.name_is_complete = 1; + int rc = ble_gap_adv_rsp_set_fields(&rsp); + if (rc == 0) return; + + rsp.name_len = utf8_prefix_length(name, SCAN_RSP_NAME_MAX); + rsp.name_is_complete = 0; + rc = ble_gap_adv_rsp_set_fields(&rsp); + if (rc != 0) { + LOG_W(TAG, "scan response name failed rc=%d", rc); + } +} + void ble_start_advertising(struct Device* device, const ble_uuid128_t* svc_uuid) { ble_gap_adv_stop(); - // Always sync the GAP name from ctx right before building the advertising packet, - // so bluetooth_set_device_name() is honoured regardless of call timing. - BleCtx* _name_ctx = ble_get_ctx(device); - if (_name_ctx) ble_svc_gap_device_name_set(_name_ctx->device_name); - - int rc; - if (svc_uuid != nullptr) { - const char* name = ble_svc_gap_device_name(); - uint8_t name_len = (uint8_t)strlen(name); - uint8_t short_len = (name_len > 8) ? 8 : name_len; - - ble_uuid128_t uuid_copy = *svc_uuid; - struct ble_hs_adv_fields fields; - memset(&fields, 0, sizeof(fields)); - fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; - fields.uuids128 = &uuid_copy; - fields.num_uuids128 = 1; - fields.uuids128_is_complete = 1; - fields.name = (const uint8_t*)name; - fields.name_len = short_len; - fields.name_is_complete = 0; // AD type 0x08 = Shortened Local Name + // Sync the GAP name from ctx so bluetooth_set_device_name() is honoured regardless of when + // it was called. + BleCtx* ctx = ble_get_ctx(device); + if (ctx) ble_svc_gap_device_name_set(ctx->device_name); - rc = ble_gap_adv_set_fields(&fields); - if (rc != 0) { - LOG_W(TAG, "startAdvertising: set_fields with name failed rc=%d, retrying", rc); - fields.name = nullptr; - fields.name_len = 0; - rc = ble_gap_adv_set_fields(&fields); - if (rc != 0) { - LOG_E(TAG, "startAdvertising: set_fields failed rc=%d", rc); - return; - } - } - struct ble_hs_adv_fields rsp; - memset(&rsp, 0, sizeof(rsp)); - rsp.name = (const uint8_t*)name; - rsp.name_len = name_len; - rsp.name_is_complete = 1; - rc = ble_gap_adv_rsp_set_fields(&rsp); - if (rc != 0) { - LOG_W(TAG, "startAdvertising: rsp_set_fields rc=%d (non-fatal)", rc); - } - LOG_I(TAG, "startAdvertising: UUID mode (uuid[0..3]=%02x%02x%02x%02x)", - svc_uuid->value[0], svc_uuid->value[1], - svc_uuid->value[2], svc_uuid->value[3]); - } else { - struct ble_hs_adv_fields fields; - memset(&fields, 0, sizeof(fields)); - fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; - const char* name = ble_svc_gap_device_name(); - fields.name = (const uint8_t*)name; - fields.name_len = (uint8_t)strlen(name); - fields.name_is_complete = 1; + const char* name = ble_svc_gap_device_name(); + uint8_t name_len = (uint8_t)strlen(name); + + ble_uuid128_t uuid_copy = *svc_uuid; + struct ble_hs_adv_fields fields; + memset(&fields, 0, sizeof(fields)); + fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; + fields.uuids128 = &uuid_copy; + fields.num_uuids128 = 1; + fields.uuids128_is_complete = 1; + // Complete name only. A shortened name is identical for every identity sharing a prefix, + // the scan response below covers names that do not fit. + fields.name = (const uint8_t*)name; + fields.name_len = name_len; + fields.name_is_complete = 1; + + int rc = ble_gap_adv_set_fields(&fields); + if (rc != 0) { + LOG_I(TAG, "startAdvertising: name doesn't fit the adv packet (rc=%d), using scan response", rc); + fields.name = nullptr; + fields.name_len = 0; rc = ble_gap_adv_set_fields(&fields); if (rc != 0) { LOG_E(TAG, "startAdvertising: set_fields failed rc=%d", rc); return; } - LOG_I(TAG, "startAdvertising: name-only mode"); } + set_scan_response_name(name); + LOG_I(TAG, "startAdvertising: UUID mode (uuid[0..3]=%02x%02x%02x%02x)", + svc_uuid->value[0], svc_uuid->value[1], + svc_uuid->value[2], svc_uuid->value[3]); + struct ble_gap_adv_params adv_params; memset(&adv_params, 0, sizeof(adv_params)); adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; @@ -593,7 +758,7 @@ void ble_start_advertising(struct Device* device, const ble_uuid128_t* svc_uuid) adv_params.itvl_min = 160; // 100 ms adv_params.itvl_max = 240; // 150 ms - rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, nullptr, BLE_HS_FOREVER, + rc = ble_gap_adv_start(advertising_addr_type(device), nullptr, BLE_HS_FOREVER, &adv_params, gap_event_handler, device); if (rc != 0 && rc != BLE_HS_EALREADY) { LOG_E(TAG, "startAdvertising: adv_start failed rc=%d", rc); @@ -606,12 +771,11 @@ void ble_start_advertising_hid(struct Device* device, uint16_t appearance) { ble_gap_adv_stop(); // Always sync the GAP name from ctx right before building the advertising packet. - BleCtx* _name_ctx = ble_get_ctx(device); - if (_name_ctx) ble_svc_gap_device_name_set(_name_ctx->device_name); + BleCtx* ctx = ble_get_ctx(device); + if (ctx) ble_svc_gap_device_name_set(ctx->device_name); const char* name = ble_svc_gap_device_name(); uint8_t name_len = (uint8_t)strlen(name); - uint8_t short_len = (name_len > 8) ? 8 : name_len; static const ble_uuid16_t hid_uuid16 = BLE_UUID16_INIT(0x1812); @@ -623,13 +787,14 @@ void ble_start_advertising_hid(struct Device* device, uint16_t appearance) { fields.uuids16 = &hid_uuid16; fields.num_uuids16 = 1; fields.uuids16_is_complete = 1; + // Complete name only, the scan response below covers names that do not fit. fields.name = (const uint8_t*)name; - fields.name_len = short_len; - fields.name_is_complete = 0; + fields.name_len = name_len; + fields.name_is_complete = 1; int rc = ble_gap_adv_set_fields(&fields); if (rc != 0) { - LOG_W(TAG, "startAdvertisingHid: set_fields with name failed rc=%d, retrying", rc); + LOG_I(TAG, "startAdvertisingHid: name doesn't fit the adv packet (rc=%d), using scan response", rc); fields.name = nullptr; fields.name_len = 0; rc = ble_gap_adv_set_fields(&fields); @@ -639,15 +804,7 @@ void ble_start_advertising_hid(struct Device* device, uint16_t appearance) { } } - struct ble_hs_adv_fields rsp; - memset(&rsp, 0, sizeof(rsp)); - rsp.name = (const uint8_t*)name; - rsp.name_len = name_len; - rsp.name_is_complete = 1; - rc = ble_gap_adv_rsp_set_fields(&rsp); - if (rc != 0) { - LOG_W(TAG, "startAdvertisingHid: rsp_set_fields rc=%d (non-fatal)", rc); - } + set_scan_response_name(name); struct ble_gap_adv_params adv_params; memset(&adv_params, 0, sizeof(adv_params)); @@ -656,7 +813,7 @@ void ble_start_advertising_hid(struct Device* device, uint16_t appearance) { adv_params.itvl_min = 160; adv_params.itvl_max = 240; - rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, nullptr, BLE_HS_FOREVER, + rc = ble_gap_adv_start(advertising_addr_type(device), nullptr, BLE_HS_FOREVER, &adv_params, gap_event_handler, device); if (rc != 0 && rc != BLE_HS_EALREADY) { LOG_E(TAG, "startAdvertisingHid: adv_start failed rc=%d", rc); @@ -713,6 +870,26 @@ static void dispatch_enable(BleCtx* ctx) { bool scan_alloc_failed = ctx->scan_results == nullptr || ctx->scan_addrs == nullptr; xSemaphoreGive(ctx->scan_mutex); + // Resolve the identity and make its bond set the active one before the NimBLE host comes + // up: ble_store_config_init() below loads the bond records from NVS, so the swap has to be + // committed while the host is still down (and before it can advertise). + const char* identity = (ctx->identity[0] != '\0') ? ctx->identity : ctx->device_name; + const uint32_t identity_hash = ble_identity_hash(identity); + ctx->conn_count.store(0); + ctx->name_res_abort.store(false); + ctx->name_res_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); + if (!scan_alloc_failed) { + if (ble_identity_bond_slot_activate(identity_hash)) { + ctx->identity_hash = identity_hash; + strncpy(ctx->identity_resolved, identity, BLE_IDENTITY_MAX); + ctx->identity_resolved[BLE_IDENTITY_MAX] = '\0'; + } else { + // Keep the identity whose bonds are actually active. A profile start retries the swap. + LOG_W(TAG, "identity: '%s' not activated, staying on '%s'", identity, + ctx->identity_resolved[0] != '\0' ? ctx->identity_resolved : "public address"); + } + } + int rc = scan_alloc_failed ? -1 : nimble_port_init(); if (rc != 0) { if (scan_alloc_failed) { @@ -729,8 +906,13 @@ static void dispatch_enable(BleCtx* ctx) { return; } - // npl_funcs only becomes valid after nimble_port_init() above; must (re-)init - // the giveup event each enable cycle, not once at device-start time. + // Re-init the giveup event every enable cycle, npl_funcs only exists after + // nimble_port_init(). nimble_port_deinit() frees the event pool that giveup_event's block + // came from, so the pointer in giveup_event.event is stale. npl_freertos_event_init() then + // memsets that stale address instead of allocating, which can corrupt the new pool's free + // list and crash the first event after re-enabling the radio. Clearing the pointer makes + // every cycle allocate a fresh block. + ctx->giveup_event.event = nullptr; ble_npl_event_init(&ctx->giveup_event, giveup_event_cb, ctx); ble_hs_cfg.sync_cb = on_sync; @@ -862,6 +1044,11 @@ static void dispatch_disable(BleCtx* ctx) { ctx->midi_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); ctx->midi_active.store(false); ctx->hid_active.store(false); + // No link survives a host restart, so linked state has to be cleared here too. + ctx->hid_host_active.store(false); + ctx->conn_count.store(0); + ctx->name_res_abort.store(false); + ctx->name_res_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); ctx->link_encrypted.store(false); ctx->pending_reset_count.store(0); current_hid_profile = BleHidProfile::None; @@ -927,15 +1114,16 @@ static error_t api_scan_start(struct Device* device) { } ble_scan_clear_results(device); + // A new scan is a new name-resolution round. + ctx->name_res_abort.store(false); struct ble_gap_disc_params disc_params = {}; disc_params.passive = 0; disc_params.filter_duplicates = 1; - uint8_t own_addr_type; - ble_hs_id_infer_auto(0, &own_addr_type); - - int rc = ble_gap_disc(own_addr_type, 5000, &disc_params, ble_gap_disc_event_handler, device); + // Central role always scans from the public address. The identity address is the peripheral + // identity, using it here would invalidate bonds with peers we act as a host for. + int rc = ble_gap_disc(BLE_OWN_ADDR_PUBLIC, 5000, &disc_params, ble_gap_disc_event_handler, device); if (rc != 0 && rc != BLE_HS_EALREADY) { LOG_E(TAG, "ble_gap_disc failed (rc=%d)", rc); return ERROR_UNDEFINED; @@ -966,6 +1154,12 @@ static bool api_is_scanning(struct Device* device) { return ctx != nullptr && ctx->scan_active.load(); } +static bool api_is_peripheral_active(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + return ctx != nullptr && + (ctx->hid_active.load() || ctx->spp_active.load() || ctx->midi_active.load()); +} + static error_t api_pair(struct Device* /*device*/, const BtAddr /*addr*/) { // Pairing handled automatically by NimBLE SM during connection. // ENC_CHANGE fires when encryption is established (PAIR_RESULT event). @@ -987,34 +1181,12 @@ static error_t api_get_paired_peers(struct Device* /*device*/, struct BtPeerReco return ERROR_NOT_SUPPORTED; } -static error_t api_connect(struct Device* device, const BtAddr addr, enum BtProfileId profile) { - BleCtx* ctx = (BleCtx*)device_get_driver_data(device); - if (!ctx) return ERROR_INVALID_STATE; - if (profile == BT_PROFILE_HID_DEVICE) { - return nimble_hid_device_api.start(ctx->hid_device_child, BT_HID_DEVICE_MODE_KEYBOARD); - } else if (profile == BT_PROFILE_SPP) { - return ble_spp_start_internal(ctx->serial_child); - } else if (profile == BT_PROFILE_MIDI) { - return ble_midi_start_internal(ctx->midi_child); - } else if (profile == BT_PROFILE_HID_HOST) { - return ble_hid_host_connect(device, addr); - } - return ERROR_NOT_SUPPORTED; +static error_t api_host_connect(struct Device* device, const BtAddr addr) { + return ble_hid_host_connect(device, addr); } -static error_t api_disconnect(struct Device* device, const BtAddr addr, enum BtProfileId profile) { - BleCtx* ctx = (BleCtx*)device_get_driver_data(device); - if (!ctx) return ERROR_INVALID_STATE; - if (profile == BT_PROFILE_HID_DEVICE) { - return nimble_hid_device_api.stop(ctx->hid_device_child); - } else if (profile == BT_PROFILE_SPP) { - return nimble_serial_api.stop(ctx->serial_child); - } else if (profile == BT_PROFILE_MIDI) { - return nimble_midi_api.stop(ctx->midi_child); - } else if (profile == BT_PROFILE_HID_HOST) { - return ble_hid_host_disconnect(); - } - return ERROR_NOT_SUPPORTED; +static error_t api_host_disconnect(struct Device* /*device*/, const BtAddr /*addr*/) { + return ble_hid_host_disconnect(); } static error_t api_event_subscribe(struct Device* device, BtEventSubscription* sub, TaskEventGroup* event_group) { @@ -1075,6 +1247,13 @@ static error_t api_set_device_name(struct Device* device, const char* name) { strncpy(ctx->device_name, name, BLE_DEVICE_NAME_MAX); ctx->device_name[BLE_DEVICE_NAME_MAX] = '\0'; if (ctx->radio_state.load() == BT_RADIO_STATE_ON) { + // A rename does not change the running session's address or bonds. Those follow at the + // next profile start if the radio is idle, or at the next radio cycle. + const char* pending_identity = (ctx->identity[0] != '\0') ? ctx->identity : ctx->device_name; + if (ctx->identity_hash != ble_identity_hash(pending_identity)) { + LOG_I(TAG, "Device name is now '%s'; identity '%s' applies from the next profile " + "start or radio cycle", ctx->device_name, ctx->identity_resolved); + } ble_svc_gap_device_name_set(ctx->device_name); // Restart advertising so the new name is broadcast immediately. // ble_schedule_adv_restart checks active profiles; no-op if nothing is advertising. @@ -1094,6 +1273,41 @@ static error_t api_get_device_name(struct Device* device, char* buf, size_t buf_ return ERROR_NONE; } +static error_t api_set_identity(struct Device* device, const char* identity) { + BleCtx* ctx = ble_get_ctx(device); + if (ctx == nullptr) return ERROR_INVALID_ARGUMENT; + if (identity != nullptr && strlen(identity) > BLE_IDENTITY_MAX) { + return ERROR_INVALID_ARGUMENT; + } + + xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY); + // Address and bond set can only change with nothing connected. A mid-session change is + // pending until the next profile start or radio cycle. + if (identity == nullptr || identity[0] == '\0') { + ctx->identity[0] = '\0'; + } else { + strncpy(ctx->identity, identity, BLE_IDENTITY_MAX); + ctx->identity[BLE_IDENTITY_MAX] = '\0'; + } + xSemaphoreGive(ctx->radio_mutex); + return ERROR_NONE; +} + +static error_t api_get_identity(struct Device* device, char* buf, size_t buf_len) { + BleCtx* ctx = ble_get_ctx(device); + if (ctx == nullptr || buf == nullptr || buf_len == 0) return ERROR_INVALID_ARGUMENT; + xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY); + // While the radio is up, report the identity the running session resolved. + const char* identity = (ctx->radio_state.load() != BT_RADIO_STATE_OFF && + ctx->identity_resolved[0] != '\0') + ? ctx->identity_resolved + : ((ctx->identity[0] != '\0') ? ctx->identity : ctx->device_name); + strncpy(buf, identity, buf_len - 1); + buf[buf_len - 1] = '\0'; + xSemaphoreGive(ctx->radio_mutex); + return ERROR_NONE; +} + // ---- BluetoothApi struct ---- const BluetoothApi nimble_bluetooth_api = { @@ -1102,15 +1316,18 @@ const BluetoothApi nimble_bluetooth_api = { .scan_start = api_scan_start, .scan_stop = api_scan_stop, .is_scanning = api_is_scanning, + .is_peripheral_active = api_is_peripheral_active, .pair = api_pair, .unpair = api_unpair, .get_paired_peers = api_get_paired_peers, - .connect = api_connect, - .disconnect = api_disconnect, + .host_connect = api_host_connect, + .host_disconnect = api_host_disconnect, .event_subscribe = api_event_subscribe, .event_unsubscribe = api_event_unsubscribe, .set_device_name = api_set_device_name, .get_device_name = api_get_device_name, + .set_identity = api_set_identity, + .get_identity = api_get_identity, }; // ---- Driver definitions ---- @@ -1155,6 +1372,9 @@ static error_t esp32_ble_start_device(struct Device* device) { ctx->midi_use_indicate.store(false); ctx->hid_active.store(false); ctx->link_encrypted.store(false); + ctx->conn_count.store(0); + ctx->name_res_abort.store(false); + ctx->name_res_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); ctx->pending_reset_count.store(0); ctx->midi_keepalive_timer = nullptr; ctx->adv_restart_timer = nullptr; @@ -1164,6 +1384,10 @@ static error_t esp32_ble_start_device(struct Device* device) { if (cfg_name == nullptr || cfg_name[0] == '\0') cfg_name = "Tactility"; strncpy(ctx->device_name, cfg_name, BLE_DEVICE_NAME_MAX); ctx->device_name[BLE_DEVICE_NAME_MAX] = '\0'; + // No explicit identity yet: dispatch_enable() falls back to device_name. + ctx->identity[0] = '\0'; + ctx->identity_hash = 0; + ctx->own_addr_type.store(BLE_OWN_ADDR_PUBLIC); ctx->device = device; ctx->serial_child = nullptr; ctx->midi_child = nullptr; diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp index 525cff854..5975c985f 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid.cpp @@ -274,9 +274,7 @@ bool ble_hid_switch_profile(struct Device* device, BleHidProfile profile) { if (profile == current_hid_profile) return true; LOG_I(TAG, "switchGattProfile: %d -> %d", (int)current_hid_profile, (int)profile); - ble_gap_adv_stop(); - - // NimBLE only allows GATT changes without discovery, which a repeating scan keeps starting + // A scan or the name resolution it starts holds the GAP procedure slot. if (ble_gap_disc_active()) { ble_gap_disc_cancel(); if (ble_get_scan_active(device)) { @@ -287,7 +285,7 @@ bool ble_hid_switch_profile(struct Device* device, BleHidProfile profile) { } } - // A connection that is being set up, such as an auto-connect to a keyboard, blocks it too + // A connection being set up, such as an auto-connect to a keyboard. if (ble_gap_conn_active()) { ble_gap_conn_cancel(); } @@ -298,14 +296,19 @@ bool ble_hid_switch_profile(struct Device* device, BleHidProfile profile) { hid_ctx->hid_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); } - // ble_svc_gap_init() asserts when NimBLE refuses the change, so check first + // Stop advertising last so a failed switch leaves the serving profile on the air. + if (ble_gap_adv_active()) { + ble_gap_adv_stop(); + } + + // ble_svc_gap_init() asserts when NimBLE refuses the change. if (ble_gap_adv_active() || ble_gap_disc_active() || ble_gap_conn_active()) { LOG_W(TAG, "switchGattProfile: GAP procedure still active (adv=%d disc=%d conn=%d)", ble_gap_adv_active(), ble_gap_disc_active(), ble_gap_conn_active()); return false; } - // Refuses with BLE_HS_EBUSY while a connection exists, which ble_svc_gap_init() would assert on + // Refused with BLE_HS_EBUSY while a connection exists. const int reset_rc = ble_gatts_reset(); if (reset_rc != 0) { LOG_W(TAG, "switchGattProfile: ble_gatts_reset failed rc=%d", reset_rc); @@ -386,16 +389,20 @@ void ble_hid_init_gatt() { // stored as its driver data. static error_t hid_device_start(struct Device* device, enum BtHidDeviceMode mode) { - // NimBLE can't change GATT services while connected, and the HID host holds a connection + // NimBLE cannot change GATT services while a link exists. if (ble_hid_get_host_active(device)) { LOG_W(TAG, "Can't start the HID device while a HID host connection is active"); return ERROR_INVALID_STATE; } - // Clean up any leftover context from a previous session (e.g. BLE was disabled - // while connected: dispatch_disable() skips the normal hid_device_stop cleanup). + // The radio may already be on under another app's identity. Prepare the GAP first, then + // make this app's identity the active one when the radio is idle. + if (!ble_prepare_profile_start(device)) { + return ERROR_INVALID_STATE; + } + ble_identity_apply_pending(device); + // Leftover context from a session that ended with the radio being disabled. BleHidDeviceCtx* old_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); delete old_ctx; // no-op if nullptr - // Create driver data for this HID session. BleHidDeviceCtx* hid_ctx = new BleHidDeviceCtx(); hid_ctx->hid_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); device_set_driver_data(device, hid_ctx); @@ -448,20 +455,15 @@ static error_t hid_device_stop(struct Device* device) { BleHidDeviceCtx* hid_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); uint16_t conn = hid_ctx ? hid_ctx->hid_conn_handle.load() : (uint16_t)BLE_HS_CONN_HANDLE_NONE; if (conn != BLE_HS_CONN_HANDLE_NONE) { - // Connected: terminate and let the DISCONNECT handler switch profile to None. - // ble_gatts_mutable() returns false while a connection is live, so calling - // switch_profile here would assert inside ble_svc_gap_init(). + // Terminate and let the disconnect handler switch the profile to None. The GATT cannot + // change while the link exists, and the handler runs on the host task where it is safe. ble_gap_terminate(conn, BLE_ERR_REM_USER_CONN_TERM); - // Do NOT clear hid_conn_handle or delete hid_ctx: - // the DISCONNECT handler in esp32_ble.cpp uses hid_conn_handle for was_hid detection. - // esp32_ble_hid_device_stop_device() (device lifecycle) will free hid_ctx later. + // Keep hid_conn_handle and hid_ctx, the disconnect handler uses the handle to recognise + // this link. The device lifecycle frees the context. } else { - // Not connected: GATT is mutable, switch profile immediately. - if (current_hid_profile != BleHidProfile::None) { - if (!ble_hid_switch_profile(device, BleHidProfile::None)) { - LOG_E(TAG, "hid_device_stop: switch to None failed — GATT state inconsistent"); - } - } + // Nothing is connected. Do not touch the GATT here, a reset from a non-host task + // corrupts NimBLE's heap. Nothing advertises while hid_active is false, and the next + // start rebuilds the profile if it needs a different mode. delete hid_ctx; device_set_driver_data(device, nullptr); } diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid_host.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid_host.cpp index 4c269dd36..936d2f9cb 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid_host.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_hid_host.cpp @@ -999,10 +999,9 @@ error_t ble_hid_host_connect(Device* device, const BtAddr addr) { setHostActive(true); ble_addr_t ble_addr = lookupPeerAddress(ctx, addr); - uint8_t own_addr_type; - if (ble_hs_id_infer_auto(0, &own_addr_type) != 0) { - own_addr_type = BLE_OWN_ADDR_PUBLIC; - } + // Central role always uses the public address, so bonds with HID hosts survive identity + // changes. + const uint8_t own_addr_type = BLE_OWN_ADDR_PUBLIC; // ble_gap_connect() is rejected while discovery is active if (ble_gap_disc_active()) { diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_identity.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_identity.cpp new file mode 100644 index 000000000..ce6ff9c15 --- /dev/null +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_identity.cpp @@ -0,0 +1,283 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_BT_NIMBLE_ENABLED) + +#include + +#include +#include + +#include +#include +#include + +/** + * Per-identity BLE presence: advertising address and bond store. + * + * A controller has one public address, so apps that only change the device name all look like + * the same peer to a PC and share its single bond. Two things make each identity a separate + * device: + * - a static random advertising address derived from the controller's public address and the + * identity hash (ble_identity_derive_addr()), + * - a separate bond set, because NimBLE keys store records by peer address only. + * + * Bond sets are swapped wholesale between IDF's "nimble_bond" namespace and one + * "btid_" archive namespace per identity. + */ + +constexpr auto* TAG = "esp32_ble_id"; + +namespace { + +/** Namespace owned by IDF's NimBLE store (see components/bt/.../ble_store_nvs.c). */ +constexpr const char* BOND_NAMESPACE = "nimble_bond"; + +/** Namespace holding the record of which identity currently owns BOND_NAMESPACE. */ +constexpr const char* META_NAMESPACE = "btid_meta"; +constexpr const char* META_KEY_OWNER = "owner"; + +/** Owner marker written while a bond-set switch is in progress (never a valid identity hash). */ +constexpr uint32_t OWNER_TRANSITION = 0xFFFFFFFFu; + +/** "btid_" + 8 hex digits + NUL. Fits NVS_NS_NAME_MAX_SIZE (16). */ +constexpr size_t SLOT_NAMESPACE_SIZE = 16; + +void get_slot_namespace(uint32_t identity_hash, char out[SLOT_NAMESPACE_SIZE]) { + snprintf(out, SLOT_NAMESPACE_SIZE, "btid_%08lx", (unsigned long)identity_hash); +} + +/** @return true when the owner marker was read; false when it is absent or unreadable. */ +bool read_owner(uint32_t* owner) { + nvs_handle_t handle; + if (nvs_open(META_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + size_t length = sizeof(*owner); + esp_err_t err = nvs_get_blob(handle, META_KEY_OWNER, owner, &length); + nvs_close(handle); + return err == ESP_OK && length == sizeof(*owner); +} + +bool write_owner(uint32_t owner) { + nvs_handle_t handle; + if (nvs_open(META_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return false; + } + esp_err_t err = nvs_set_blob(handle, META_KEY_OWNER, &owner, sizeof(owner)); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + return err == ESP_OK; +} + +/** Erase every key in a namespace. A namespace that does not exist yet counts as success. */ +bool clear_namespace(const char* name) { + nvs_handle_t handle; + esp_err_t err = nvs_open(name, NVS_READWRITE, &handle); + if (err != ESP_OK) { + return err == ESP_ERR_NVS_NOT_FOUND; + } + err = nvs_erase_all(handle); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + return err == ESP_OK; +} + +/** + * Replace the contents of @a dst_name with a copy of every blob in @a src_name. + * @return false when @a src_name does not exist or a read or write failed. @a dst_name is then + * cleared, which is the desired state for an identity that never paired. + */ +bool copy_namespace(const char* src_name, const char* dst_name) { + nvs_handle_t src; + if (nvs_open(src_name, NVS_READONLY, &src) != ESP_OK) { + return false; + } + + // Collect key names before writing. A write can trigger NVS compaction, which must not run + // while an iterator is live. A bond set holds a handful of records, 32 keys is ample. + constexpr size_t MAX_KEYS = 32; + char keys[MAX_KEYS][NVS_KEY_NAME_MAX_SIZE] = {}; + size_t key_count = 0; + + nvs_iterator_t iterator = nullptr; + esp_err_t err = nvs_entry_find_in_handle(src, NVS_TYPE_BLOB, &iterator); + while (err == ESP_OK && key_count < MAX_KEYS) { + nvs_entry_info_t info; + nvs_entry_info(iterator, &info); + strncpy(keys[key_count], info.key, NVS_KEY_NAME_MAX_SIZE - 1); + ++key_count; + err = nvs_entry_next(&iterator); + } + nvs_release_iterator(iterator); + // A successful iteration ends with ESP_ERR_NVS_NOT_FOUND. Hitting the cap with more keys + // left would archive an incomplete set, so refuse instead. + if (key_count == MAX_KEYS && err == ESP_OK) { + LOG_E(TAG, "bond namespace holds more than %u keys, refusing to archive", (unsigned)MAX_KEYS); + nvs_close(src); + return false; + } + bool ok = (err == ESP_ERR_NVS_NOT_FOUND); + + nvs_handle_t dst; + if (nvs_open(dst_name, NVS_READWRITE, &dst) != ESP_OK) { + nvs_close(src); + return false; + } + if (ok) { + ok = nvs_erase_all(dst) == ESP_OK; + } + + size_t capacity = 256; + auto* buffer = static_cast(malloc(capacity)); + if (ok && buffer == nullptr) { + ok = false; + } + + for (size_t i = 0; ok && i < key_count; ++i) { + size_t length = 0; + if (nvs_get_blob(src, keys[i], nullptr, &length) != ESP_OK || length == 0) { + // Entry vanished or is not a blob; nothing to copy. + continue; + } + if (length > capacity) { + auto* grown = static_cast(realloc(buffer, length)); + if (grown == nullptr) { + ok = false; + break; + } + buffer = grown; + capacity = length; + } + if (nvs_get_blob(src, keys[i], buffer, &length) != ESP_OK || + nvs_set_blob(dst, keys[i], buffer, length) != ESP_OK) { + ok = false; + break; + } + } + + free(buffer); + if (ok) { + ok = nvs_commit(dst) == ESP_OK; + } + nvs_close(dst); + nvs_close(src); + return ok; +} + +} // namespace + +uint32_t ble_identity_hash(const char* identity) { + // FNV-1a, 32-bit. + uint32_t hash = 2166136261u; + for (const char* p = identity; p != nullptr && *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 16777619u; + } + // 0 means "no owner recorded" and 0xFFFFFFFF means "switch in progress". + if (hash == 0 || hash == OWNER_TRANSITION) { + hash ^= 0x5A5A5A5Au; + } + return hash; +} + +bool ble_identity_derive_addr(uint32_t identity_hash, const uint8_t base[BT_ADDR_LEN], + uint8_t out[BT_ADDR_LEN]) { + if (base == nullptr || out == nullptr) { + return false; + } + + memcpy(out, base, BT_ADDR_LEN); + // Only the identity hash bytes are mixed in, so the same controller + identity always + // yields the same address (and therefore the same peer entry on the PC). + out[0] ^= static_cast(identity_hash); + out[1] ^= static_cast(identity_hash >> 8); + out[2] ^= static_cast(identity_hash >> 16); + out[3] ^= static_cast(identity_hash >> 24); + // Static random address: the two most significant bits of the MSB must both be set. + out[5] = static_cast(0xC0u | (out[5] & 0x3Fu)); + // Never collide with the public address: it would defeat the whole point (and the peer + // would silently reuse its existing bond). + if (memcmp(out, base, BT_ADDR_LEN) == 0) { + out[4] ^= 0x01u; + } + return true; +} + +bool ble_identity_bond_slot_activate(uint32_t identity_hash) { + uint32_t owner = 0; + if (!read_owner(&owner)) { + // No marker means the active namespace predates per-identity slots, so its bonds were + // made with the public address and are unusable now. Start clean. + if (!clear_namespace(BOND_NAMESPACE)) { + LOG_E(TAG, "could not clear stale bonds"); + return false; + } + if (!write_owner(identity_hash)) { + LOG_E(TAG, "could not record bond slot owner"); + return false; + } + LOG_I(TAG, "bond slots initialised for identity %08lx", (unsigned long)identity_hash); + return true; + } + + if (owner == identity_hash) { + return true; + } + + // Archive the active set and commit the transition marker before touching it. A power loss + // in between leaves the marker behind, so the next boot reinstalls the archive instead of + // filing it under the wrong identity. + if (owner != OWNER_TRANSITION) { + char slot[SLOT_NAMESPACE_SIZE]; + get_slot_namespace(owner, slot); + if (!copy_namespace(BOND_NAMESPACE, slot)) { + LOG_E(TAG, "failed to archive bonds of identity %08lx; keeping its slot active", + (unsigned long)owner); + return false; + } + if (!write_owner(OWNER_TRANSITION)) { + LOG_E(TAG, "failed to record bond slot transition; keeping identity %08lx active", + (unsigned long)owner); + return false; + } + } + + char slot[SLOT_NAMESPACE_SIZE]; + get_slot_namespace(identity_hash, slot); + + // copy_namespace() fails both when the identity never archived a set and when a copy failed. + // Only the former may clear the active set and commit the new owner. A failed restore keeps + // OWNER_TRANSITION, so the next activation retries it instead of archiving an empty set over + // the identity's intact archive. + bool restored; + nvs_handle_t probe; + esp_err_t probe_result = nvs_open(slot, NVS_READONLY, &probe); + if (probe_result == ESP_OK) { + nvs_close(probe); + restored = copy_namespace(slot, BOND_NAMESPACE); + } else if (probe_result == ESP_ERR_NVS_NOT_FOUND) { + // A first pairing for this identity, so there is nothing to restore. + restored = clear_namespace(BOND_NAMESPACE); + } else { + restored = false; + } + if (!restored) { + // The marker stays at OWNER_TRANSITION, so the next attempt retries the restore. + LOG_E(TAG, "failed to activate bond slot for identity %08lx", (unsigned long)identity_hash); + return false; + } + if (!write_owner(identity_hash)) { + LOG_E(TAG, "failed to record bond slot owner %08lx", (unsigned long)identity_hash); + return false; + } + LOG_I(TAG, "bond slot switched to identity %08lx", (unsigned long)identity_hash); + return true; +} + +#endif // CONFIG_BT_NIMBLE_ENABLED diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_midi.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_midi.cpp index 458c5b1f8..c3e86616d 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_midi.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_midi.cpp @@ -167,6 +167,11 @@ static void midi_keepalive_cb(void* arg) { // All functions receive the midi child Device*. static error_t midi_start(struct Device* device) { + // The radio may already be on under another app's identity (system Bluetooth on). + if (!ble_prepare_profile_start(device)) { + return ERROR_INVALID_STATE; + } + ble_identity_apply_pending(device); // gap_mutex (not radio_mutex - see BleCtx::gap_mutex's comment) serializes this GAP sequence // against on_sync()'s own advertising decision and hid/spp's start/stop. BleCtx* root_ctx = ble_get_ctx(device); diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp index cb9ade881..348e14163 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp @@ -23,6 +23,43 @@ static BleCtx* s_scan_ctx = nullptr; // ---- Scan data helpers ---- +/** Clears the scan-active flag and notifies subscribers, once. */ +static void finish_scan_events(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + // Serialize with name_read_callback() so the finish event cannot overtake a peer result. + xSemaphoreTake(ctx->scan_mutex, portMAX_DELAY); + if (!ble_get_scan_active(device)) { + xSemaphoreGive(ctx->scan_mutex); + return; + } + ble_set_scan_active(device, false); + struct BtEvent e = {}; + e.type = BT_EVENT_SCAN_FINISHED; + ble_publish_event(device, e); + xSemaphoreGive(ctx->scan_mutex); +} + +void ble_scan_abort(struct Device* device) { + BleCtx* ctx = ble_get_ctx(device); + // Blocks the DISC_COMPLETE handler and the resolution chain from starting a replacement + // connection after the cancels below. + ctx->name_res_abort.store(true); + uint16_t name_res_link = ctx->name_res_conn_handle.load(); + if (name_res_link != BLE_HS_CONN_HANDLE_NONE) { + // An open name-read link blocks a GATT change and the bond swap, so end it here. The + // disconnect handler clears name_res_conn_handle. + ble_gap_terminate(name_res_link, BLE_ERR_REM_USER_CONN_TERM); + } + if (ble_gap_disc_active()) { + ble_gap_disc_cancel(); + } + if (ble_gap_conn_active()) { + // The cancel completes on the host task, ble_prepare_profile_start() waits for it. + ble_gap_conn_cancel(); + } + finish_scan_events(device); +} + void ble_scan_clear_results(struct Device* device) { BleCtx* ctx = ble_get_ctx(device); xSemaphoreTake(ctx->scan_mutex, portMAX_DELAY); @@ -90,9 +127,13 @@ int ble_gap_disc_event_handler(struct ble_gap_event* event, void* arg) { case BLE_GAP_EVENT_DISC_COMPLETE: LOG_I(TAG, "Scan complete (reason=%d)", event->disc_complete.reason); - // Keep scan_active=true; resolveNextUnnamedPeer clears it and fires ScanFinished - // once name resolution finishes, so the UI spinner stays active throughout. - ble_resolve_next_unnamed_peer(device, 0); + // scan_active stays true until name resolution finishes, so the UI spinner keeps + // running. A scan aborted for a profile start skips resolution. + if (ctx->name_res_abort.load()) { + finish_scan_events(device); + } else { + ble_resolve_next_unnamed_peer(device, 0); + } break; default: @@ -121,6 +162,8 @@ static int name_read_callback(uint16_t conn_handle, const struct ble_gatt_error* char name_buf[BT_NAME_MAX + 1] = {}; os_mbuf_copydata(attr->om, 0, len, name_buf); { + // Held across the publication so a concurrent ble_scan_abort() cannot publish + // SCAN_FINISHED between the name update and this peer event. xSemaphoreTake(ctx->scan_mutex, portMAX_DELAY); if (idx < ctx->scan_count && ctx->scan_results[idx].name[0] == '\0') { memcpy(ctx->scan_results[idx].name, name_buf, len); @@ -128,12 +171,13 @@ static int name_read_callback(uint16_t conn_handle, const struct ble_gatt_error* LOG_I(TAG, "Name resolved (idx=%u): %s", (unsigned)idx, name_buf); } BtPeerRecord record = (idx < ctx->scan_count) ? ctx->scan_results[idx] : BtPeerRecord{}; + if (!ctx->name_res_abort.load()) { + struct BtEvent e = {}; + e.type = BT_EVENT_PEER_FOUND; + e.peer = record; + ble_publish_event(ctx->device, e); + } xSemaphoreGive(ctx->scan_mutex); - - struct BtEvent e = {}; - e.type = BT_EVENT_PEER_FOUND; - e.peer = record; - ble_publish_event(ctx->device, e); } } return 0; // wait for BLE_HS_EDONE @@ -151,6 +195,7 @@ static int name_res_gap_callback(struct ble_gap_event* event, void* arg) { switch (event->type) { case BLE_GAP_EVENT_CONNECT: if (event->connect.status == 0) { + ctx->name_res_conn_handle.store(event->connect.conn_handle); LOG_I(TAG, "Name resolution: connected (idx=%u handle=%u)", (unsigned)idx, event->connect.conn_handle); static const ble_uuid16_t device_name_uuid = BLE_UUID16_INIT(0x2A00); int rc = ble_gattc_read_by_uuid(event->connect.conn_handle, @@ -168,6 +213,7 @@ static int name_res_gap_callback(struct ble_gap_event* event, void* arg) { break; case BLE_GAP_EVENT_DISCONNECT: + ctx->name_res_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); LOG_I(TAG, "Name resolution: disconnected (idx=%u)", (unsigned)idx); ble_resolve_next_unnamed_peer(ctx->device, idx + 1); break; @@ -182,15 +228,18 @@ void ble_resolve_next_unnamed_peer(struct Device* device, size_t start_idx) { BleCtx* ctx = ble_get_ctx(device); s_scan_ctx = ctx; - // Skip if a profile server or HID host connection attempt is active — - // initiating a central connection simultaneously would fail (BLE_HS_EALREADY). + // A profile start aborted the scan, so do not chain into another connection. + if (ctx->name_res_abort.load()) { + LOG_I(TAG, "Name resolution: aborted (profile start)"); + finish_scan_events(device); + return; + } + + // A simultaneous central connection would fail while a profile or HID host link is active. if (ble_midi_get_active(device) || ble_spp_get_active(device) || ble_hid_get_active(device) || ble_hid_get_host_active(device)) { LOG_I(TAG, "Name resolution: skipping (server or HID host active)"); - ble_set_scan_active(device, false); - struct BtEvent e = {}; - e.type = BT_EVENT_SCAN_FINISHED; - ble_publish_event(device, e); + finish_scan_events(device); return; } @@ -201,14 +250,15 @@ void ble_resolve_next_unnamed_peer(struct Device* device, size_t start_idx) { bool radio_on = false; int rc = -1; - // Don't start (or chain into) a new GAP connection once the radio is going down - // (or is already off) — ble_gap_connect() racing nimble_port_stop() can block the - // NimBLE host task and hang the stop, same class of bug as the OFF_PENDING guard - // on advertising restart in gap_event_handler's BLE_GAP_EVENT_DISCONNECT case. - // The check must be re-done on every iteration (not just once on entry) since a - // failed ble_gap_connect() loops back for the next peer, and it must happen under - // scan_mutex together with the connect call itself so a concurrent dispatch_disable() - // can't flip radio_state between the check and the call. + // Re-checked every iteration, the chain continues from GAP callbacks on the host task. + if (ctx->name_res_abort.load()) { + LOG_I(TAG, "Name resolution: aborted (profile start)"); + finish_scan_events(device); + return; + } + + // Never start a connection while the radio is going down, nimble_port_stop() would + // hang. Checked per iteration and under scan_mutex, together with the connect call. xSemaphoreTake(ctx->scan_mutex, portMAX_DELAY); radio_on = ctx->radio_state.load() == BT_RADIO_STATE_ON; if (radio_on) { @@ -221,10 +271,10 @@ void ble_resolve_next_unnamed_peer(struct Device* device, size_t start_idx) { ++i; } if (found) { - uint8_t own_addr_type; - ble_hs_id_infer_auto(0, &own_addr_type); + // Central role always uses the public address, so bonds with peers we connect + // to survive identity changes. void* idx_arg = (void*)(uintptr_t)i; - rc = ble_gap_connect(own_addr_type, &addr, 1500, nullptr, + rc = ble_gap_connect(BLE_OWN_ADDR_PUBLIC, &addr, 1500, nullptr, name_res_gap_callback, idx_arg); } } diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_spp.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_spp.cpp index 3d3332d07..ae961c64f 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_spp.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_spp.cpp @@ -126,6 +126,11 @@ void ble_spp_set_conn_handle(struct Device* device, uint16_t h) { // All functions receive the serial child Device*. static error_t spp_start(struct Device* device) { + // The radio may already be on under another app's identity (system Bluetooth on). + if (!ble_prepare_profile_start(device)) { + return ERROR_INVALID_STATE; + } + ble_identity_apply_pending(device); // gap_mutex (not radio_mutex - see BleCtx::gap_mutex's comment) serializes this GAP sequence // against on_sync()'s own advertising decision and hid/midi's start/stop. BleCtx* root_ctx = ble_get_ctx(device); @@ -149,8 +154,8 @@ static error_t spp_stop(struct Device* device) { ble_gap_terminate(conn, BLE_ERR_REM_USER_CONN_TERM); ble_spp_set_conn_handle(device, BLE_HS_CONN_HANDLE_NONE); } - // Do NOT restart advertising after user-initiated stop — restarting name-only - // advertising causes bonded Windows hosts to auto-reconnect in a tight loop. + // Do not restart advertising after a user-initiated stop, a bonded host would reconnect in + // a tight loop. Only another active profile keeps the radio on the air. if (!ble_midi_get_active(device) && !ble_hid_get_active(device)) { ble_gap_adv_stop(); } diff --git a/TactilityKernel/include/tactility/drivers/bluetooth.h b/TactilityKernel/include/tactility/drivers/bluetooth.h index 931219c7e..d1f6a695c 100644 --- a/TactilityKernel/include/tactility/drivers/bluetooth.h +++ b/TactilityKernel/include/tactility/drivers/bluetooth.h @@ -24,6 +24,15 @@ struct DeviceType; */ #define BLE_DEVICE_NAME_MAX 64 +// ---- Identity ---- + +/** + * Maximum BLE identity length in bytes, excluding the NUL terminator. + * The identity is never advertised: it is hashed into the radio's advertising address and + * used to select which bond store the radio loads. + */ +#define BLE_IDENTITY_MAX 64 + // ---- Address ---- #define BT_ADDR_LEN 6 @@ -215,6 +224,12 @@ struct BluetoothApi { */ bool (*is_scanning)(struct Device* device); + /** + * @param[in] device the bluetooth device + * @return true when a peripheral profile (HID device, SPP or MIDI) is active + */ + bool (*is_peripheral_active)(struct Device* device); + /** * Initiate pairing with a peer. * @param[in] device the bluetooth device @@ -241,22 +256,22 @@ struct BluetoothApi { error_t (*get_paired_peers)(struct Device* device, struct BtPeerRecord* out, size_t* count); /** - * Connect to a peer using the specified profile. + * Connect to a peer as the central device. + * Central role only, HID host is the only central profile today. Peripheral profiles are + * started with their own bluetooth__start() call instead. * @param[in] device the bluetooth device * @param[in] addr the peer address - * @param[in] profile the profile to connect with * @return ERROR_NONE on success */ - error_t (*connect)(struct Device* device, const BtAddr addr, enum BtProfileId profile); + error_t (*host_connect)(struct Device* device, const BtAddr addr); /** - * Disconnect a peer from the specified profile. + * Disconnect the peer connected as the central device. * @param[in] device the bluetooth device * @param[in] addr the peer address - * @param[in] profile the profile to disconnect from * @return ERROR_NONE on success */ - error_t (*disconnect)(struct Device* device, const BtAddr addr, enum BtProfileId profile); + error_t (*host_disconnect)(struct Device* device, const BtAddr addr); /** * Register a subscription for this device's BtEvents. @@ -302,6 +317,35 @@ struct BluetoothApi { * @return ERROR_NONE on success */ error_t (*get_device_name)(struct Device* device, char* buf, size_t buf_len); + + /** + * Set the stable identity this radio advertises under. + * The identity determines the static random advertising address and which bond set is + * loaded, so different identities appear as separate bonded devices on the peer even + * though they share one controller. Only its hash is used, the string is never transmitted. + * + * Defaults to the device name (see set_device_name()). While the radio is off the change + * applies on the next enable. While it is on the change is pending until the next + * peripheral profile start with the radio idle, or until the radio cycles. get_identity() + * reports the identity in use. + * + * @param[in] device the bluetooth device + * @param[in] identity NUL-terminated identity (max BLE_IDENTITY_MAX bytes), NULL or "" + * reverts to the device name + * @return ERROR_NONE on success + * @return ERROR_INVALID_ARGUMENT if identity is too long + */ + error_t (*set_identity)(struct Device* device, const char* identity); + + /** + * Get the identity the radio is currently advertising under (the resolved one, i.e. the + * device name when no explicit identity was ever set). + * @param[in] device the bluetooth device + * @param[out] buf buffer to write the NUL-terminated identity into + * @param[in] buf_len size of buf (must be >= BLE_IDENTITY_MAX + 1) + * @return ERROR_NONE on success + */ + error_t (*get_identity)(struct Device* device, char* buf, size_t buf_len); }; extern const struct DeviceType BLUETOOTH_TYPE; @@ -325,14 +369,39 @@ bool bluetooth_is_radio_on_or_pending(struct Device* device); error_t bluetooth_scan_start(struct Device* device); error_t bluetooth_scan_stop(struct Device* device); bool bluetooth_is_scanning(struct Device* device); +/** @return true when a peripheral profile (HID device, SPP or MIDI) is active */ +bool bluetooth_is_peripheral_active(struct Device* device); error_t bluetooth_pair(struct Device* device, const BtAddr addr); error_t bluetooth_unpair(struct Device* device, const BtAddr addr); error_t bluetooth_get_paired_peers(struct Device* device, struct BtPeerRecord* out, size_t* count); -error_t bluetooth_connect(struct Device* device, const BtAddr addr, enum BtProfileId profile); -error_t bluetooth_disconnect(struct Device* device, const BtAddr addr, enum BtProfileId profile); + +/** Connect to a peer as the central device (HID host). */ +error_t bluetooth_host_connect(struct Device* device, const BtAddr addr); +/** Disconnect the peer connected as the central device. */ +error_t bluetooth_host_disconnect(struct Device* device, const BtAddr addr); error_t bluetooth_set_device_name(struct Device* device, const char* name); error_t bluetooth_get_device_name(struct Device* device, char* buf, size_t buf_len); +/** + * Set the stable identity this radio advertises under (see BluetoothApi::set_identity). + * Defaults to the device name when never set. Applies on the next radio enable, or at the + * next peripheral profile start while the radio is on and idle. + * @param[in] device the bluetooth device + * @param[in] identity NUL-terminated identity (max BLE_IDENTITY_MAX bytes), NULL or "" reverts + * to the device name + * @return ERROR_NONE on success, ERROR_INVALID_ARGUMENT if too long + */ +error_t bluetooth_set_identity(struct Device* device, const char* identity); + +/** + * Get the identity the radio is currently advertising under. + * @param[in] device the bluetooth device + * @param[out] buf buffer to write the NUL-terminated identity into + * @param[in] buf_len size of buf (must be >= BLE_IDENTITY_MAX + 1) + * @return ERROR_NONE on success + */ +error_t bluetooth_get_identity(struct Device* device, char* buf, size_t buf_len); + /** * Register a subscription for @a device's BtEvents. * @warning Does not work in ISR context. diff --git a/TactilityKernel/source/drivers/bluetooth.cpp b/TactilityKernel/source/drivers/bluetooth.cpp index 0b477dd3a..5b15ff5af 100644 --- a/TactilityKernel/source/drivers/bluetooth.cpp +++ b/TactilityKernel/source/drivers/bluetooth.cpp @@ -53,6 +53,10 @@ bool bluetooth_is_scanning(struct Device* device) { return BT_API(device)->is_scanning(device); } +bool bluetooth_is_peripheral_active(struct Device* device) { + return BT_API(device)->is_peripheral_active(device); +} + // ---- Pairing ---- error_t bluetooth_pair(struct Device* device, const BtAddr addr) { @@ -69,12 +73,12 @@ error_t bluetooth_get_paired_peers(struct Device* device, struct BtPeerRecord* o // ---- Connect / disconnect ---- -error_t bluetooth_connect(struct Device* device, const BtAddr addr, enum BtProfileId profile) { - return BT_API(device)->connect(device, addr, profile); +error_t bluetooth_host_connect(struct Device* device, const BtAddr addr) { + return BT_API(device)->host_connect(device, addr); } -error_t bluetooth_disconnect(struct Device* device, const BtAddr addr, enum BtProfileId profile) { - return BT_API(device)->disconnect(device, addr, profile); +error_t bluetooth_host_disconnect(struct Device* device, const BtAddr addr) { + return BT_API(device)->host_disconnect(device, addr); } // ---- Event subscription ---- @@ -136,6 +140,14 @@ error_t bluetooth_get_device_name(struct Device* device, char* buf, size_t buf_l return BT_API(device)->get_device_name(device, buf, buf_len); } +error_t bluetooth_set_identity(struct Device* device, const char* identity) { + return BT_API(device)->set_identity(device, identity); +} + +error_t bluetooth_get_identity(struct Device* device, char* buf, size_t buf_len) { + return BT_API(device)->get_identity(device, buf, buf_len); +} + // ---- Device type ---- const struct DeviceType BLUETOOTH_TYPE = { diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 424f516fe..1652c0507 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -390,16 +390,19 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(bluetooth_scan_start), DEFINE_MODULE_SYMBOL(bluetooth_scan_stop), DEFINE_MODULE_SYMBOL(bluetooth_is_scanning), + DEFINE_MODULE_SYMBOL(bluetooth_is_peripheral_active), DEFINE_MODULE_SYMBOL(bluetooth_pair), DEFINE_MODULE_SYMBOL(bluetooth_unpair), DEFINE_MODULE_SYMBOL(bluetooth_get_paired_peers), - DEFINE_MODULE_SYMBOL(bluetooth_connect), - DEFINE_MODULE_SYMBOL(bluetooth_disconnect), + DEFINE_MODULE_SYMBOL(bluetooth_host_connect), + DEFINE_MODULE_SYMBOL(bluetooth_host_disconnect), DEFINE_MODULE_SYMBOL(bluetooth_event_subscribe), DEFINE_MODULE_SYMBOL(bluetooth_event_unsubscribe), DEFINE_MODULE_SYMBOL(bluetooth_event_poll), DEFINE_MODULE_SYMBOL(bluetooth_set_device_name), DEFINE_MODULE_SYMBOL(bluetooth_get_device_name), + DEFINE_MODULE_SYMBOL(bluetooth_set_identity), + DEFINE_MODULE_SYMBOL(bluetooth_get_identity), DEFINE_MODULE_SYMBOL(BLUETOOTH_TYPE), // drivers/bluetooth_serial DEFINE_MODULE_SYMBOL(bluetooth_serial_get),