diff --git a/Tactility/Include/Tactility/bluetooth/BluetoothPairedDevice.h b/Tactility/Include/Tactility/bluetooth/BluetoothPairedDevice.h index 96ee6ebd..4f6dcb12 100644 --- a/Tactility/Include/Tactility/bluetooth/BluetoothPairedDevice.h +++ b/Tactility/Include/Tactility/bluetooth/BluetoothPairedDevice.h @@ -13,6 +13,8 @@ struct PairedDevice { bool autoConnect = false; /** Profile used to pair (BtProfileId value). Defaults to BT_PROFILE_SPP=2. */ int profileId = 2; + /** BLE address type (0=PUBLIC, 1=RANDOM, etc). Defaults to PUBLIC for backward compat. */ + uint8_t addrType = 0; }; std::string addrToHex(const std::array& addr); diff --git a/Tactility/Source/bluetooth/Bluetooth.cpp b/Tactility/Source/bluetooth/Bluetooth.cpp index b3a17c92..7d037c32 100644 --- a/Tactility/Source/bluetooth/Bluetooth.cpp +++ b/Tactility/Source/bluetooth/Bluetooth.cpp @@ -115,6 +115,15 @@ static void bt_event_bridge(Device*, void* /*context*/, BtEvent event) { case BT_RADIO_STATE_ON: getMainDispatcher().dispatch([] { auto peers = settings::loadAll(); + LOG_I(TAG, "RADIO ON: loaded %d paired peers", (int)peers.size()); + for (const auto& p : peers) { + LOG_I(TAG, " - peer %s name='%s' profile=%d auto=%d type=%d", + settings::addrToHex(p.addr).c_str(), + p.name.c_str(), + p.profileId, + (int)p.autoConnect, + (int)p.addrType); + } bool has_hid_host_auto = false; bool has_hid_device_auto = false; for (const auto& p : peers) { @@ -122,28 +131,36 @@ static void bt_event_bridge(Device*, void* /*context*/, BtEvent event) { if (p.profileId == BT_PROFILE_HID_HOST) has_hid_host_auto = true; if (p.profileId == BT_PROFILE_HID_DEVICE) has_hid_device_auto = true; } + LOG_I(TAG, "RADIO ON: has_hid_host_auto=%d has_hid_device_auto=%d spp=%d midi=%d", + (int)has_hid_host_auto, (int)has_hid_device_auto, + (int)settings::shouldSppAutoStart(), (int)settings::shouldMidiAutoStart()); + // Start all auto-connect/auto-start roles that are configured. + // Scanning (central) and advertising (peripheral) can run concurrently + // on ESP32 NimBLE, so we no longer use exclusive else-if. if (has_hid_host_auto) { LOG_I(TAG, "HID host auto-connect peer found — starting scan"); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { bluetooth_scan_start(dev); } - } else if (has_hid_device_auto) { + } + if (has_hid_device_auto) { LOG_I(TAG, "HID device auto-start (bonded peer found)"); if (Device* dev = bluetooth_hid_device_get_device()) { bluetooth_hid_device_start(dev, BT_HID_DEVICE_MODE_KEYBOARD); } - } else { - if (settings::shouldSppAutoStart()) { - LOG_I(TAG, "Auto-starting SPP server"); - if (Device* dev = bluetooth_serial_get_device()) { - bluetooth_serial_start(dev); - } + } + // SPP and MIDI are peripheral servers; start them if their global + // auto-start flags are set, regardless of HID roles. + if (settings::shouldSppAutoStart()) { + LOG_I(TAG, "Auto-starting SPP server"); + if (Device* dev = bluetooth_serial_get_device()) { + bluetooth_serial_start(dev); } - if (settings::shouldMidiAutoStart()) { - LOG_I(TAG, "Auto-starting MIDI server"); - if (Device* dev = bluetooth_midi_get_device()) { - bluetooth_midi_start(dev); - } + } + if (settings::shouldMidiAutoStart()) { + LOG_I(TAG, "Auto-starting MIDI server"); + if (Device* dev = bluetooth_midi_get_device()) { + bluetooth_midi_start(dev); } } }); @@ -176,6 +193,9 @@ static void bt_event_bridge(Device*, void* /*context*/, BtEvent event) { int profile_copy = event.pair_result.profile; memcpy(addr_buf, event.pair_result.addr, 6); getMainDispatcher().dispatch([addr_buf, profile_copy]() mutable { + // Ensure Bluetooth auto-enables on boot after successful pairing, + // so previously connected devices reconnect after a restart. + settings::setEnableOnBoot(true); std::array peer_addr; memcpy(peer_addr.data(), addr_buf, 6); const auto hex = settings::addrToHex(peer_addr); @@ -185,6 +205,13 @@ static void bt_event_bridge(Device*, void* /*context*/, BtEvent event) { dev.name = ""; dev.autoConnect = true; dev.profileId = profile_copy; + // Try to preserve addrType from scan cache if available + uint8_t cached_type = 0; + if (getCachedScanAddrType(peer_addr.data(), &cached_type)) { + dev.addrType = cached_type; + } else { + dev.addrType = 0; + } if (settings::save(dev)) { LOG_I(TAG, "Saved paired peer %s (profile=%d)", hex.c_str(), profile_copy); } @@ -290,6 +317,11 @@ bool start(Device* dev) { return false; } + // Persist enable-on-boot so that paired devices auto-reconnect after a restart. + // The settings file is written from the main task to avoid blocking the NimBLE host + // task, but the dispatcher may run immediately, so we also set it here. + settings::setEnableOnBoot(true); + LOG_I(TAG, "BT enabled"); return true; } @@ -414,6 +446,9 @@ void unpair(const std::array& addr) { void connect(const std::array& addr, int profileId) { LOG_I(TAG, "connect(profile=%d)", profileId); + // Ensure BT restarts in the same mode after reboot, so previously connected + // devices can be re-found (scan) or reconnected to (advertising). + settings::setEnableOnBoot(true); if (profileId == BT_PROFILE_HID_HOST) { hidHostConnect(addr); } else if (profileId == BT_PROFILE_HID_DEVICE) { diff --git a/Tactility/Source/bluetooth/BluetoothHidHost.cpp b/Tactility/Source/bluetooth/BluetoothHidHost.cpp index e621ea0c..82518007 100644 --- a/Tactility/Source/bluetooth/BluetoothHidHost.cpp +++ b/Tactility/Source/bluetooth/BluetoothHidHost.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -480,6 +481,8 @@ static void hidHostSubscribeNext(HidHostCtx& ctx) { auto peer_addr = ctx.peerAddr; getMainDispatcher().dispatch([peer_addr] { + // Ensure BT stays on after reboot so this keyboard can be re-found. + settings::setEnableOnBoot(true); // Find name from cached scan results std::string name; { @@ -488,17 +491,34 @@ static void hidHostSubscribeNext(HidHostCtx& ctx) { if (r.addr == peer_addr) { name = r.name; break; } } } + uint8_t cached_type = 0; + bool has_cached_type = getCachedScanAddrType(peer_addr.data(), &cached_type); settings::PairedDevice device; device.addr = peer_addr; device.profileId = BT_PROFILE_HID_HOST; device.autoConnect = true; + device.addrType = has_cached_type ? cached_type : 0; const auto addr_hex = settings::addrToHex(peer_addr); + LOG_I(TAG, "HID host ready: saving device %s name='%s' cached_type=%d has_cached=%d", + addr_hex.c_str(), name.c_str(), (int)cached_type, (int)has_cached_type); settings::PairedDevice existing; if (settings::load(addr_hex, existing)) { + LOG_I(TAG, "Existing file found for %s, preserving autoConnect=%d", addr_hex.c_str(), (int)existing.autoConnect); device.autoConnect = existing.autoConnect; + // Preserve existing addrType if we don't have a cached one + if (!has_cached_type) { + device.addrType = existing.addrType; + } + // Preserve stored name if scan didn't provide one + if (name.empty() && !existing.name.empty()) { + name = existing.name; + } + } else { + LOG_I(TAG, "No existing file for %s, creating new", addr_hex.c_str()); } device.name = name; - settings::save(device); + bool saved = settings::save(device); + LOG_I(TAG, "Save result for %s: %d", addr_hex.c_str(), (int)saved); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { BtEvent e = {}; e.type = BT_EVENT_PROFILE_STATE_CHANGED; @@ -794,13 +814,22 @@ void hidHostConnect(const std::array& addr) { // Notify driver that a HID host central connection is starting. if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) bluetooth_set_hid_host_active(dev, true); - // Look up the addr_type from the cached scan results. + // Look up the addr_type from the cached scan results, or from persisted storage. + // For RPA devices we may have cached RPA type, but we want to connect using identity. + // Prefer cached scan type for direct scan-match, fallback to stored file's addrType. ble_addr_t ble_addr = {}; ble_addr.type = BLE_ADDR_PUBLIC; std::memcpy(ble_addr.val, addr.data(), 6); uint8_t addr_type = 0; if (getCachedScanAddrType(addr.data(), &addr_type)) { ble_addr.type = addr_type; + } else { + // Try persisted addrType if available + const auto hex = settings::addrToHex(addr); + settings::PairedDevice stored; + if (settings::load(hex, stored)) { + ble_addr.type = stored.addrType; + } } uint8_t own_addr_type; @@ -847,34 +876,58 @@ bool hidHostGetConnectedPeer(std::array& addr_out) { void autoConnectHidHost() { if (hidHostIsConnected()) return; - // Connect to the first saved HID host peer that appeared in the last scan. - // cacheScanAddr() is populated during scanning so addr_type is available for ble_gap_connect. + // Gather all stored peers that want auto-connect as HID host (central). + auto all_peers = settings::loadAll(); + std::vector auto_peers; + for (const auto& p : all_peers) { + if (p.autoConnect && p.profileId == BT_PROFILE_HID_HOST) { + auto_peers.push_back(p); + } + } + if (auto_peers.empty()) return; + auto scan = getScanResults(); + + // 1. Direct address match (public address devices, most keyboards). + // cacheScanAddr() is populated during scanning so addr_type is available for ble_gap_connect. for (const auto& r : scan) { settings::PairedDevice stored; if (settings::load(settings::addrToHex(r.addr), stored) && stored.autoConnect && stored.profileId == BT_PROFILE_HID_HOST) { - LOG_I(TAG, "Auto-connecting HID host to %s", settings::addrToHex(r.addr).c_str()); + LOG_I(TAG, "Auto-connecting HID host to %s (direct match)", settings::addrToHex(r.addr).c_str()); hidHostConnect(r.addr); return; } } - // Device not in the last scan. If we have an autoConnect HID host peer, restart - // scanning so we keep checking until the device powers back on. - auto peers = settings::loadAll(); - for (const auto& peer : peers) { - if (peer.autoConnect && peer.profileId == BT_PROFILE_HID_HOST) { - if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { - if (!bluetooth_is_scanning(dev)) { - LOG_I(TAG, "Auto-connect HID host: device not in scan, retrying scan"); - bluetooth_scan_start(dev); - } + // 2. RPA / name fallback: some peripherals use Resolvable Private Addresses. + // Their advertised address (RPA) does not equal the stored identity address, + // so hex-lookup fails. If we see a scan result whose name matches a stored + // auto-connect peer, attempt a direct connection to the stored identity address. + // The controller's resolving list (populated from NVS IRK) will resolve the RPA. + for (const auto& r : scan) { + if (r.name.empty()) continue; + for (const auto& stored : auto_peers) { + if (!stored.name.empty() && stored.name == r.name) { + LOG_I(TAG, "Auto-connecting HID host to %s via name match '%s' (RPA handling: scan=%s stored=%s)", + settings::addrToHex(stored.addr).c_str(), + r.name.c_str(), + settings::addrToHex(r.addr).c_str(), + settings::addrToHex(stored.addr).c_str()); + hidHostConnect(stored.addr); + return; } - break; } } + + // 3. Direct connect fallback: if device not in scan (or uses RPA without name in adv), + // try to connect directly to the first stored auto peer. ble_gap_connect() will + // internally scan and use the resolving list to match RPA to identity. + // This also covers the case where the keyboard is powered off and later on. + LOG_I(TAG, "Auto-connect HID host: %d auto peer(s) not in scan, trying direct connect to %s", + (int)auto_peers.size(), settings::addrToHex(auto_peers[0].addr).c_str()); + hidHostConnect(auto_peers[0].addr); } } // namespace tt::bluetooth diff --git a/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp b/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp index c425b48a..2c1b7873 100644 --- a/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp +++ b/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp @@ -19,10 +19,11 @@ constexpr auto* TAG = "BluetoothPairedDevice"; // Use the same directory as the old service for backward compatibility. constexpr auto* DEVICE_SETTINGS_FORMAT = "{}/{}.device.properties"; -constexpr auto* KEY_NAME = "name"; -constexpr auto* KEY_ADDR = "addr"; +constexpr auto* KEY_NAME = "name"; +constexpr auto* KEY_ADDR = "addr"; constexpr auto* KEY_AUTO_CONNECT = "autoConnect"; -constexpr auto* KEY_PROFILE_ID = "profileId"; +constexpr auto* KEY_PROFILE_ID = "profileId"; +constexpr auto* KEY_ADDR_TYPE = "addrType"; static std::string getSettingsFilePath() { return getUserDataPath() + "/service/bluetooth"; @@ -78,8 +79,23 @@ bool load(const std::string& addr_hex, PairedDevice& device) { device.autoConnect = !map.contains(KEY_AUTO_CONNECT) || (map[KEY_AUTO_CONNECT] == "true"); if (map.contains(KEY_PROFILE_ID)) { - // TODO: Handle incorrect parsing input - device.profileId = std::stoi(map[KEY_PROFILE_ID]); + char* endPtr = nullptr; + long val = std::strtol(map[KEY_PROFILE_ID].c_str(), &endPtr, 10); + if (endPtr != map[KEY_PROFILE_ID].c_str()) { + device.profileId = static_cast(val); + } + } + + if (map.contains(KEY_ADDR_TYPE)) { + char* endPtr = nullptr; + long val = std::strtol(map[KEY_ADDR_TYPE].c_str(), &endPtr, 10); + if (endPtr != map[KEY_ADDR_TYPE].c_str() && val >= 0 && val <= 255) { + device.addrType = static_cast(val); + } else { + device.addrType = 0; + } + } else { + device.addrType = 0; // backward compat: assume PUBLIC } return true; } @@ -91,12 +107,17 @@ bool save(const PairedDevice& device) { map[KEY_ADDR] = addr_hex; map[KEY_AUTO_CONNECT] = device.autoConnect ? "true" : "false"; map[KEY_PROFILE_ID] = std::to_string(device.profileId); + map[KEY_ADDR_TYPE] = std::to_string(device.addrType); auto file_path = getFilePath(addr_hex); + LOG_I(TAG, "Saving device file %s profile=%d auto=%d type=%d", + file_path.c_str(), device.profileId, (int)device.autoConnect, (int)device.addrType); if (!file::findOrCreateParentDirectory(file_path, 0755)) { LOG_E(TAG, "Failed to create parent dir for %s", file_path.c_str()); return false; } - return file::savePropertiesFile(file_path, map); + bool result = file::savePropertiesFile(file_path, map); + LOG_I(TAG, "SavePropertiesFile result for %s: %d", file_path.c_str(), (int)result); + return result; } bool remove(const std::string& addr_hex) { @@ -108,6 +129,7 @@ bool remove(const std::string& addr_hex) { std::vector loadAll() { std::vector entries; if (!file::isDirectory(getSettingsFilePath())) { + LOG_I(TAG, "loadAll: directory %s does not exist", getSettingsFilePath().c_str()); return {}; } file::scandir(getSettingsFilePath(), entries, [](const dirent* entry) -> int { @@ -116,6 +138,7 @@ std::vector loadAll() { return name.ends_with(".device.properties") ? 0 : -1; }, nullptr); + LOG_I(TAG, "loadAll: found %d entries in %s", (int)entries.size(), getSettingsFilePath().c_str()); std::vector result; result.reserve(entries.size()); for (const auto& entry : entries) { @@ -126,6 +149,8 @@ std::vector loadAll() { PairedDevice device; if (load(addr_hex, device)) { result.push_back(std::move(device)); + } else { + LOG_W(TAG, "loadAll: failed to load %s", filename.c_str()); } } return result;