Bluetooth (#518)

* Bluetooth LE addition

* fixes

* use the psram!

helps a little on S3 (t-deck)

* custom device name

* Update symbols.c

* Feedback + fixes

Fixes external app start/stop server (child devices)
Fixes BtManage causing a full system hang upon disabling bt when a device is connected to the host.

* updoot

* more updoot

* move back!

* Revert "move back!"

This reverts commit d3694365c634acc5db62ac59771c496cb971a727.

* fix some of the things

* Addressing feedback? hmm

* Fixes

Bug 1 — Reconnect loop / Reconnect not working fixed
Bug 2 — Name-only advertising overwrites HID advertising
Bug 3 — BleHidDeviceCtx leak on re-enable
Enhancement — HID device auto-start on radio re-enable

* stuff...

* update for consistency with others

* fix crashes

and some bonus symbols

* a few symbols, i2c speed, cdn message

100kHz i2c speed seems to be more compatible with m5stack modules...and probably in general.
cdn message no longer applies

* Hide BT Settings when bt not enabled

* Addressing things and device fixes

* Missed one!

* stuff
This commit is contained in:
Shadowtrance
2026-05-23 06:49:37 +10:00
committed by GitHub
parent 895e6bc50d
commit 5c78d55b04
72 changed files with 7155 additions and 352 deletions
+2
View File
@@ -58,6 +58,8 @@ tactility_add_module(Tactility
)
if (DEFINED ENV{ESP_IDF_VERSION})
idf_component_optional_requires(PRIVATE bt)
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_compile_options(${COMPONENT_LIB} PUBLIC -Wno-unused-variable)
endif ()
@@ -0,0 +1,9 @@
#pragma once
#include <Tactility/app/App.h>
namespace tt::app::btmanage {
LaunchId start();
} // namespace tt::app::btmanage
@@ -0,0 +1,99 @@
#pragma once
#include <array>
#include <string>
#include <vector>
#include <cstdint>
struct Device;
namespace tt::bluetooth {
enum class RadioState {
Off,
OnPending,
On,
OffPending,
};
struct PeerRecord {
std::array<uint8_t, 6> addr;
std::string name;
int8_t rssi;
bool paired;
bool connected;
/** Profile used to pair (BtProfileId value). Only meaningful for paired peers. */
int profileId = 0;
};
/** Find the first ready BLE device in the kernel device registry. Returns nullptr if unavailable. */
struct Device* findFirstDevice();
/** @return the current radio state */
RadioState getRadioState();
/** For logging purposes */
const char* radioStateToString(RadioState state);
/** @return the peers found during the last scan */
std::vector<PeerRecord> getScanResults();
/** @return the list of currently paired peers */
std::vector<PeerRecord> getPairedPeers();
/**
* @brief Initiate pairing with a peer.
* Returns immediately; result is delivered via kernel event callback (BT_EVENT_PAIR_RESULT).
*/
void pair(const std::array<uint8_t, 6>& addr);
/**
* @brief Remove a previously paired peer.
* @param[in] addr the peer address
*/
void unpair(const std::array<uint8_t, 6>& addr);
/**
* @brief Connect to a peer using the specified profile.
* @param[in] addr the peer address
* @param[in] profileId the BtProfileId value (from bluetooth.h)
*/
void connect(const std::array<uint8_t, 6>& addr, int profileId);
/**
* @brief Disconnect a peer from the specified profile.
* @param[in] addr the peer address
* @param[in] profileId the BtProfileId value (from bluetooth.h)
*/
void disconnect(const std::array<uint8_t, 6>& addr, int profileId);
/**
* @brief Check whether a given profile is supported on this build/SOC.
* @param[in] profileId the BtProfileId value to query
* @return true when the profile is available
*/
bool isProfileSupported(int profileId);
// ---- BLE HID Host (central role — connect to external BLE keyboard/mouse) ----
/**
* @brief Connect to a remote BLE HID device (keyboard, mouse, etc.) as a host.
* Discovery, CCCD subscription, and LVGL indev registration happen automatically.
* @param[in] addr 6-byte BLE address of the HID peripheral
*/
void hidHostConnect(const std::array<uint8_t, 6>& addr);
/** @brief Disconnect from the currently connected BLE HID device. */
void hidHostDisconnect();
/** @return true when a BLE HID peripheral is fully subscribed and acting as LVGL input device */
bool hidHostIsConnected();
/**
* @brief Initialize the Bluetooth bridge layer and optionally enable the radio.
* Called once from Tactility startup (after kernel drivers are ready).
* Reads settings and enables the radio if configured to auto-start.
*/
void systemStart();
} // namespace tt::bluetooth
@@ -0,0 +1,30 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace tt::bluetooth::settings {
struct PairedDevice {
std::string name;
std::array<uint8_t, 6> addr;
bool autoConnect = false;
/** Profile used to pair (BtProfileId value). Defaults to BT_PROFILE_SPP=2. */
int profileId = 2;
};
std::string addrToHex(const std::array<uint8_t, 6>& addr);
bool hasFileForDevice(const std::string& addr_hex);
bool load(const std::string& addr_hex, PairedDevice& device);
bool save(const PairedDevice& device);
bool remove(const std::string& addr_hex);
std::vector<PairedDevice> loadAll();
} // namespace tt::bluetooth::settings
@@ -0,0 +1,14 @@
#pragma once
namespace tt::bluetooth::settings {
void setEnableOnBoot(bool enable);
bool shouldEnableOnBoot();
void setSppAutoStart(bool enable);
bool shouldSppAutoStart();
void setMidiAutoStart(bool enable);
bool shouldMidiAutoStart();
} // namespace tt::bluetooth::settings
@@ -0,0 +1,24 @@
#pragma once
#include <array>
#include <cstdint>
namespace tt::app::btmanage {
typedef void (*OnBtToggled)(bool enable);
typedef void (*OnScanToggled)(bool enable);
typedef void (*OnConnectPeer)(const std::array<uint8_t, 6>& addr, int profileId);
typedef void (*OnDisconnectPeer)(const std::array<uint8_t, 6>& addr, int profileId);
typedef void (*OnPairPeer)(const std::array<uint8_t, 6>& addr);
typedef void (*OnForgetPeer)(const std::array<uint8_t, 6>& addr);
struct Bindings {
OnBtToggled onBtToggled;
OnScanToggled onScanToggled;
OnConnectPeer onConnectPeer;
OnDisconnectPeer onDisconnectPeer;
OnPairPeer onPairPeer;
OnForgetPeer onForgetPeer;
};
} // namespace tt::app::btmanage
@@ -0,0 +1,40 @@
#pragma once
#include "./View.h"
#include "./State.h"
#include <Tactility/app/App.h>
#include <Tactility/Mutex.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <tactility/drivers/bluetooth.h>
namespace tt::app::btmanage {
class BtManage final : public App {
Mutex mutex;
Bindings bindings = { };
State state;
View view = View(&bindings, &state);
bool isViewEnabled = false;
struct Device* btDevice = nullptr;
public:
void onBtEvent(const struct BtEvent& event);
BtManage();
void lock();
void unlock();
void onShow(AppContext& app, lv_obj_t* parent) override;
void onHide(AppContext& app) override;
Bindings& getBindings() { return bindings; }
State& getState() { return state; }
void requestViewUpdate();
};
} // namespace tt::app::btmanage
@@ -0,0 +1,41 @@
#pragma once
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/RecursiveMutex.h>
namespace tt::app::btmanage {
class State final {
mutable RecursiveMutex mutex;
bool scanning = false;
bluetooth::RadioState radioState = bluetooth::RadioState::Off;
std::vector<bluetooth::PeerRecord> scanResults;
std::vector<bluetooth::PeerRecord> pairedPeers;
public:
State() = default;
void setScanning(bool isScanning);
bool isScanning() const;
void setRadioState(bluetooth::RadioState state);
bluetooth::RadioState getRadioState() const;
void updateScanResults();
void updatePairedPeers();
std::vector<bluetooth::PeerRecord> getScanResults() const {
auto lock = mutex.asScopedLock();
lock.lock();
return scanResults;
}
std::vector<bluetooth::PeerRecord> getPairedPeers() const {
auto lock = mutex.asScopedLock();
lock.lock();
return pairedPeers;
}
};
} // namespace tt::app::btmanage
@@ -0,0 +1,41 @@
#pragma once
#include "./Bindings.h"
#include "./State.h"
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppPaths.h>
#include <lvgl.h>
namespace tt::app::btmanage {
class View final {
Bindings* bindings;
State* state;
std::unique_ptr<AppPaths> paths;
lv_obj_t* root = nullptr;
lv_obj_t* enable_switch = nullptr;
lv_obj_t* enable_on_boot_switch = nullptr;
lv_obj_t* scanning_spinner = nullptr;
lv_obj_t* peers_list = nullptr;
void updateBtToggle();
void updateEnableOnBootToggle();
void updateScanning();
void updatePeerList();
void createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired, size_t index);
static void onConnect(lv_event_t* event);
public:
View(Bindings* bindings, State* state) : bindings(bindings), state(state) {}
void init(const AppContext& app, lv_obj_t* parent);
void update();
};
} // namespace tt::app::btmanage
@@ -0,0 +1,9 @@
#pragma once
#include <string>
namespace tt::app::btpeersettings {
void start(const std::string& addrHex);
} // namespace tt::app::btpeersettings
@@ -0,0 +1,29 @@
#pragma once
#include <Tactility/bluetooth/Bluetooth.h>
#include <tactility/drivers/bluetooth.h>
struct Device;
namespace tt::bluetooth {
/** Cache a BLE address and its type from a scan result (used by HID host for addr_type lookup). */
void cacheScanAddr(const uint8_t addr[6], uint8_t addr_type);
/**
* Look up the ble_addr_t (including address type) for a peer address in the last scan.
* Returns false and fills type=0 (PUBLIC) if not found.
*/
bool getCachedScanAddrType(const uint8_t addr[6], uint8_t* addr_type_out);
/** Called from BluetoothHidHost.cpp to trigger auto-connect check after scan finishes. */
void autoConnectHidHost();
/**
* Returns the BLE address of the currently fully-connected HID host peer (i.e.
* all CCCDs subscribed and ready). Returns false if no HID host peer is connected.
*/
bool hidHostGetConnectedPeer(std::array<uint8_t, 6>& addr_out);
} // namespace tt::bluetooth
+10
View File
@@ -38,6 +38,8 @@
#include <Tactility/InitEsp.h>
#endif
#include <Tactility/bluetooth/Bluetooth.h>
namespace tt {
static auto LOGGER = Logger("Tactility");
@@ -106,6 +108,8 @@ namespace app {
namespace touchcalibration { extern const AppManifest manifest; }
namespace timezone { extern const AppManifest manifest; }
namespace usbsettings { extern const AppManifest manifest; }
namespace btmanage { extern const AppManifest manifest; }
namespace btpeersettings { extern const AppManifest manifest; }
namespace wifiapsettings { extern const AppManifest manifest; }
namespace wificonnect { extern const AppManifest manifest; }
namespace wifimanage { extern const AppManifest manifest; }
@@ -191,6 +195,11 @@ static void registerInternalApps() {
if (hal::hasDevice(hal::Device::Type::Power)) {
addAppManifest(app::power::manifest);
}
#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED
addAppManifest(app::btmanage::manifest);
addAppManifest(app::btpeersettings::manifest);
#endif
}
static void registerInstalledApp(std::string path) {
@@ -335,6 +344,7 @@ void run(const Configuration& config, Module* dtsModules[], DtsDevice dtsDevices
settings::initTimeZone();
hal::init(*config.hardware);
network::ntp::init();
bluetooth::systemStart();
registerAndStartPrimaryServices();
+189
View File
@@ -0,0 +1,189 @@
#include <Tactility/app/btmanage/BtManagePrivate.h>
#include <Tactility/app/btmanage/View.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/Logger.h>
#include <Tactility/LogMessages.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/Tactility.h>
#include <tactility/lvgl_icon_shared.h>
namespace tt::app::btmanage {
static const auto LOGGER = Logger("BtManage");
extern const AppManifest manifest;
static void onBtToggled(bool enabled) {
struct Device* dev = bluetooth::findFirstDevice();
if (!dev) return;
bluetooth_set_radio_enabled(dev, enabled);
}
static void onScanToggled(bool enabled) {
struct Device* dev = bluetooth::findFirstDevice();
if (!dev) return;
if (enabled) {
bluetooth_scan_start(dev);
} else {
bluetooth_scan_stop(dev);
}
}
static void onConnectPeer(const std::array<uint8_t, 6>& addr, int profileId) {
bluetooth::connect(addr, profileId);
}
static void onDisconnectPeer(const std::array<uint8_t, 6>& addr, int profileId) {
bluetooth::disconnect(addr, profileId);
}
static void onPairPeer(const std::array<uint8_t, 6>& addr) {
// Clicking an unrecognised scan result initiates a HID host connection.
// Bond exchange happens automatically during the first connection.
bluetooth::hidHostConnect(addr);
}
static void onForgetPeer(const std::array<uint8_t, 6>& addr) {
bluetooth::unpair(addr);
}
BtManage::BtManage() {
bindings = (Bindings) {
.onBtToggled = onBtToggled,
.onScanToggled = onScanToggled,
.onConnectPeer = onConnectPeer,
.onDisconnectPeer = onDisconnectPeer,
.onPairPeer = onPairPeer,
.onForgetPeer = onForgetPeer,
};
}
void BtManage::lock() {
mutex.lock();
}
void BtManage::unlock() {
mutex.unlock();
}
void BtManage::requestViewUpdate() {
lock();
if (isViewEnabled) {
if (lvgl::lock(1000)) {
view.update();
lvgl::unlock();
} else {
LOGGER.error(LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "LVGL");
}
}
unlock();
}
void BtManage::onBtEvent(const struct BtEvent& event) {
auto radio_state = bluetooth::getRadioState();
LOGGER.info("Update with state {}", bluetooth::radioStateToString(radio_state));
getState().setRadioState(radio_state);
switch (event.type) {
case BT_EVENT_SCAN_STARTED:
getState().setScanning(true);
break;
case BT_EVENT_SCAN_FINISHED:
getState().setScanning(false);
getState().updateScanResults();
getState().updatePairedPeers();
break;
case BT_EVENT_PEER_FOUND:
getState().updateScanResults();
break;
case BT_EVENT_PAIR_RESULT:
getState().updatePairedPeers();
break;
case BT_EVENT_PROFILE_STATE_CHANGED:
getState().updateScanResults();
getState().updatePairedPeers();
break;
case BT_EVENT_RADIO_STATE_CHANGED:
if (event.radio_state == BT_RADIO_STATE_ON) {
getState().updatePairedPeers();
struct Device* dev = bluetooth::findFirstDevice();
if (dev && !bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
}
}
break;
default:
break;
}
requestViewUpdate();
}
static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent event) {
// BT event callbacks can fire from the NimBLE host task (e.g. DISCONNECT during
// nimble_port_stop shutdown). Calling onBtEvent() synchronously from the NimBLE
// task would block it on the LVGL mutex (held by the LVGL task waiting in
// nimble_port_stop), creating a permanent deadlock. Dispatch to the main task so
// the NimBLE host task is never blocked by BtManage's state updates or LVGL lock.
auto* self = static_cast<BtManage*>(context);
getMainDispatcher().dispatch([self, event] {
self->onBtEvent(event);
});
}
void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
// Initialise state and view before subscribing to avoid incoming events
// racing with state initialisation.
state.setRadioState(bluetooth::getRadioState());
struct Device* dev = bluetooth::findFirstDevice();
state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
state.updateScanResults();
state.updatePairedPeers();
lock();
isViewEnabled = true;
view.init(app, parent);
view.update();
unlock();
btDevice = dev;
if (btDevice) {
bluetooth_add_event_callback(btDevice, this, onKernelBtEvent);
}
auto radio_state = bluetooth::getRadioState();
bool can_scan = radio_state == bluetooth::RadioState::On;
LOGGER.info("Radio: {}, Scanning: {}, Can scan: {}",
bluetooth::radioStateToString(radio_state),
dev ? bluetooth_is_scanning(dev) : false,
can_scan);
if (can_scan && dev && !bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
}
}
void BtManage::onHide(AppContext& app) {
lock();
if (btDevice) {
bluetooth_remove_event_callback(btDevice, onKernelBtEvent);
btDevice = nullptr;
}
isViewEnabled = false;
unlock();
}
extern const AppManifest manifest = {
.appId = "BtManage",
.appName = "Bluetooth",
.appIcon = LVGL_ICON_SHARED_BLUETOOTH,
.appCategory = Category::Settings,
.createApp = create<BtManage>
};
LaunchId start() {
return app::start(manifest.appId);
}
} // namespace tt::app::btmanage
+44
View File
@@ -0,0 +1,44 @@
#include <Tactility/app/btmanage/BtManagePrivate.h>
namespace tt::app::btmanage {
void State::setScanning(bool isScanning) {
auto lock = mutex.asScopedLock();
lock.lock();
scanning = isScanning;
}
bool State::isScanning() const {
auto lock = mutex.asScopedLock();
lock.lock();
return scanning;
}
void State::setRadioState(bluetooth::RadioState s) {
auto lock = mutex.asScopedLock();
lock.lock();
radioState = s;
}
bluetooth::RadioState State::getRadioState() const {
auto lock = mutex.asScopedLock();
lock.lock();
return radioState;
}
void State::updateScanResults() {
// Fetch outside the lock to avoid holding it during a service call.
auto results = bluetooth::getScanResults();
auto lock = mutex.asScopedLock();
lock.lock();
scanResults = std::move(results);
}
void State::updatePairedPeers() {
auto peers = bluetooth::getPairedPeers();
auto lock = mutex.asScopedLock();
lock.lock();
pairedPeers = std::move(peers);
}
} // namespace tt::app::btmanage
+246
View File
@@ -0,0 +1,246 @@
#include <format>
#include <string>
#include <tactility/lvgl_module.h>
#include <Tactility/app/btmanage/View.h>
#include <Tactility/app/btmanage/BtManagePrivate.h>
#include <Tactility/app/btpeersettings/BtPeerSettings.h>
#include <Tactility/Logger.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothSettings.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/Tactility.h>
namespace tt::app::btmanage {
static const auto LOGGER = Logger("BtManageView");
static void onEnableSwitchChanged(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
bt->getBindings().onBtToggled(is_on);
}
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
getMainDispatcher().dispatch([is_on] {
bluetooth::settings::setEnableOnBoot(is_on);
});
}
static void onEnableOnBootParentClicked(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_user_data(event));
bool new_state = !lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
if (new_state) {
lv_obj_add_state(enable_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(enable_switch, LV_STATE_CHECKED);
}
// add/remove_state does not fire LV_EVENT_VALUE_CHANGED, so persist here directly.
getMainDispatcher().dispatch([new_state] {
bluetooth::settings::setEnableOnBoot(new_state);
});
}
static void onScanButtonClicked(lv_event_t* event) {
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
struct Device* dev = bluetooth::findFirstDevice();
bool scanning = dev ? bluetooth_is_scanning(dev) : false;
bt->getBindings().onScanToggled(!scanning);
}
// region Peer list callbacks
struct PeerListItemData {
View* view;
size_t index;
bool isPaired;
};
void View::onConnect(lv_event_t* event) {
auto* data = static_cast<PeerListItemData*>(lv_event_get_user_data(event));
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
auto& state = bt->getState();
if (data->isPaired) {
// Open the per-device settings screen for paired devices
auto peers = state.getPairedPeers();
if (data->index < peers.size()) {
btpeersettings::start(bluetooth::settings::addrToHex(peers[data->index].addr));
}
} else {
// Unrecognised scan result — initiate pairing
auto peers = state.getScanResults();
if (data->index < peers.size()) {
bt->getBindings().onPairPeer(peers[data->index].addr);
}
}
}
// endregion Peer list callbacks
static uint8_t mapRssiToPercentage(int8_t rssi) {
auto abs_rssi = std::abs(rssi);
if (abs_rssi < 30) abs_rssi = 30;
if (abs_rssi > 90) abs_rssi = 90;
return static_cast<uint8_t>((float)(90 - abs_rssi) / 60.f * 100.f);
}
void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired, size_t index) {
const auto percentage = mapRssiToPercentage(record.rssi);
const auto label = record.name.empty()
? std::format("Unknown ({:02x}{:02x}{:02x}{:02x}{:02x}{:02x}) {}%",
record.addr[0], record.addr[1], record.addr[2],
record.addr[3], record.addr[4], record.addr[5],
percentage)
: std::format("{} {}%", record.name, percentage);
auto* button = lv_list_add_button(peers_list, nullptr, label.c_str());
auto* item_data = new PeerListItemData { this, index, isPaired };
lv_obj_set_user_data(button, item_data);
lv_obj_add_event_cb(button, onConnect, LV_EVENT_SHORT_CLICKED, item_data);
lv_obj_add_event_cb(button, [](lv_event_t* e) {
delete static_cast<PeerListItemData*>(lv_obj_get_user_data(lv_event_get_current_target_obj(e)));
}, LV_EVENT_DELETE, nullptr);
}
// region Secondary updates
void View::updateBtToggle() {
lv_obj_clear_state(enable_switch, LV_STATE_ANY);
switch (state->getRadioState()) {
using enum bluetooth::RadioState;
case On:
lv_obj_add_state(enable_switch, LV_STATE_CHECKED);
break;
case OnPending:
lv_obj_add_state(enable_switch, LV_STATE_CHECKED);
lv_obj_add_state(enable_switch, LV_STATE_DISABLED);
break;
case Off:
lv_obj_remove_state(enable_switch, LV_STATE_CHECKED);
lv_obj_remove_state(enable_switch, LV_STATE_DISABLED);
break;
case OffPending:
lv_obj_remove_state(enable_switch, LV_STATE_CHECKED);
lv_obj_add_state(enable_switch, LV_STATE_DISABLED);
break;
}
}
void View::updateEnableOnBootToggle() {
if (enable_on_boot_switch != nullptr) {
lv_obj_clear_state(enable_on_boot_switch, LV_STATE_ANY);
if (bluetooth::settings::shouldEnableOnBoot()) {
lv_obj_add_state(enable_on_boot_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(enable_on_boot_switch, LV_STATE_CHECKED);
}
}
}
void View::updateScanning() {
if (state->getRadioState() == bluetooth::RadioState::On && state->isScanning()) {
lv_obj_remove_flag(scanning_spinner, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(scanning_spinner, LV_OBJ_FLAG_HIDDEN);
}
}
void View::updatePeerList() {
lv_obj_clean(peers_list);
// Enable on boot row
auto* enable_on_boot_wrapper = lv_obj_create(peers_list);
lv_obj_set_size(enable_on_boot_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
auto* enable_label = lv_label_create(enable_on_boot_wrapper);
lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0);
enable_on_boot_switch = lv_switch_create(enable_on_boot_wrapper);
lv_obj_align(enable_on_boot_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(enable_on_boot_switch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_add_event_cb(enable_on_boot_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch);
if (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(enable_on_boot_wrapper, 2, LV_STATE_DEFAULT);
} else {
lv_obj_set_style_pad_ver(enable_on_boot_wrapper, 8, LV_STATE_DEFAULT);
}
updateEnableOnBootToggle();
using enum bluetooth::RadioState;
if (state->getRadioState() == On) {
// Paired peers section
auto paired = state->getPairedPeers();
if (!paired.empty()) {
lv_list_add_text(peers_list, "Paired");
for (size_t i = 0; i < paired.size(); ++i) {
createPeerListItem(paired[i], true, i);
}
}
// Scan results section
auto scan_results = state->getScanResults();
lv_list_add_text(peers_list, "Available");
if (!scan_results.empty()) {
for (size_t i = 0; i < scan_results.size(); ++i) {
createPeerListItem(scan_results[i], false, i);
}
} else if (!state->isScanning()) {
auto* no_devices_label = lv_label_create(peers_list);
lv_label_set_text(no_devices_label, "No devices found.");
}
// Never hide peers_list: it always contains the "Enable on boot" row.
// While scanning with no results the spinner in the toolbar provides feedback.
// Scan button
auto* scan_button = lv_button_create(peers_list);
lv_obj_set_width(scan_button, LV_PCT(100));
lv_obj_set_style_margin_ver(scan_button, 4, LV_STATE_DEFAULT);
auto* scan_label = lv_label_create(scan_button);
lv_label_set_text(scan_label, state->isScanning() ? "Stop scan" : "Scan");
lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, nullptr);
}
}
// endregion Secondary updates
void View::init(const AppContext& app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
root = parent;
paths = app.getPaths();
// Toolbar
auto* toolbar = lvgl::toolbar_create(parent, app);
scanning_spinner = lvgl::toolbar_add_spinner_action(toolbar);
enable_switch = lvgl::toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr);
// Peer list
peers_list = lv_list_create(parent);
lv_obj_set_flex_grow(peers_list, 1);
lv_obj_set_width(peers_list, LV_PCT(100));
}
void View::update() {
updateBtToggle();
updateScanning();
updatePeerList();
}
} // namespace tt::app::btmanage
@@ -0,0 +1,227 @@
#include <Tactility/app/btpeersettings/BtPeerSettings.h>
#include <Tactility/Logger.h>
#include <Tactility/LogMessages.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <tactility/check.h>
#include <tactility/drivers/bluetooth.h>
#include <lvgl.h>
namespace tt::app::btpeersettings {
static const auto LOGGER = Logger("BtPeerSettings");
extern const AppManifest manifest;
void start(const std::string& addrHex) {
auto bundle = std::make_shared<Bundle>();
bundle->putString("addr", addrHex);
app::start(manifest.appId, bundle);
}
class BtPeerSettings : public App {
bool viewEnabled = false;
lv_obj_t* connectButton = nullptr;
lv_obj_t* disconnectButton = nullptr;
std::string addrHex;
std::array<uint8_t, 6> addr = {};
int profileId = BT_PROFILE_HID_HOST;
bool isCurrentlyConnected() const {
for (const auto& p : bluetooth::getPairedPeers()) {
if (p.addr == addr) return p.connected;
}
return false;
}
static void onPressConnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
if (self->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostConnect(self->addr);
} else {
bluetooth::connect(self->addr, self->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
static void onPressDisconnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
if (self->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(self->addr, self->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
static void onPressForget(lv_event_t* event) {
std::vector<std::string> choices = { "Yes", "No" };
alertdialog::start("Confirmation", "Forget this device?", choices);
}
static void onToggleAutoConnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(self->addrHex, device)) {
device.autoConnect = is_on;
if (!bluetooth::settings::save(device)) {
LOGGER.error("Failed to save auto-connect setting");
}
}
}
void requestViewUpdate() const {
if (viewEnabled) {
if (lvgl::lock(1000)) {
updateViews();
lvgl::unlock();
} else {
LOGGER.error(LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "LVGL");
}
}
}
void updateViews() const {
if (isCurrentlyConnected()) {
lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED);
} else {
lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(connectButton, LV_STATE_DISABLED);
}
}
public:
void onCreate(AppContext& app) override {
const auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
addrHex = parameters->getString("addr");
// Load addr and profileId from stored settings — avoids manual hex parsing
// (std::stoul throws on invalid input and exceptions are disabled).
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(addrHex, device)) {
addr = device.addr;
profileId = device.profileId;
}
}
static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) {
static_cast<BtPeerSettings*>(context)->requestViewUpdate();
}
void onShow(AppContext& app, lv_obj_t* parent) override {
if (struct Device* dev = bluetooth::findFirstDevice()) {
bluetooth_add_event_callback(dev, this, onKernelBtEvent);
}
// Load stored settings (name, autoConnect)
bluetooth::settings::PairedDevice device;
bool deviceLoaded = bluetooth::settings::load(addrHex, device);
std::string title = (deviceLoaded && !device.name.empty()) ? device.name : addrHex;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, title);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
connectButton = lv_button_create(wrapper);
lv_obj_set_width(connectButton, LV_PCT(100));
lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, this);
auto* connect_label = lv_label_create(connectButton);
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(connect_label, "Connect");
disconnectButton = lv_button_create(wrapper);
lv_obj_set_width(disconnectButton, LV_PCT(100));
lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, this);
auto* disconnect_label = lv_label_create(disconnectButton);
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(disconnect_label, "Disconnect");
auto* forget_button = lv_button_create(wrapper);
lv_obj_set_width(forget_button, LV_PCT(100));
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, this);
auto* forget_label = lv_label_create(forget_button);
lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(forget_label, "Forget");
// Auto-connect toggle row
auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
if (deviceLoaded && device.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
}
viewEnabled = true;
updateViews();
}
void onHide(AppContext& app) override {
if (struct Device* dev = bluetooth::findFirstDevice()) {
bluetooth_remove_event_callback(dev, onKernelBtEvent);
}
viewEnabled = false;
}
void onResult(AppContext& appContext, LaunchId /*launchId*/, Result result, std::unique_ptr<Bundle> bundle) override {
if (result != Result::Ok || bundle == nullptr) return;
if (alertdialog::getResultIndex(*bundle) != 0) return; // 0 = Yes
// Disconnect first if connected
if (isCurrentlyConnected()) {
if (profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(addr, profileId);
}
}
bluetooth::unpair(addr);
stop();
}
};
extern const AppManifest manifest = {
.appId = "BtPeerSettings",
.appName = "BT Device Settings",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<BtPeerSettings>
};
} // namespace tt::app::btpeersettings
+401
View File
@@ -0,0 +1,401 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_BT_NIMBLE_ENABLED)
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/bluetooth/BluetoothSettings.h>
#include <Tactility/bluetooth/BluetoothPrivate.h>
#include <Tactility/Logger.h>
#include <Tactility/Mutex.h>
#include <Tactility/Tactility.h>
#include <tactility/check.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tactility/drivers/bluetooth_midi.h>
#include <tactility/drivers/bluetooth_serial.h>
#include <array>
#include <cstring>
#include <vector>
namespace tt::bluetooth {
static const auto LOGGER = Logger("Bluetooth");
// ---- Scan result cache (C++ PeerRecord list, updated from BT_EVENT_PEER_FOUND) ----
static Mutex scan_cache_mutex;
static std::vector<PeerRecord> scan_results_cache;
struct CachedAddr {
uint8_t addr[6];
uint8_t addr_type;
};
static std::vector<CachedAddr> scan_addr_cache; // parallel to scan_results_cache
// ---- Device accessor ----
struct Device* findFirstDevice() {
struct Device* found = nullptr;
device_for_each_of_type(&BLUETOOTH_TYPE, &found, [](struct Device* dev, void* ctx) -> bool {
if (device_is_ready(dev)) {
*static_cast<struct Device**>(ctx) = dev;
return false;
}
return true;
});
return found;
}
// ---- Scan cache helpers ----
void cacheScanAddr(const uint8_t addr[6], uint8_t addr_type) {
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
for (auto& entry : scan_addr_cache) {
if (memcmp(entry.addr, addr, 6) == 0) {
entry.addr_type = addr_type;
return;
}
}
CachedAddr e = {};
memcpy(e.addr, addr, 6);
e.addr_type = addr_type;
scan_addr_cache.push_back(e);
}
bool getCachedScanAddrType(const uint8_t addr[6], uint8_t* addr_type_out) {
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
for (const auto& entry : scan_addr_cache) {
if (memcmp(entry.addr, addr, 6) == 0) {
if (addr_type_out) *addr_type_out = entry.addr_type;
return true;
}
}
if (addr_type_out) *addr_type_out = 0;
return false;
}
static void cachePeerRecord(const BtPeerRecord& krecord) {
PeerRecord rec;
memcpy(rec.addr.data(), krecord.addr, 6);
rec.name = krecord.name[0] != '\0' ? krecord.name : "";
rec.rssi = krecord.rssi;
rec.paired = krecord.paired;
rec.connected = krecord.connected;
rec.profileId = 0;
cacheScanAddr(krecord.addr, krecord.addr_type);
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
for (auto& existing : scan_results_cache) {
if (existing.addr == rec.addr) {
if (!rec.name.empty()) existing.name = rec.name;
existing.rssi = rec.rssi;
return;
}
}
scan_results_cache.push_back(std::move(rec));
}
// ---- Bridge callback (registered with kernel driver) ----
// This callback listens to platform driver events to perform auto-start logic
// and settings management. Consumers should register their own callbacks via
// bluetooth_add_event_callback() to receive events directly.
static void bt_event_bridge(struct Device* /*device*/, void* /*context*/, struct BtEvent event) {
switch (event.type) {
case BT_EVENT_RADIO_STATE_CHANGED:
switch (event.radio_state) {
case BT_RADIO_STATE_ON:
getMainDispatcher().dispatch([] {
auto peers = settings::loadAll();
bool has_hid_host_auto = false;
bool has_hid_device_auto = false;
for (const auto& p : peers) {
if (!p.autoConnect) continue;
if (p.profileId == BT_PROFILE_HID_HOST) has_hid_host_auto = true;
if (p.profileId == BT_PROFILE_HID_DEVICE) has_hid_device_auto = true;
}
if (has_hid_host_auto) {
LOGGER.info("HID host auto-connect peer found — starting scan");
if (struct Device* dev = findFirstDevice()) {
bluetooth_scan_start(dev);
}
} else if (has_hid_device_auto) {
LOGGER.info("HID device auto-start (bonded peer found)");
if (struct Device* dev = bluetooth_hid_device_get_device()) {
bluetooth_hid_device_start(dev, BT_HID_DEVICE_MODE_KEYBOARD);
}
} else {
if (settings::shouldSppAutoStart()) {
LOGGER.info("Auto-starting SPP server");
if (struct Device* dev = bluetooth_serial_get_device()) {
bluetooth_serial_start(dev);
}
}
if (settings::shouldMidiAutoStart()) {
LOGGER.info("Auto-starting MIDI server");
if (struct Device* dev = bluetooth_midi_get_device()) {
bluetooth_midi_start(dev);
}
}
}
});
break;
default:
break;
}
break;
case BT_EVENT_SCAN_STARTED:
{
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
scan_results_cache.clear();
scan_addr_cache.clear();
}
break;
case BT_EVENT_SCAN_FINISHED:
getMainDispatcher().dispatch([] { autoConnectHidHost(); });
break;
case BT_EVENT_PEER_FOUND:
cachePeerRecord(event.peer);
break;
case BT_EVENT_PAIR_RESULT:
if (event.pair_result.result == BT_PAIR_RESULT_SUCCESS) {
uint8_t addr_buf[6];
int profile_copy = event.pair_result.profile;
memcpy(addr_buf, event.pair_result.addr, 6);
getMainDispatcher().dispatch([addr_buf, profile_copy]() mutable {
std::array<uint8_t, 6> peer_addr;
memcpy(peer_addr.data(), addr_buf, 6);
const auto hex = settings::addrToHex(peer_addr);
if (!settings::hasFileForDevice(hex)) {
settings::PairedDevice dev;
dev.addr = peer_addr;
dev.name = "";
dev.autoConnect = true;
dev.profileId = profile_copy;
if (settings::save(dev)) {
LOGGER.info("Saved paired peer {} (profile={})", hex, profile_copy);
}
}
});
} else if (event.pair_result.result == BT_PAIR_RESULT_BOND_LOST) {
uint8_t addr_buf[6];
memcpy(addr_buf, event.pair_result.addr, 6);
getMainDispatcher().dispatch([addr_buf]() mutable {
std::array<uint8_t, 6> peer_addr;
memcpy(peer_addr.data(), addr_buf, 6);
settings::remove(settings::addrToHex(peer_addr));
});
}
break;
case BT_EVENT_PROFILE_STATE_CHANGED:
if (event.profile_state.state == BT_PROFILE_STATE_CONNECTED) {
uint8_t addr_buf[6];
int profile_copy = (int)event.profile_state.profile;
memcpy(addr_buf, event.profile_state.addr, 6);
getMainDispatcher().dispatch([addr_buf, profile_copy]() mutable {
std::array<uint8_t, 6> peer_addr;
memcpy(peer_addr.data(), addr_buf, 6);
const auto hex = settings::addrToHex(peer_addr);
settings::PairedDevice stored;
if (settings::load(hex, stored) && stored.profileId != profile_copy) {
stored.profileId = profile_copy;
settings::save(stored);
}
});
// TODO: Fix auto reconnect if user manually disconnects
} else if (event.profile_state.state == BT_PROFILE_STATE_IDLE &&
event.profile_state.profile == BT_PROFILE_HID_HOST) {
// HID host disconnected — check if any peer has autoConnect and re-scan
// so that autoConnectHidHost() fires when the scan finishes.
getMainDispatcher().dispatch([] {
auto peers = settings::loadAll();
bool has_auto = false;
for (const auto& p : peers) {
if (p.autoConnect && p.profileId == BT_PROFILE_HID_HOST) {
has_auto = true;
break;
}
}
if (has_auto) {
if (struct Device* dev = findFirstDevice()) {
if (!bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
}
}
}
});
}
break;
default:
break;
}
}
// ---- systemStart ----
void systemStart() {
struct Device* dev = findFirstDevice();
if (dev == nullptr) {
LOGGER.warn("systemStart: no BLE device found");
return;
}
bluetooth_add_event_callback(dev, nullptr, bt_event_bridge);
if (settings::shouldEnableOnBoot()) {
LOGGER.info("Auto-enabling Bluetooth on boot");
bluetooth_set_radio_enabled(dev, true);
}
}
// ---- Public API ----
const char* radioStateToString(RadioState state) {
switch (state) {
using enum RadioState;
case Off: return "Off";
case OnPending: return "OnPending";
case On: return "On";
case OffPending: return "OffPending";
}
check(false, "not implemented");
}
RadioState getRadioState() {
struct Device* dev = findFirstDevice();
if (dev == nullptr) return RadioState::Off;
BtRadioState state = BT_RADIO_STATE_OFF;
bluetooth_get_radio_state(dev, &state);
switch (state) {
case BT_RADIO_STATE_OFF: return RadioState::Off;
case BT_RADIO_STATE_ON_PENDING: return RadioState::OnPending;
case BT_RADIO_STATE_ON: return RadioState::On;
case BT_RADIO_STATE_OFF_PENDING: return RadioState::OffPending;
}
return RadioState::Off;
}
std::vector<PeerRecord> getScanResults() {
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
return scan_results_cache;
}
std::vector<PeerRecord> getPairedPeers() {
auto stored = settings::loadAll();
std::vector<PeerRecord> result;
result.reserve(stored.size());
std::array<uint8_t, 6> connected_addr = {};
bool hid_host_connected = hidHostGetConnectedPeer(connected_addr);
for (const auto& device : stored) {
PeerRecord record;
record.addr = device.addr;
record.name = device.name;
record.rssi = 0;
record.paired = true;
record.profileId = device.profileId;
record.connected = hid_host_connected && device.addr == connected_addr;
result.push_back(std::move(record));
}
// Synthesize fallback: LittleFS readdir can lag behind fwrite by one tick, so the
// connected peer may not appear in loadAll() yet. Always ensure it is in the list.
if (hid_host_connected) {
bool found = false;
for (const auto& r : result) {
if (r.addr == connected_addr) { found = true; break; }
}
if (!found) {
PeerRecord record;
record.addr = connected_addr;
record.rssi = 0;
record.paired = true;
record.connected = true;
record.profileId = BT_PROFILE_HID_HOST;
// Try to get the name from the scan cache.
{
auto lock = scan_cache_mutex.asScopedLock();
lock.lock();
for (const auto& sr : scan_results_cache) {
if (sr.addr == connected_addr) { record.name = sr.name; break; }
}
}
result.push_back(std::move(record));
}
}
return result;
}
void pair(const std::array<uint8_t, 6>& /*addr*/) {
// Pairing is handled automatically during connection by NimBLE SM.
}
void unpair(const std::array<uint8_t, 6>& addr) {
struct Device* dev = findFirstDevice();
if (dev != nullptr) {
bluetooth_unpair(dev, addr.data());
}
settings::remove(settings::addrToHex(addr));
}
void connect(const std::array<uint8_t, 6>& addr, int profileId) {
LOGGER.info("connect(profile={})", profileId);
if (profileId == BT_PROFILE_HID_HOST) {
hidHostConnect(addr);
} else if (profileId == BT_PROFILE_HID_DEVICE) {
if (struct Device* dev = bluetooth_hid_device_get_device()) {
bluetooth_hid_device_start(dev, BT_HID_DEVICE_MODE_KEYBOARD);
}
} else if (profileId == BT_PROFILE_SPP) {
if (struct Device* dev = bluetooth_serial_get_device()) {
bluetooth_serial_start(dev);
settings::setSppAutoStart(true);
}
} else if (profileId == BT_PROFILE_MIDI) {
if (struct Device* dev = bluetooth_midi_get_device()) {
bluetooth_midi_start(dev);
settings::setMidiAutoStart(true);
}
}
}
void disconnect(const std::array<uint8_t, 6>& addr, int profileId) {
LOGGER.info("disconnect(profile={})", profileId);
if (profileId == BT_PROFILE_HID_HOST) {
hidHostDisconnect();
} else if (profileId == BT_PROFILE_HID_DEVICE) {
if (struct Device* dev = bluetooth_hid_device_get_device()) {
bluetooth_hid_device_stop(dev);
}
} else {
struct Device* dev = findFirstDevice();
if (dev == nullptr) return;
bluetooth_disconnect(dev, addr.data(), (BtProfileId)profileId);
}
}
bool isProfileSupported(int profileId) {
return profileId == BT_PROFILE_HID_HOST ||
profileId == BT_PROFILE_HID_DEVICE ||
profileId == BT_PROFILE_SPP ||
profileId == BT_PROFILE_MIDI;
}
} // namespace tt::bluetooth
#endif // CONFIG_BT_NIMBLE_ENABLED
@@ -0,0 +1,885 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_BT_NIMBLE_ENABLED)
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/bluetooth/BluetoothPrivate.h>
#include <Tactility/Assets.h>
#include <Tactility/Logger.h>
#include <Tactility/Tactility.h>
#include <Tactility/lvgl/Keyboard.h>
#include <Tactility/lvgl/LvglSync.h>
#include <host/ble_gap.h>
#include <host/ble_gatt.h>
#include <host/ble_hs.h>
#include <host/ble_uuid.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <lvgl.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <memory>
#include <vector>
#define TAG "BtHidHost"
#include <esp_log.h>
namespace tt::bluetooth {
static const auto LOGGER = Logger("BtHidHost");
// ---- Report type ----
enum class HidReportType : uint8_t { Unknown = 0, Keyboard, Mouse, Consumer };
struct HidHostInputRpt {
uint16_t valHandle;
uint16_t cccdHandle;
uint16_t rptRefHandle;
uint8_t reportId;
HidReportType type;
};
struct HidHostCtx {
uint16_t connHandle = BLE_HS_CONN_HANDLE_NONE;
uint16_t hidSvcStart = 0;
uint16_t hidSvcEnd = 0;
std::vector<HidHostInputRpt> inputRpts;
std::vector<uint16_t> allChrDefHandles;
int subscribeIdx = 0;
int dscDiscIdx = 0;
int rptRefReadIdx = 0;
uint16_t rptMapHandle = 0;
std::vector<uint8_t> rptMap;
bool securityInitiated = false;
bool typeResolutionDone = false;
bool readyBlockFired = false;
lv_indev_t* kbIndev = nullptr;
lv_indev_t* mouseIndev = nullptr;
lv_obj_t* mouseCursor = nullptr;
std::array<uint8_t, 6> peerAddr = {};
};
// ---- Globals ----
static std::unique_ptr<HidHostCtx> hid_host_ctx;
static QueueHandle_t hid_host_key_queue = nullptr;
static uint8_t hid_host_prev_keys[6] = {};
static esp_timer_handle_t hid_enc_retry_timer = nullptr;
static std::atomic<int32_t> hid_host_mouse_x{0};
static std::atomic<int32_t> hid_host_mouse_y{0};
static std::atomic<bool> hid_host_mouse_btn{false};
static std::atomic<bool> hid_host_mouse_active{false};
#define HID_HOST_KEY_QUEUE_SIZE 64
struct HidHostKeyEvt { uint32_t key; bool pressed; };
// ---- Forward declarations ----
static void hidHostSubscribeNext(HidHostCtx& ctx);
static void hidHostStartRptRefRead(HidHostCtx& ctx);
static void hidHostReadReportMap(HidHostCtx& ctx);
static uint16_t getDescEndHandle(const HidHostCtx& ctx, uint16_t valHandle);
// ---- Keycode mapping ----
static uint32_t hidHostMapKeycode(uint8_t mod, uint8_t kc) {
bool shift = (mod & 0x22) != 0;
switch (kc) {
case 0x28: return LV_KEY_ENTER;
case 0x29: return LV_KEY_ESC;
case 0x2A: return LV_KEY_BACKSPACE;
case 0x4C: return LV_KEY_DEL;
case 0x2B: return shift ? (uint32_t)LV_KEY_PREV : (uint32_t)LV_KEY_NEXT;
case 0x52: return LV_KEY_UP;
case 0x51: return LV_KEY_DOWN;
case 0x50: return LV_KEY_LEFT;
case 0x4F: return LV_KEY_RIGHT;
case 0x4A: return LV_KEY_HOME;
case 0x4D: return LV_KEY_END;
default: break;
}
if (kc >= 0x04 && kc <= 0x1D) {
uint32_t c = static_cast<uint32_t>('a' + (kc - 0x04));
return shift ? (c - 0x20) : c;
}
if (kc >= 0x1E && kc <= 0x27) {
static const char nums[] = "1234567890";
static const char snums[] = "!@#$%^&*()";
int i = kc - 0x1E;
return shift ? static_cast<uint32_t>(snums[i]) : static_cast<uint32_t>(nums[i]);
}
if (kc == 0x2C) return ' ';
return 0;
}
static void hidHostKeyboardReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) {
if (!hid_host_key_queue) { data->state = LV_INDEV_STATE_RELEASED; return; }
HidHostKeyEvt evt = {};
if (xQueueReceive(hid_host_key_queue, &evt, 0) == pdTRUE) {
data->key = evt.key;
data->state = evt.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
data->continue_reading = (uxQueueMessagesWaiting(hid_host_key_queue) > 0);
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
static void hidHostHandleKeyboardReport(const uint8_t* data, uint16_t len) {
if (len < 3 || !hid_host_key_queue) return;
uint8_t mod = data[0];
const uint8_t* curr = &data[2];
int nkeys = std::min((int)(len - 2), 6);
for (int i = 0; i < 6; i++) {
uint8_t kc = hid_host_prev_keys[i];
if (kc == 0) continue;
bool still = false;
for (int j = 0; j < nkeys; j++) { if (curr[j] == kc) { still = true; break; } }
if (!still) {
uint32_t lv = hidHostMapKeycode(0, kc);
if (lv) { HidHostKeyEvt e{lv, false}; xQueueSend(hid_host_key_queue, &e, 0); }
}
}
for (int i = 0; i < nkeys; i++) {
uint8_t kc = curr[i];
if (kc == 0) continue;
bool had = false;
for (int j = 0; j < 6; j++) { if (hid_host_prev_keys[j] == kc) { had = true; break; } }
if (!had) {
uint32_t lv = hidHostMapKeycode(mod, kc);
if (lv) { HidHostKeyEvt e{lv, true}; xQueueSend(hid_host_key_queue, &e, 0); }
}
}
std::memcpy(hid_host_prev_keys, curr, nkeys);
if (nkeys < 6) std::memset(hid_host_prev_keys + nkeys, 0, 6 - nkeys);
}
static void hidHostMouseReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) {
int32_t cx = hid_host_mouse_x.load();
int32_t cy = hid_host_mouse_y.load();
lv_display_t* disp = lv_display_get_default();
if (disp) {
int32_t ow = lv_display_get_original_horizontal_resolution(disp);
int32_t oh = lv_display_get_original_vertical_resolution(disp);
switch (lv_display_get_rotation(disp)) {
case LV_DISPLAY_ROTATION_0:
data->point.x = (lv_coord_t)cx;
data->point.y = (lv_coord_t)cy;
break;
case LV_DISPLAY_ROTATION_90:
data->point.x = (lv_coord_t)cy;
data->point.y = (lv_coord_t)(oh - cx - 1);
break;
case LV_DISPLAY_ROTATION_180:
data->point.x = (lv_coord_t)(ow - cx - 1);
data->point.y = (lv_coord_t)(oh - cy - 1);
break;
case LV_DISPLAY_ROTATION_270:
data->point.x = (lv_coord_t)(ow - cy - 1);
data->point.y = (lv_coord_t)cx;
break;
}
} else {
data->point.x = (lv_coord_t)cx;
data->point.y = (lv_coord_t)cy;
}
data->state = hid_host_mouse_btn.load() ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
if (!hid_host_mouse_active.load()) {
hid_host_mouse_active.store(true);
if (hid_host_ctx && hid_host_ctx->mouseCursor) {
lv_obj_remove_flag(hid_host_ctx->mouseCursor, LV_OBJ_FLAG_HIDDEN);
}
}
}
static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) {
if (len < 3) return;
bool btn = (data[0] & 0x01) != 0;
int8_t dx = (int8_t)data[1];
int8_t dy = (int8_t)data[2];
lv_display_t* disp = lv_display_get_default();
int32_t w = disp ? lv_display_get_horizontal_resolution(disp) : 320;
int32_t h = disp ? lv_display_get_vertical_resolution(disp) : 240;
int32_t nx = hid_host_mouse_x.load() + dx;
int32_t ny = hid_host_mouse_y.load() + dy;
if (nx < 0) nx = 0;
if (nx >= w) nx = w - 1;
if (ny < 0) ny = 0;
if (ny >= h) ny = h - 1;
hid_host_mouse_x.store(nx);
hid_host_mouse_y.store(ny);
hid_host_mouse_btn.store(btn);
if (hid_host_ctx && hid_host_ctx->mouseIndev == nullptr) {
getMainDispatcher().dispatch([] {
if (!hid_host_ctx || hid_host_ctx->mouseIndev != nullptr) return;
if (!tt::lvgl::lock(1000)) { LOGGER.warn("LVGL lock failed for mouse indev"); return; }
auto* ms = lv_indev_create();
lv_indev_set_type(ms, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(ms, hidHostMouseReadCb);
auto* cur = lv_image_create(lv_layer_sys());
lv_obj_remove_flag(cur, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(cur, LV_OBJ_FLAG_HIDDEN);
lv_image_set_src(cur, TT_ASSETS_UI_CURSOR);
lv_indev_set_cursor(ms, cur);
hid_host_ctx->mouseIndev = ms;
hid_host_ctx->mouseCursor = cur;
tt::lvgl::unlock();
LOGGER.info("Mouse indev registered");
});
}
}
// ---- Timer callback for post-encryption CCCD retry ----
static void hidEncRetryTimerCb(void* /*arg*/) {
if (hid_host_ctx) {
if (!hid_host_ctx->typeResolutionDone) {
LOGGER.warn("Post-encryption delay — type resolution timed out, proceeding");
hid_host_ctx->typeResolutionDone = true;
hid_host_ctx->subscribeIdx = 0;
} else {
LOGGER.info("Post-encryption delay complete — starting CCCD subscriptions");
}
hidHostSubscribeNext(*hid_host_ctx);
}
}
// ---- Report Map parsing ----
static void applyReportMapTypes(HidHostCtx& ctx) {
const uint8_t* data = ctx.rptMap.data();
size_t len = ctx.rptMap.size();
uint16_t usagePage = 0, usage = 0;
uint8_t reportId = 0;
int depth = 0;
HidReportType collType = HidReportType::Unknown;
struct Entry { uint8_t id; HidReportType type; };
std::vector<Entry> typeMap;
std::vector<HidReportType> collOrder;
bool collHadInput = false;
size_t i = 0;
while (i < len) {
uint8_t prefix = data[i++];
if (prefix == 0xFE) {
if (i + 1 >= len) break;
uint8_t lsz = data[i++]; i++; i += lsz; continue;
}
uint8_t bSize = prefix & 0x03;
uint8_t bType = (prefix >> 2) & 0x03;
uint8_t bTag = (prefix >> 4) & 0x0F;
uint8_t dataLen = (bSize == 3) ? 4 : bSize;
if (i + dataLen > len) break;
uint32_t value = 0;
for (uint8_t j = 0; j < dataLen; j++) value |= (uint32_t)data[i++] << (8 * j);
if (bType == 0) {
if (bTag == 0xA) {
if (depth == 0 && value == 0x01) {
if (usagePage == 0x01 && usage == 0x06) collType = HidReportType::Keyboard;
else if (usagePage == 0x01 && usage == 0x02) collType = HidReportType::Mouse;
else if (usagePage == 0x0C) collType = HidReportType::Consumer;
else collType = HidReportType::Unknown;
collHadInput = false;
}
depth++; usage = 0;
} else if (bTag == 0xC) {
if (depth > 0) depth--;
if (depth == 0) { collType = HidReportType::Unknown; collHadInput = false; }
usage = 0;
} else if (bTag == 0x8) {
if (depth > 0 && collType != HidReportType::Unknown) {
if (!collHadInput) { collOrder.push_back(collType); collHadInput = true; }
if (reportId != 0) {
bool found = false;
for (const auto& e : typeMap) { if (e.id == reportId) { found = true; break; } }
if (!found) typeMap.push_back({reportId, collType});
}
}
usage = 0;
} else { usage = 0; }
} else if (bType == 1) {
if (bTag == 0x0) usagePage = (uint16_t)value;
else if (bTag == 0x8) reportId = (uint8_t)value;
} else if (bType == 2) {
if (bTag == 0x0) usage = (uint16_t)value;
}
}
bool anyNonZeroId = false;
for (const auto& rpt : ctx.inputRpts) { if (rpt.reportId != 0) { anyNonZeroId = true; break; } }
size_t zeroRptIdx = 0;
for (auto& rpt : ctx.inputRpts) {
if (anyNonZeroId) {
for (const auto& e : typeMap) { if (e.id == rpt.reportId) { rpt.type = e.type; break; } }
} else {
if (zeroRptIdx < collOrder.size()) rpt.type = collOrder[zeroRptIdx];
zeroRptIdx++;
}
LOGGER.info("Report val_handle={} reportId={} type={}", rpt.valHandle, rpt.reportId, (int)rpt.type);
}
ctx.rptMap.clear();
}
// ---- Report Reference read chain ----
static void hidHostStartRptRefRead(HidHostCtx& ctx) {
while (ctx.rptRefReadIdx < (int)ctx.inputRpts.size() &&
ctx.inputRpts[ctx.rptRefReadIdx].rptRefHandle == 0) {
ctx.rptRefReadIdx++;
}
if (ctx.rptRefReadIdx >= (int)ctx.inputRpts.size()) {
hidHostReadReportMap(ctx);
return;
}
uint16_t handle = ctx.inputRpts[ctx.rptRefReadIdx].rptRefHandle;
int rc = ble_gattc_read(ctx.connHandle, handle, [](uint16_t conn_handle,
const struct ble_gatt_error* error,
struct ble_gatt_attr* attr, void* /*arg*/) -> int {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status == BLE_HS_EDONE) return 0;
if (error->status == 0 && attr != nullptr) {
if (OS_MBUF_PKTLEN(attr->om) >= 2 && ctx.rptRefReadIdx < (int)ctx.inputRpts.size()) {
uint8_t rpt_ref[2] = {};
os_mbuf_copydata(attr->om, 0, 2, rpt_ref);
ctx.inputRpts[ctx.rptRefReadIdx].reportId = rpt_ref[0];
LOGGER.info("Report[{}] val_handle={} reportId={}", ctx.rptRefReadIdx,
ctx.inputRpts[ctx.rptRefReadIdx].valHandle, rpt_ref[0]);
}
}
ctx.rptRefReadIdx++;
hidHostStartRptRefRead(ctx);
return 0;
}, nullptr);
if (rc != 0) {
LOGGER.warn("rptRef read[{}] failed rc={} — skipping", ctx.rptRefReadIdx, rc);
ctx.rptRefReadIdx++;
hidHostStartRptRefRead(ctx);
}
}
// ---- Report Map read ----
static void hidHostReadReportMap(HidHostCtx& ctx) {
if (ctx.rptMapHandle == 0) {
LOGGER.info("No Report Map char — skipping type resolution");
ctx.typeResolutionDone = true;
ctx.subscribeIdx = 0;
hidHostSubscribeNext(ctx);
return;
}
int rc = ble_gattc_read_long(ctx.connHandle, ctx.rptMapHandle, 0,
[](uint16_t conn_handle, const struct ble_gatt_error* error,
struct ble_gatt_attr* attr, void* /*arg*/) -> int {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status == 0 && attr != nullptr) {
uint16_t chunk = OS_MBUF_PKTLEN(attr->om);
size_t old_sz = ctx.rptMap.size();
ctx.rptMap.resize(old_sz + chunk);
os_mbuf_copydata(attr->om, 0, chunk, ctx.rptMap.data() + old_sz);
return 0;
}
if (!ctx.rptMap.empty()) {
LOGGER.info("Report map read ({} bytes)", ctx.rptMap.size());
applyReportMapTypes(ctx);
} else {
LOGGER.warn("Report map read failed — types remain Unknown");
}
ctx.typeResolutionDone = true;
ctx.subscribeIdx = 0;
hidHostSubscribeNext(ctx);
return 0;
}, nullptr);
if (rc != 0) {
LOGGER.warn("Report map read_long failed rc={} — skipping", rc);
ctx.typeResolutionDone = true;
ctx.subscribeIdx = 0;
hidHostSubscribeNext(ctx);
}
}
// ---- CCCD subscription chain ----
static int hidHostCccdWriteCb(uint16_t conn_handle, const struct ble_gatt_error* error,
struct ble_gatt_attr* /*attr*/, void* /*arg*/) {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status != 0 && error->status != BLE_HS_EDONE) {
if ((error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_INSUFFICIENT_AUTHEN) ||
error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_INSUFFICIENT_ENC))
&& !ctx.securityInitiated) {
LOGGER.info("CCCD auth required — initiating security");
ctx.securityInitiated = true;
ble_gap_security_initiate(conn_handle);
return 0;
}
if (error->status == BLE_HS_ETIMEOUT) {
LOGGER.warn("CCCD write timed out for report[{}] — skipping", ctx.subscribeIdx);
ctx.subscribeIdx++;
hidHostSubscribeNext(ctx);
return 0;
}
if (error->status == BLE_HS_ENOTCONN) {
LOGGER.warn("CCCD write failed — not connected");
return 0;
}
LOGGER.warn("CCCD write failed status={}", error->status);
}
ctx.subscribeIdx++;
hidHostSubscribeNext(ctx);
return 0;
}
static void hidHostSubscribeNext(HidHostCtx& ctx) {
if (ctx.subscribeIdx >= (int)ctx.inputRpts.size()) {
if (ctx.readyBlockFired) {
LOGGER.info("Subscribe ready block already ran — ignoring duplicate");
return;
}
ctx.readyBlockFired = true;
LOGGER.info("All {} reports subscribed — ready", ctx.inputRpts.size());
if (hid_enc_retry_timer) esp_timer_stop(hid_enc_retry_timer);
if (!hid_host_key_queue) {
hid_host_key_queue = xQueueCreate(HID_HOST_KEY_QUEUE_SIZE, sizeof(HidHostKeyEvt));
}
getMainDispatcher().dispatch([] {
if (!hid_host_ctx || hid_host_ctx->kbIndev != nullptr) return;
if (!tt::lvgl::lock(1000)) { LOGGER.warn("LVGL lock failed for kb indev"); return; }
auto* kb = lv_indev_create();
lv_indev_set_type(kb, LV_INDEV_TYPE_KEYPAD);
lv_indev_set_read_cb(kb, hidHostKeyboardReadCb);
hid_host_ctx->kbIndev = kb;
tt::lvgl::hardware_keyboard_set_indev(kb);
tt::lvgl::unlock();
LOGGER.info("Keyboard indev registered");
});
auto peer_addr = ctx.peerAddr;
getMainDispatcher().dispatch([peer_addr] {
// Find name from cached scan results
std::string name;
{
auto results = getScanResults();
for (const auto& r : results) {
if (r.addr == peer_addr) { name = r.name; break; }
}
}
settings::PairedDevice device;
device.addr = peer_addr;
device.profileId = BT_PROFILE_HID_HOST;
device.autoConnect = true;
const auto addr_hex = settings::addrToHex(peer_addr);
settings::PairedDevice existing;
if (settings::load(addr_hex, existing)) {
device.autoConnect = existing.autoConnect;
}
device.name = name;
settings::save(device);
if (struct Device* dev = findFirstDevice()) {
struct BtEvent e = {};
e.type = BT_EVENT_PROFILE_STATE_CHANGED;
e.profile_state.state = BT_PROFILE_STATE_CONNECTED;
e.profile_state.profile = BT_PROFILE_HID_HOST;
bluetooth_fire_event(dev, e);
}
});
return;
}
auto& rpt = ctx.inputRpts[ctx.subscribeIdx];
if (rpt.cccdHandle == 0) {
ctx.subscribeIdx++;
hidHostSubscribeNext(ctx);
return;
}
static const uint16_t notify_val = 0x0001;
int rc = ble_gattc_write_flat(ctx.connHandle, rpt.cccdHandle,
&notify_val, sizeof(notify_val),
hidHostCccdWriteCb, nullptr);
if (rc != 0) {
LOGGER.warn("gattc_write_flat CCCD failed rc={}", rc);
ctx.subscribeIdx++;
hidHostSubscribeNext(ctx);
}
}
// ---- Descriptor discovery ----
static int hidHostDscDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error,
uint16_t chr_val_handle, const struct ble_gatt_dsc* dsc, void* /*arg*/) {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status == 0 && dsc != nullptr) {
uint16_t dsc_uuid = ble_uuid_u16(&dsc->uuid.u);
for (auto& rpt : ctx.inputRpts) {
if (rpt.valHandle != chr_val_handle) continue;
if (dsc_uuid == 0x2902) { rpt.cccdHandle = dsc->handle; }
else if (dsc_uuid == 0x2908) { rpt.rptRefHandle = dsc->handle; }
break;
}
} else if (error->status == BLE_HS_EDONE) {
int next_idx = ctx.dscDiscIdx + 1;
if (next_idx < (int)ctx.inputRpts.size()) {
ctx.dscDiscIdx = next_idx;
auto& next_rpt = ctx.inputRpts[next_idx];
uint16_t end = getDescEndHandle(ctx, next_rpt.valHandle);
int rc = ble_gattc_disc_all_dscs(ctx.connHandle, next_rpt.valHandle, end,
hidHostDscDiscCb, nullptr);
if (rc != 0) {
LOGGER.warn("disc_all_dscs[{}] failed rc={}", next_idx, rc);
ctx.rptRefReadIdx = 0;
hidHostStartRptRefRead(ctx);
}
} else {
ctx.rptRefReadIdx = 0;
hidHostStartRptRefRead(ctx);
}
}
return 0;
}
static uint16_t getDescEndHandle(const HidHostCtx& ctx, uint16_t valHandle) {
for (uint16_t dh : ctx.allChrDefHandles) {
if (dh > valHandle) return dh - 1;
}
return ctx.hidSvcEnd;
}
// ---- Characteristic discovery ----
static int hidHostChrDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error,
const struct ble_gatt_chr* chr, void* /*arg*/) {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status == 0 && chr != nullptr) {
ctx.allChrDefHandles.push_back(chr->def_handle);
uint16_t uuid16 = ble_uuid_u16(&chr->uuid.u);
if (uuid16 == 0x2A4D && (chr->properties & BLE_GATT_CHR_PROP_NOTIFY)) {
HidHostInputRpt rpt = {};
rpt.valHandle = chr->val_handle;
ctx.inputRpts.push_back(rpt);
LOGGER.info("Input Report chr val_handle={}", chr->val_handle);
} else if (uuid16 == 0x2A4B) {
ctx.rptMapHandle = chr->val_handle;
}
} else if (error->status == BLE_HS_EDONE) {
std::sort(ctx.allChrDefHandles.begin(), ctx.allChrDefHandles.end());
if (ctx.inputRpts.empty()) {
LOGGER.warn("No Input Report chars — disconnecting");
ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM);
return 0;
}
ctx.dscDiscIdx = 0;
auto& first = ctx.inputRpts[0];
uint16_t end = getDescEndHandle(ctx, first.valHandle);
int rc = ble_gattc_disc_all_dscs(ctx.connHandle, first.valHandle, end,
hidHostDscDiscCb, nullptr);
if (rc != 0) {
LOGGER.warn("disc_all_dscs[0] failed rc={}", rc);
ctx.rptRefReadIdx = 0;
hidHostStartRptRefRead(ctx);
}
}
return 0;
}
// ---- Service discovery ----
static int hidHostSvcDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error,
const struct ble_gatt_svc* svc, void* /*arg*/) {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
if (conn_handle != ctx.connHandle) return 0;
if (error->status == 0 && svc != nullptr) {
if (ble_uuid_u16(&svc->uuid.u) == 0x1812) {
ctx.hidSvcStart = svc->start_handle;
ctx.hidSvcEnd = svc->end_handle;
LOGGER.info("HID service start={} end={}", ctx.hidSvcStart, ctx.hidSvcEnd);
}
} else if (error->status == BLE_HS_EDONE) {
if (ctx.hidSvcStart == 0) {
LOGGER.warn("No HID service found — disconnecting");
ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM);
return 0;
}
int rc = ble_gattc_disc_all_chrs(ctx.connHandle, ctx.hidSvcStart, ctx.hidSvcEnd,
hidHostChrDiscCb, nullptr);
if (rc != 0) {
LOGGER.warn("disc_all_chrs failed rc={}", rc);
ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM);
}
}
return 0;
}
// ---- GAP callback for HID host central connection ----
static int hidHostGapCb(struct ble_gap_event* event, void* /*arg*/) {
if (!hid_host_ctx) return 0;
auto& ctx = *hid_host_ctx;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
ctx.connHandle = event->connect.conn_handle;
LOGGER.info("Connected (handle={})", ctx.connHandle);
int rc = ble_gattc_disc_all_svcs(ctx.connHandle, hidHostSvcDiscCb, nullptr);
if (rc != 0) {
LOGGER.warn("disc_all_svcs failed rc={}", rc);
ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM);
}
} else {
LOGGER.warn("Connect failed status={}", event->connect.status);
hid_host_ctx.reset();
if (struct Device* dev = findFirstDevice()) {
bluetooth_set_hid_host_active(dev, false);
struct BtEvent e = {};
e.type = BT_EVENT_PROFILE_STATE_CHANGED;
e.profile_state.state = BT_PROFILE_STATE_IDLE;
e.profile_state.profile = BT_PROFILE_HID_HOST;
bluetooth_fire_event(dev, e);
}
}
break;
case BLE_GAP_EVENT_DISCONNECT: {
LOGGER.info("Disconnected reason={}", event->disconnect.reason);
lv_indev_t* saved_kb = hid_host_ctx ? hid_host_ctx->kbIndev : nullptr;
lv_indev_t* saved_mouse = hid_host_ctx ? hid_host_ctx->mouseIndev : nullptr;
lv_obj_t* saved_cursor = hid_host_ctx ? hid_host_ctx->mouseCursor : nullptr;
QueueHandle_t saved_queue = hid_host_key_queue;
hid_host_ctx.reset();
hid_host_key_queue = nullptr;
std::memset(hid_host_prev_keys, 0, sizeof(hid_host_prev_keys));
hid_host_mouse_x.store(0);
hid_host_mouse_y.store(0);
hid_host_mouse_btn.store(false);
hid_host_mouse_active.store(false);
if (struct Device* dev = findFirstDevice()) {
bluetooth_set_hid_host_active(dev, false);
struct BtEvent e = {};
e.type = BT_EVENT_PROFILE_STATE_CHANGED;
e.profile_state.state = BT_PROFILE_STATE_IDLE;
e.profile_state.profile = BT_PROFILE_HID_HOST;
bluetooth_fire_event(dev, e);
}
getMainDispatcher().dispatch([saved_kb, saved_mouse, saved_cursor, saved_queue] {
if (!tt::lvgl::lock(1000)) {
LOGGER.warn("Failed to acquire LVGL lock for indev cleanup");
if (saved_queue) vQueueDelete(saved_queue);
return;
}
if (saved_kb) {
tt::lvgl::hardware_keyboard_set_indev(nullptr);
lv_indev_delete(saved_kb);
}
if (saved_mouse) lv_indev_delete(saved_mouse);
if (saved_cursor) lv_obj_delete(saved_cursor);
tt::lvgl::unlock();
if (saved_queue) vQueueDelete(saved_queue);
});
break;
}
case BLE_GAP_EVENT_ENC_CHANGE:
if (event->enc_change.conn_handle == ctx.connHandle) {
if (event->enc_change.status == 0) {
LOGGER.info("Encryption established — retrying CCCD in 500ms");
ctx.subscribeIdx = 0;
if (hid_enc_retry_timer) {
esp_timer_stop(hid_enc_retry_timer);
esp_timer_start_once(hid_enc_retry_timer, 500 * 1000);
} else {
hidHostSubscribeNext(ctx);
}
} else {
LOGGER.warn("Encryption failed status={}", event->enc_change.status);
}
}
break;
case BLE_GAP_EVENT_NOTIFY_RX:
if (event->notify_rx.conn_handle == ctx.connHandle) {
uint16_t len = OS_MBUF_PKTLEN(event->notify_rx.om);
if (len > 0 && len <= 64) {
uint8_t buf[64] = {};
os_mbuf_copydata(event->notify_rx.om, 0, len, buf);
for (const auto& rpt : ctx.inputRpts) {
if (rpt.valHandle != event->notify_rx.attr_handle) continue;
switch (rpt.type) {
case HidReportType::Keyboard: hidHostHandleKeyboardReport(buf, len); break;
case HidReportType::Mouse: hidHostHandleMouseReport(buf, len); break;
case HidReportType::Consumer:
LOGGER.info("Consumer report len={}", len);
break;
case HidReportType::Unknown:
if (len >= 6) hidHostHandleKeyboardReport(buf, len);
else if (len >= 3) hidHostHandleMouseReport(buf, len);
break;
}
break;
}
}
}
break;
default:
break;
}
return 0;
}
// ---- Public functions ----
void hidHostConnect(const std::array<uint8_t, 6>& addr) {
if (getRadioState() != RadioState::On) {
LOGGER.warn("hidHostConnect: radio not on");
return;
}
if (hid_host_ctx) {
LOGGER.warn("hidHostConnect: already connecting/connected");
return;
}
hid_host_mouse_x.store(0);
hid_host_mouse_y.store(0);
hid_host_mouse_btn.store(false);
hid_host_mouse_active.store(false);
hid_host_ctx = std::make_unique<HidHostCtx>();
hid_host_ctx->peerAddr = addr;
// Create enc retry timer lazily
if (hid_enc_retry_timer == nullptr) {
esp_timer_create_args_t args = {};
args.callback = hidEncRetryTimerCb;
args.dispatch_method = ESP_TIMER_TASK;
args.name = "hid_enc_retry";
if (esp_timer_create(&args, &hid_enc_retry_timer) != ESP_OK) {
LOGGER.error("Failed to create hid_enc_retry timer");
hid_enc_retry_timer = nullptr;
}
}
// Notify driver that a HID host central connection is starting.
if (struct Device* dev = findFirstDevice()) bluetooth_set_hid_host_active(dev, true);
// Look up the addr_type from the cached scan results.
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;
}
uint8_t own_addr_type;
if (ble_hs_id_infer_auto(0, &own_addr_type) != 0) {
own_addr_type = BLE_OWN_ADDR_PUBLIC;
}
int rc = ble_gap_connect(own_addr_type, &ble_addr, 5000, nullptr, hidHostGapCb, nullptr);
if (rc != 0) {
LOGGER.warn("ble_gap_connect failed rc={}", rc);
hid_host_ctx.reset();
if (struct Device* dev = findFirstDevice()) {
bluetooth_set_hid_host_active(dev, false);
// Fire IDLE so bt_event_bridge can start a new scan and retry.
struct BtEvent e = {};
e.type = BT_EVENT_PROFILE_STATE_CHANGED;
e.profile_state.state = BT_PROFILE_STATE_IDLE;
e.profile_state.profile = BT_PROFILE_HID_HOST;
bluetooth_fire_event(dev, e);
}
} else {
LOGGER.info("Connecting...");
}
}
void hidHostDisconnect() {
if (!hid_host_ctx || hid_host_ctx->connHandle == BLE_HS_CONN_HANDLE_NONE) return;
ble_gap_terminate(hid_host_ctx->connHandle, BLE_ERR_REM_USER_CONN_TERM);
}
bool hidHostIsConnected() {
return hid_host_ctx != nullptr &&
hid_host_ctx->connHandle != BLE_HS_CONN_HANDLE_NONE &&
!hid_host_ctx->inputRpts.empty() &&
hid_host_ctx->subscribeIdx >= (int)hid_host_ctx->inputRpts.size();
}
bool hidHostGetConnectedPeer(std::array<uint8_t, 6>& addr_out) {
if (!hidHostIsConnected()) return false;
addr_out = hid_host_ctx->peerAddr;
return true;
}
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.
auto scan = getScanResults();
for (const auto& r : scan) {
settings::PairedDevice stored;
if (settings::load(settings::addrToHex(r.addr), stored) &&
stored.autoConnect &&
stored.profileId == BT_PROFILE_HID_HOST) {
LOGGER.info("Auto-connecting HID host to {}", settings::addrToHex(r.addr));
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 (struct Device* dev = findFirstDevice()) {
if (!bluetooth_is_scanning(dev)) {
LOGGER.info("Auto-connect HID host: device not in scan, retrying scan");
bluetooth_scan_start(dev);
}
}
break;
}
}
}
} // namespace tt::bluetooth
#endif // CONFIG_BT_NIMBLE_ENABLED
@@ -0,0 +1,44 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if !defined(CONFIG_BT_NIMBLE_ENABLED)
#include <Tactility/bluetooth/Bluetooth.h>
namespace tt::bluetooth {
struct Device* findFirstDevice() { return nullptr; }
const char* radioStateToString(RadioState state) {
switch (state) {
using enum RadioState;
case Off: return "Off";
case OnPending: return "OnPending";
case On: return "On";
case OffPending: return "OffPending";
}
return "Unknown";
}
RadioState getRadioState() { return RadioState::Off; }
std::vector<PeerRecord> getScanResults() { return {}; }
std::vector<PeerRecord> getPairedPeers() { return {}; }
void pair(const std::array<uint8_t, 6>& /*addr*/) {}
void unpair(const std::array<uint8_t, 6>& /*addr*/) {}
void connect(const std::array<uint8_t, 6>& /*addr*/, int /*profileId*/) {}
void disconnect(const std::array<uint8_t, 6>& /*addr*/, int /*profileId*/) {}
bool isProfileSupported(int /*profileId*/) { return false; }
void hidHostConnect(const std::array<uint8_t, 6>& /*addr*/) {}
void hidHostDisconnect() {}
bool hidHostIsConnected() { return false; }
void systemStart() {}
} // namespace tt::bluetooth
#endif // !CONFIG_BT_NIMBLE_ENABLED
@@ -0,0 +1,118 @@
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Logger.h>
#include <dirent.h>
#include <format>
#include <iomanip>
#include <sstream>
#include <string>
#include <cstdio>
namespace tt::bluetooth::settings {
static const auto LOGGER = Logger("BluetoothPairedDevice");
// Use the same directory as the old service for backward compatibility.
constexpr auto* DATA_DIR = "/data/service/bluetooth";
constexpr auto* DEVICE_SETTINGS_FORMAT = "{}/{}.device.properties";
constexpr auto* KEY_NAME = "name";
constexpr auto* KEY_ADDR = "addr";
constexpr auto* KEY_AUTO_CONNECT = "autoConnect";
constexpr auto* KEY_PROFILE_ID = "profileId";
std::string addrToHex(const std::array<uint8_t, 6>& addr) {
std::stringstream stream;
stream << std::hex;
for (int i = 0; i < 6; ++i) {
stream << std::setw(2) << std::setfill('0') << static_cast<int>(addr[i]);
}
return stream.str();
}
static bool hexToAddr(const std::string& hex, std::array<uint8_t, 6>& addr) {
if (hex.size() != 12) {
LOGGER.error("hexToAddr() length mismatch: expected 12, got {}", hex.size());
return false;
}
char buf[3] = { 0 };
for (int i = 0; i < 6; ++i) {
buf[0] = hex[i * 2];
buf[1] = hex[i * 2 + 1];
char* endptr = nullptr;
addr[i] = static_cast<uint8_t>(strtoul(buf, &endptr, 16));
if (endptr != buf + 2) {
LOGGER.error("hexToAddr() invalid hex at byte {}: '{}{}'", i, buf[0], buf[1]);
return false;
}
}
return true;
}
static std::string getFilePath(const std::string& addr_hex) {
return std::format(DEVICE_SETTINGS_FORMAT, DATA_DIR, addr_hex);
}
bool hasFileForDevice(const std::string& addr_hex) {
return file::isFile(getFilePath(addr_hex));
}
bool load(const std::string& addr_hex, PairedDevice& device) {
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(getFilePath(addr_hex), map)) return false;
if (!map.contains(KEY_ADDR)) return false;
if (!hexToAddr(map[KEY_ADDR], device.addr)) return false;
device.name = map.contains(KEY_NAME) ? map[KEY_NAME] : "";
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]);
}
return true;
}
bool save(const PairedDevice& device) {
const auto addr_hex = addrToHex(device.addr);
std::map<std::string, std::string> map;
map[KEY_NAME] = device.name;
map[KEY_ADDR] = addr_hex;
map[KEY_AUTO_CONNECT] = device.autoConnect ? "true" : "false";
map[KEY_PROFILE_ID] = std::to_string(device.profileId);
return file::savePropertiesFile(getFilePath(addr_hex), map);
}
bool remove(const std::string& addr_hex) {
const auto file_path = getFilePath(addr_hex);
if (!file::isFile(file_path)) return false;
return ::remove(file_path.c_str()) == 0;
}
std::vector<PairedDevice> loadAll() {
std::vector<dirent> entries;
file::scandir(DATA_DIR, entries, [](const dirent* entry) -> int {
if (entry->d_type != file::TT_DT_REG && entry->d_type != file::TT_DT_UNKNOWN) return -1;
std::string name = entry->d_name;
return name.ends_with(".device.properties") ? 0 : -1;
}, nullptr);
std::vector<PairedDevice> result;
result.reserve(entries.size());
for (const auto& entry : entries) {
std::string filename = entry.d_name;
constexpr std::string_view suffix = ".device.properties";
if (filename.size() <= suffix.size()) continue;
const std::string addr_hex = filename.substr(0, filename.size() - suffix.size());
PairedDevice device;
if (load(addr_hex, device)) {
result.push_back(std::move(device));
}
}
return result;
}
} // namespace tt::bluetooth::settings
@@ -0,0 +1,103 @@
#include <Tactility/bluetooth/BluetoothSettings.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Logger.h>
#include <Tactility/Mutex.h>
namespace tt::bluetooth::settings {
static const auto LOGGER = Logger("BluetoothSettings");
// Use the same path as the old service so existing settings survive migration.
constexpr auto* SETTINGS_PATH = "/data/service/bluetooth/settings.properties";
constexpr auto* KEY_ENABLE_ON_BOOT = "enableOnBoot";
constexpr auto* KEY_SPP_AUTO_START = "sppAutoStart";
constexpr auto* KEY_MIDI_AUTO_START = "midiAutoStart";
struct BluetoothSettings {
bool enableOnBoot = false;
bool sppAutoStart = false;
bool midiAutoStart = false;
};
static Mutex settings_mutex;
static BluetoothSettings cached;
static bool cached_valid = false;
static bool load(BluetoothSettings& out) {
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(SETTINGS_PATH, map)) {
return false;
}
auto it = map.find(KEY_ENABLE_ON_BOOT);
if (it == map.end()) return false;
out.enableOnBoot = (it->second == "true");
auto spp_it = map.find(KEY_SPP_AUTO_START);
out.sppAutoStart = (spp_it != map.end() && spp_it->second == "true");
auto midi_it = map.find(KEY_MIDI_AUTO_START);
out.midiAutoStart = (midi_it != map.end() && midi_it->second == "true");
return true;
}
static bool save(const BluetoothSettings& s) {
std::map<std::string, std::string> map;
file::loadPropertiesFile(SETTINGS_PATH, map); // ignore failure — may not exist yet
map[KEY_ENABLE_ON_BOOT] = s.enableOnBoot ? "true" : "false";
map[KEY_SPP_AUTO_START] = s.sppAutoStart ? "true" : "false";
map[KEY_MIDI_AUTO_START] = s.midiAutoStart ? "true" : "false";
return file::savePropertiesFile(SETTINGS_PATH, map);
}
static BluetoothSettings getCachedOrLoad() {
settings_mutex.lock();
if (!cached_valid) {
if (!load(cached)) {
cached = BluetoothSettings{};
}
cached_valid = true;
}
auto result = cached;
settings_mutex.unlock();
return result;
}
void setEnableOnBoot(bool enable) {
settings_mutex.lock();
cached.enableOnBoot = enable;
cached_valid = true;
settings_mutex.unlock();
if (!save(cached)) LOGGER.error("Failed to save");
}
bool shouldEnableOnBoot() {
return getCachedOrLoad().enableOnBoot;
}
void setSppAutoStart(bool enable) {
settings_mutex.lock();
cached.sppAutoStart = enable;
cached_valid = true;
settings_mutex.unlock();
if (!save(cached)) LOGGER.error("Failed to save (setSppAutoStart)");
}
bool shouldSppAutoStart() {
return getCachedOrLoad().sppAutoStart;
}
void setMidiAutoStart(bool enable) {
settings_mutex.lock();
cached.midiAutoStart = enable;
cached_valid = true;
settings_mutex.unlock();
if (!save(cached)) LOGGER.error("Failed to save (setMidiAutoStart)");
}
bool shouldMidiAutoStart() {
return getCachedOrLoad().midiAutoStart;
}
} // namespace tt::bluetooth::settings
+8
View File
@@ -6,6 +6,7 @@
namespace tt::lvgl {
static lv_indev_t* keyboard_device = nullptr;
static lv_group_t* pending_keyboard_group = nullptr;
void software_keyboard_show(lv_obj_t* textarea) {
auto gui_service = service::gui::findService();
@@ -31,12 +32,14 @@ bool software_keyboard_is_enabled() {
}
void software_keyboard_activate(lv_group_t* group) {
pending_keyboard_group = group;
if (keyboard_device != nullptr) {
lv_indev_set_group(keyboard_device, group);
}
}
void software_keyboard_deactivate() {
pending_keyboard_group = nullptr;
if (keyboard_device != nullptr) {
lv_indev_set_group(keyboard_device, nullptr);
}
@@ -48,6 +51,11 @@ bool hardware_keyboard_is_available() {
void hardware_keyboard_set_indev(lv_indev_t* device) {
keyboard_device = device;
// If an app already activated a keyboard group while no hardware keyboard was
// connected, apply the pending group now that the device is available.
if (device != nullptr && pending_keyboard_group != nullptr) {
lv_indev_set_group(device, pending_keyboard_group);
}
}
}
@@ -11,6 +11,10 @@
#include <Tactility/service/ServiceContext.h>
#include <Tactility/service/ServicePaths.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_serial.h>
#include <tactility/drivers/bluetooth_midi.h>
#include <Tactility/service/gps/GpsService.h>
#include <Tactility/service/wifi/Wifi.h>
#include <tactility/check.h>
@@ -57,6 +61,21 @@ static const char* getWifiStatusIcon(wifi::RadioState state) {
}
}
static const char* getBluetoothStatusIcon(tt::bluetooth::RadioState state, bool scanning, bool connected) {
switch (state) {
using enum tt::bluetooth::RadioState;
case Off:
case OffPending:
return nullptr; // hidden when off
case OnPending:
case On:
if (connected) return LVGL_ICON_STATUSBAR_BLUETOOTH_CONNECTED;
if (scanning) return LVGL_ICON_STATUSBAR_BLUETOOTH_SEARCHING;
return LVGL_ICON_STATUSBAR_BLUETOOTH;
}
return nullptr;
}
static const char* getSdCardStatusIcon(bool mounted) {
if (mounted) return LVGL_ICON_STATUSBAR_SD_CARD;
return LVGL_ICON_STATUSBAR_SD_CARD_ALERT;
@@ -107,6 +126,8 @@ class StatusbarService final : public Service {
std::unique_ptr<Timer> updateTimer;
int8_t gps_icon_id;
bool gps_last_state = false;
int8_t bt_icon_id;
const char* bt_last_icon = nullptr;
int8_t wifi_icon_id;
const char* wifi_last_icon = nullptr;
int8_t sdcard_icon_id;
@@ -136,6 +157,26 @@ class StatusbarService final : public Service {
}
}
void updateBluetoothIcon() {
auto radio_state = tt::bluetooth::getRadioState();
struct Device* btdev = tt::bluetooth::findFirstDevice();
bool scanning = btdev ? bluetooth_is_scanning(btdev) : false;
struct Device* serial_dev = bluetooth_serial_get_device();
struct Device* midi_dev = bluetooth_midi_get_device();
bool connected = (serial_dev && bluetooth_serial_is_connected(serial_dev)) ||
(midi_dev && bluetooth_midi_is_connected(midi_dev));
const char* desired_icon = getBluetoothStatusIcon(radio_state, scanning, connected);
if (bt_last_icon != desired_icon) {
if (desired_icon != nullptr) {
lvgl::statusbar_icon_set_image(bt_icon_id, desired_icon);
lvgl::statusbar_icon_set_visibility(bt_icon_id, true);
} else {
lvgl::statusbar_icon_set_visibility(bt_icon_id, false);
}
bt_last_icon = desired_icon;
}
}
void updateWifiIcon() {
wifi::RadioState radio_state = wifi::getRadioState();
const char* desired_icon = getWifiStatusIcon(radio_state);
@@ -186,6 +227,7 @@ class StatusbarService final : public Service {
if (lvgl::isStarted()) {
if (lvgl::lock(100)) {
updateGpsIcon();
updateBluetoothIcon();
updateWifiIcon();
updateSdCardIcon();
updatePowerStatusIcon();
@@ -198,6 +240,7 @@ public:
StatusbarService() {
gps_icon_id = lvgl::statusbar_icon_add();
bt_icon_id = lvgl::statusbar_icon_add();
sdcard_icon_id = lvgl::statusbar_icon_add();
wifi_icon_id = lvgl::statusbar_icon_add();
power_icon_id = lvgl::statusbar_icon_add();
@@ -206,6 +249,7 @@ public:
~StatusbarService() override {
lvgl::statusbar_icon_remove(wifi_icon_id);
lvgl::statusbar_icon_remove(sdcard_icon_id);
lvgl::statusbar_icon_remove(bt_icon_id);
lvgl::statusbar_icon_remove(power_icon_id);
lvgl::statusbar_icon_remove(gps_icon_id);
}