From 1ca5a75c21b2eee464e331ada288843b4cb5fe14 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 25 Aug 2026 22:43:32 +0200 Subject: [PATCH 1/3] Fix for EspNowBridge --- Apps/EspNowBridge/main/Source/main.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Apps/EspNowBridge/main/Source/main.cpp b/Apps/EspNowBridge/main/Source/main.cpp index 248632b..8f753c4 100644 --- a/Apps/EspNowBridge/main/Source/main.cpp +++ b/Apps/EspNowBridge/main/Source/main.cpp @@ -69,8 +69,10 @@ int main(int argc, char* argv[]) { window_manager_remove(window); check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); + // Must unsubscribe from the WiFi event group (inside espNowBridgeTeardown()) before + // destructing it below - releasing a subscription's bit needs the group to still be alive. espNowBridgeTeardown(ctx.get()); + task_event_group_destruct(&event_group); return 0; } From 5ccf1be2e1701099abb25534eb26c7b13017035f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 25 Aug 2026 22:52:50 +0200 Subject: [PATCH 2/3] Update MediaKeys for bluetooth API changes --- Apps/MediaKeys/main/Source/MediaKeys.cpp | 114 ++++++++++++----------- Apps/MediaKeys/main/Source/MediaKeys.h | 17 +++- Apps/MediaKeys/main/Source/main.cpp | 17 +++- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/Apps/MediaKeys/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index 057416b..2f058ec 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -73,7 +73,7 @@ void onSwitchToggled(lv_event_t* event); void onButtonPressed(lv_event_t* event); void onKeyEvent(lv_event_t* event); void onKeyHighlightTimer(lv_timer_t* t); -void btEventCallback(struct Device* device, void* context, struct BtEvent event); +void handleBtEvent(Context* ctx, const BtEvent& event); void handleSwitchToggle(Context* ctx, bool enabled); void onSwitchToggled(lv_event_t* event) { @@ -164,10 +164,7 @@ void onKeyHighlightTimer(lv_timer_t* t) { lv_timer_pause(t); } -void btEventCallback(struct Device* /*device*/, void* context, struct BtEvent event) { - auto* ctx = static_cast(context); - if (!ctx) return; - +void handleBtEvent(Context* ctx, const BtEvent& event) { if (event.type == BT_EVENT_RADIO_STATE_CHANGED) { LOG_I(TAG, "BT radio state: %d", (int)event.radio_state); @@ -207,17 +204,12 @@ void btEventCallback(struct Device* /*device*/, void* context, struct BtEvent ev void startHid(Context* ctx) { // Called once the BT radio is confirmed ON (either already was, or just came up). - // May be called from the BT event callback thread - LVGL must already be locked by caller. + // May be called from handleBtEvent() on the app's own task - LVGL must already be locked by caller. ctx->radioEnabling = false; ctx->hidDevice = bluetooth_hid_device_get_device(); if (!ctx->hidDevice) { LOG_E(TAG, "BLE HID device unavailable after radio on"); - if (ctx->btDevice) { - bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); - device_put(ctx->btDevice); - ctx->btDevice = nullptr; - } ctx->isEnabled = false; if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; @@ -226,11 +218,6 @@ void startHid(Context* ctx) { error_t err = bluetooth_hid_device_start(ctx->hidDevice, BT_HID_DEVICE_MODE_KEYBOARD); if (err != ERROR_NONE) { LOG_E(TAG, "Failed to start HID device: %d", (int)err); - if (ctx->btDevice) { - bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); - device_put(ctx->btDevice); - ctx->btDevice = nullptr; - } ctx->hidDevice = nullptr; ctx->isEnabled = false; if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); @@ -241,22 +228,28 @@ void startHid(Context* ctx) { if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx); } +// Restores the radio to the state we found it in when we turned it on ourselves. Does not touch +// the BT event subscription - that's app-lifetime, set up by mediaKeysInitBt()/torn down by +// teardownBt() at app exit, not per toggle. +void restoreRadioIfNeeded(Context* ctx) { + if (ctx->btDevice && ctx->radioWasOff) bluetooth_set_radio_enabled(ctx->btDevice, false); + ctx->radioWasOff = false; +} + void teardownBt(Context* ctx) { - // Remove callback FIRST - stops any in-flight BT events from firing against - // our (possibly already freed) UI widget pointers after this returns. - if (ctx->btDevice) bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); + // Unsubscribe FIRST - stops any in-flight BT events from firing against our (possibly + // already freed) UI widget pointers after this returns. + if (ctx->btDevice) bluetooth_event_unsubscribe(ctx->btDevice, &ctx->btEventSub); // Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() / // ble_gatts_start() which corrupts NimBLE heap while the host task is still // running. HID device is a persistent kernel device; hid_device_start() cleans // up stale context on next use. Explicit stop is handled by handleSwitchToggle. - // Restore the radio/device to the state we found them in. - if (ctx->btDevice && ctx->radioWasOff) bluetooth_set_radio_enabled(ctx->btDevice, false); - if (ctx->btDevice && ctx->deviceWasStarted) device_stop(ctx->btDevice); + // The device itself is never stopped, it's started for the process lifetime (see + // Documentation/bluetooth-app-migration.md) - just restore the radio and drop our ref. + restoreRadioIfNeeded(ctx); if (ctx->btDevice) device_put(ctx->btDevice); ctx->btDevice = nullptr; ctx->hidDevice = nullptr; - ctx->radioWasOff = false; - ctx->deviceWasStarted = false; } void handleSwitchToggle(Context* ctx, bool enabled) { @@ -264,33 +257,15 @@ void handleSwitchToggle(Context* ctx, bool enabled) { ctx->isEnabled = enabled; if (enabled) { - if (device_get_first_by_type(&BLUETOOTH_TYPE, &ctx->btDevice) != ERROR_NONE) { + if (!ctx->btDevice) { LOG_E(TAG, "No Bluetooth device found"); - ctx->btDevice = nullptr; ctx->isEnabled = false; if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; } - // Device may not be started yet (BT disabled in DTS by default to save memory). - if (!device_is_ready(ctx->btDevice)) { - LOG_I(TAG, "BT device not started, starting now"); - if (device_start(ctx->btDevice) != ERROR_NONE) { - LOG_E(TAG, "Failed to start BT device"); - device_put(ctx->btDevice); - ctx->btDevice = nullptr; - ctx->isEnabled = false; - if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); - return; - } - ctx->deviceWasStarted = true; - } - bluetooth_set_device_name(ctx->btDevice, "Tactility Media Keys"); - // Register callback before enabling radio so we don't miss the state-change event. - bluetooth_add_event_callback(ctx->btDevice, ctx, btEventCallback); - enum BtRadioState radioState; bluetooth_get_radio_state(ctx->btDevice, &radioState); @@ -299,8 +274,10 @@ void handleSwitchToggle(Context* ctx, bool enabled) { ctx->radioWasOff = false; startHid(ctx); } else { - // Turn the radio on; startHid() will be called from btEventCallback - // once BT_RADIO_STATE_ON fires. + // Turn the radio on; startHid() will be called from handleBtEvent() + // once BT_RADIO_STATE_ON fires. Our BT event subscription (mediaKeysInitBt()) + // was already claimed before the app's main loop started waiting, so that + // wakeup won't be missed. LOG_I(TAG, "BT radio not on (state=%d), enabling...", (int)radioState); ctx->radioWasOff = true; ctx->radioEnabling = true; @@ -312,7 +289,8 @@ void handleSwitchToggle(Context* ctx, bool enabled) { // Explicit user toggle-off: stop HID cleanly (safe here since we're on the // LVGL task and the user intentionally disabled, so no race with app teardown). if (ctx->hidDevice) bluetooth_hid_device_stop(ctx->hidDevice); - teardownBt(ctx); + ctx->hidDevice = nullptr; + restoreRadioIfNeeded(ctx); if (ctx->mainWrapper) lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); } } @@ -336,6 +314,34 @@ void handleButtonPress(Context* ctx, uint32_t buttonId) { } // namespace +bool mediaKeysInitBt(Context* ctx) { + // ble0 is started for the process lifetime once enabled in the devicetree - just look it up, + // no device_start() needed (see Documentation/bluetooth-app-migration.md). + if (device_get_first_by_type(&BLUETOOTH_TYPE, &ctx->btDevice) != ERROR_NONE) { + LOG_E(TAG, "No Bluetooth device found"); + ctx->btDevice = nullptr; + return false; + } + // Must claim this subscription's bit before the app's main loop makes its first + // task_event_group_wait_any() call - a bit claimed mid-wait isn't included until the next + // call, so a radio-on event fired right after a late subscribe could go unnoticed forever. + if (bluetooth_event_subscribe(ctx->btDevice, &ctx->btEventSub, ctx->eventGroup) != ERROR_NONE) { + LOG_E(TAG, "Failed to subscribe to BT events"); + device_put(ctx->btDevice); + ctx->btDevice = nullptr; + return false; + } + return true; +} + +void mediaKeysProcessBtEvents(Context* ctx) { + if (ctx->btDevice == nullptr) return; + BtEvent event {}; + while (bluetooth_event_poll(&ctx->btEventSub, &event) == ERROR_NONE) { + handleBtEvent(ctx, event); + } +} + void mediaKeysCreateWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); @@ -386,17 +392,13 @@ void mediaKeysCreateWidgets(lv_obj_t* parent, void* userData) { lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); // Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app). - // Transient lookup only - handleSwitchToggle() acquires its own reference into ctx->btDevice. - struct Device* btDev = nullptr; - if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDev) == ERROR_NONE) { - if (device_is_ready(btDev)) { - enum BtRadioState radioState; - if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) { - lv_obj_add_state(ctx->switchWidget, LV_STATE_CHECKED); - handleSwitchToggle(ctx, true); - } + // ctx->btDevice was already acquired by mediaKeysInitBt() before this ran. + if (ctx->btDevice && device_is_ready(ctx->btDevice)) { + enum BtRadioState radioState; + if (bluetooth_get_radio_state(ctx->btDevice, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) { + lv_obj_add_state(ctx->switchWidget, LV_STATE_CHECKED); + handleSwitchToggle(ctx, true); } - device_put(btDev); } } diff --git a/Apps/MediaKeys/main/Source/MediaKeys.h b/Apps/MediaKeys/main/Source/MediaKeys.h index 177dac6..f5e86b5 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.h +++ b/Apps/MediaKeys/main/Source/MediaKeys.h @@ -12,6 +12,7 @@ struct Context { AppInstanceId appInstanceId = 0; WindowId window = 0; + struct TaskEventGroup* eventGroup = nullptr; // borrowed from main(); outlives ctx // UI elements lv_obj_t* mainWrapper = nullptr; @@ -24,17 +25,27 @@ struct Context { // HAL device handles struct Device* btDevice = nullptr; struct Device* hidDevice = nullptr; + struct BtEventSubscription btEventSub {}; - // State - accessed from both LVGL thread and BT callback thread + // State - accessed from both LVGL thread and the app's own task (BT event poll loop) std::atomic isEnabled {false}; std::atomic radioEnabling {false}; // true while waiting for radio to come ON std::atomic radioWasOff {false}; // true if we turned the radio on (restore on exit) - std::atomic deviceWasStarted{false}; // true if we called device_start (restore on exit) }; /** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ void mediaKeysCreateWidgets(lv_obj_t* parent, void* userData); -/** Removes the BT callback, stops HID, restores radio/device state, releases widget-tracking +/** Looks up the BT device and subscribes to its events, claiming a bit in ctx->eventGroup. + * Must be called once, before the app's main loop starts blocking on that event group (its bit + * has to already be claimed by the first task_event_group_wait_any() call - see that function's + * warning about bits claimed mid-wait). @return true on success. */ +bool mediaKeysInitBt(Context* ctx); + +/** Drains any BT events queued for ctx and reacts to them (radio state, HID profile state). + * Call from the app's main loop after task_event_group_wait_any() returns. */ +void mediaKeysProcessBtEvents(Context* ctx); + +/** Removes the BT event subscription, stops HID, restores radio state, releases widget-tracking * state. Call once, after the window has been torn down. */ void mediaKeysTeardown(Context* ctx); diff --git a/Apps/MediaKeys/main/Source/main.cpp b/Apps/MediaKeys/main/Source/main.cpp index 34284ae..79f7156 100644 --- a/Apps/MediaKeys/main/Source/main.cpp +++ b/Apps/MediaKeys/main/Source/main.cpp @@ -15,18 +15,23 @@ extern "C" { int main(int argc, char* argv[]) { AppInstanceId app_instance_id = app_scheduler_current_app_id(); - // Heap-allocated: the BT event callback (bluetooth_add_event_callback) captures ctx's - // address for a background BT-stack thread to call back into, so it can't be a stack frame - // that goes away while that callback might still fire. + // Heap-allocated: BT event subscription/HID background work holds a raw Context* across the + // whole app instance lifetime, well past any single stack frame here. auto ctx = std::make_unique(); ctx->appInstanceId = app_instance_id; struct TaskEventGroup event_group {}; task_event_group_construct(&event_group); + ctx->eventGroup = &event_group; struct AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + // Must happen before window_manager_create() (which synchronously builds widgets and may + // auto-enable BT) and before the wait loop below makes its first task_event_group_wait_any() + // call - see mediaKeysInitBt()'s doc comment. + mediaKeysInitBt(ctx.get()); + WindowId window = window_manager_create(app_instance_id, mediaKeysCreateWidgets, ctx.get()); ctx->window = window; @@ -41,12 +46,16 @@ int main(int argc, char* argv[]) { } if (should_close) break; } + + mediaKeysProcessBtEvents(ctx.get()); } window_manager_remove(window); check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); + // Must unsubscribe from the BT event group (inside mediaKeysTeardown()) before destructing + // it below - releasing a subscription's bit needs the group to still be alive. mediaKeysTeardown(ctx.get()); + task_event_group_destruct(&event_group); return 0; } From 5353e0eb51537a4f75071e0ee34648bdc0f307e0 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Wed, 26 Aug 2026 00:28:35 +0200 Subject: [PATCH 3/3] Update app versions --- Apps/EspNowBridge/manifest.properties | 4 ++-- Apps/MediaKeys/manifest.properties | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Apps/EspNowBridge/manifest.properties b/Apps/EspNowBridge/manifest.properties index 417d0b7..ef94329 100644 --- a/Apps/EspNowBridge/manifest.properties +++ b/Apps/EspNowBridge/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32p4 app.id=tactility.espnowbridge -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.8.0 +app.version.code=8 app.name=ESP-NOW Bridge app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support. diff --git a/Apps/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index 88d5c9a..17ed6fd 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=tactility.mediakeys -app.version.name=0.10.0 -app.version.code=10 +app.version.name=0.11.0 +app.version.code=11 app.name=Media Keys app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.