Files
tactility_apps/Apps/MediaKeys/main/Source/MediaKeys.cpp
T
2026-07-19 21:52:39 +02:00

380 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "MediaKeys.h"
#include <tactility/log.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <tactility/lvgl_fonts.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
static const char* TAG = "MediaKeys";
// Button layout: two rows of three media-control buttons
static const char* BTN_MAP[] = {
LV_SYMBOL_PREV, LV_SYMBOL_PLAY, LV_SYMBOL_NEXT, "\n",
LV_SYMBOL_MUTE, LV_SYMBOL_VOLUME_MID, LV_SYMBOL_VOLUME_MAX, ""
};
// Physical key → button matrix index mapping (B=prev, P=play, N=next, M=mute, D=vol-, U=vol+)
static const struct { uint32_t key; uint32_t btnIdx; } KEY_MAP[] = {
{ 'b', 0 }, { 'B', 0 },
{ 'p', 1 }, { 'P', 1 },
{ 'n', 2 }, { 'N', 2 },
{ 'm', 3 }, { 'M', 3 },
{ 'd', 4 }, { 'D', 4 },
{ 'u', 5 }, { 'U', 5 },
};
// HID Consumer Page (0x0C) usage codes for each button, in BTN_MAP order
static const uint16_t CONSUMER_USAGE[6] = {
0x00B6, // 0 PREV: Previous Track
0x00CD, // 1 PLAY: Play/Pause
0x00B5, // 2 NEXT: Next Track
0x00E2, // 3 MUTE: Mute
0x00EA, // 4 VOL_DOWN: Volume Decrement
0x00E9, // 5 VOL_UP: Volume Increment
};
// Data passed to the one-shot key-send task
struct SendKeyData {
struct Device* hidDevice;
uint16_t usage;
};
// ---- Static task / callback implementations ----
void MediaKeys::sendKeyTask(void* param) {
SendKeyData* data = static_cast<SendKeyData*>(param);
uint8_t press[2] = {
static_cast<uint8_t>(data->usage & 0xFF),
static_cast<uint8_t>((data->usage >> 8) & 0xFF)
};
bluetooth_hid_device_send_consumer(data->hidDevice, press, 2);
vTaskDelay(pdMS_TO_TICKS(50));
uint8_t release[2] = {0, 0};
bluetooth_hid_device_send_consumer(data->hidDevice, release, 2);
delete data;
vTaskDelete(nullptr);
}
void MediaKeys::onSwitchToggled(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self) return;
bool enabled = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
self->handleSwitchToggle(enabled);
}
void MediaKeys::onButtonPressed(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self) return;
uint32_t id = lv_btnmatrix_get_selected_btn(lv_event_get_target_obj(event));
if (id != LV_BTNMATRIX_BTN_NONE) {
self->handleButtonPress(id);
}
}
void MediaKeys::onKeyEvent(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_KEY) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self || !self->_buttonMatrix) return;
uint32_t key = lv_event_get_key(event);
// Q or Esc exits key mode and returns focus to normal UI navigation
if (key == 'q' || key == 'Q' || key == LV_KEY_ESC) {
self->exitKeyMode();
return;
}
if (!self->_isEnabled) return;
for (auto& mapping : KEY_MAP) {
if (mapping.key == key) {
// Highlight: select the button and mark checked for visual feedback
self->_activeKeyBtn = mapping.btnIdx;
lv_buttonmatrix_set_selected_button(self->_buttonMatrix, mapping.btnIdx);
lv_buttonmatrix_set_button_ctrl(self->_buttonMatrix, mapping.btnIdx, LV_BTNMATRIX_CTRL_CHECKED);
// Restart the highlight-clear timer
if (self->_keyHighlightTimer) {
lv_timer_reset(self->_keyHighlightTimer);
lv_timer_resume(self->_keyHighlightTimer);
}
self->handleButtonPress(mapping.btnIdx);
return;
}
}
}
void MediaKeys::enterKeyMode() {
if (_keyboardActive || !_buttonMatrix) return;
lv_group_t* group = lv_group_get_default();
if (!group) return;
lv_group_add_obj(group, _buttonMatrix);
lv_group_focus_obj(_buttonMatrix);
lv_group_set_editing(group, true);
_keyboardActive = true;
LOG_I(TAG, "Key mode: ON (Q/Esc to exit)");
}
void MediaKeys::exitKeyMode() {
if (!_keyboardActive || !_buttonMatrix) return;
lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(_buttonMatrix);
_keyboardActive = false;
LOG_I(TAG, "Key mode: OFF");
}
void MediaKeys::onKeyHighlightTimer(lv_timer_t* t) {
MediaKeys* self = static_cast<MediaKeys*>(lv_timer_get_user_data(t));
if (!self || !self->_buttonMatrix) return;
if (self->_activeKeyBtn != LV_BTNMATRIX_BTN_NONE) {
lv_buttonmatrix_clear_button_ctrl(self->_buttonMatrix, self->_activeKeyBtn, LV_BTNMATRIX_CTRL_CHECKED);
self->_activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
}
lv_obj_remove_state(self->_buttonMatrix, LV_STATE_FOCUSED);
lv_timer_pause(t);
}
void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct BtEvent event) {
MediaKeys* self = static_cast<MediaKeys*>(context);
if (!self) return;
if (event.type == BT_EVENT_RADIO_STATE_CHANGED) {
LOG_I(TAG, "BT radio state: %d", (int)event.radio_state);
if (event.radio_state == BT_RADIO_STATE_ON) {
// Radio is now up - start HID (needs LVGL lock for UI update)
if (tt_lvgl_lock(1000)) {
// Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false)
if (self->_radioEnabling) {
self->startHid();
}
tt_lvgl_unlock();
}
} else if (event.radio_state == BT_RADIO_STATE_OFF && self->_isEnabled) {
// Radio dropped while we were active - revert UI
LOG_I(TAG, "BT radio turned off, disabling HID");
if (tt_lvgl_lock(1000)) {
if (tt_lvgl_hardware_keyboard_is_available()) self->exitKeyMode();
self->_hidDevice = nullptr;
self->_isEnabled = false;
self->_radioEnabling = false;
if (self->_switchWidget) lv_obj_remove_state(self->_switchWidget, LV_STATE_CHECKED);
if (self->_mainWrapper) lv_obj_add_flag(self->_mainWrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
}
} else if (event.type == BT_EVENT_PROFILE_STATE_CHANGED && event.profile_state.profile == BT_PROFILE_HID_DEVICE) {
LOG_I(TAG, "HID device: %s", event.profile_state.state == BT_PROFILE_STATE_CONNECTED ? "connected" : "disconnected");
}
}
// ---- Instance methods ----
void MediaKeys::startHid() {
// 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.
_radioEnabling = false;
_hidDevice = bluetooth_hid_device_get_device();
if (!_hidDevice) {
LOG_E(TAG, "BLE HID device unavailable after radio on");
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
error_t err = bluetooth_hid_device_start(_hidDevice, BT_HID_DEVICE_MODE_KEYBOARD);
if (err != ERROR_NONE) {
LOG_E(TAG, "Failed to start HID device: %d", (int)err);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
}
void MediaKeys::teardownBt() {
// Remove callback FIRST - stops any in-flight BT events from firing against
// our (possibly already freed) UI widget pointers after this returns.
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
// 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 (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
_btDevice = nullptr;
_hidDevice = nullptr;
_radioWasOff = false;
_deviceWasStarted = false;
}
void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled;
if (enabled) {
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
// Device may not be started yet (BT disabled in DTS by default to save memory).
if (!device_is_ready(_btDevice)) {
LOG_I(TAG, "BT device not started, starting now");
if (device_start(_btDevice) != ERROR_NONE) {
LOG_E(TAG, "Failed to start BT device");
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
_deviceWasStarted = true;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event.
bluetooth_add_event_callback(_btDevice, this, btEventCallback);
enum BtRadioState radioState;
bluetooth_get_radio_state(_btDevice, &radioState);
if (radioState == BT_RADIO_STATE_ON) {
// Radio already up - start HID immediately.
_radioWasOff = false;
startHid();
} else {
// Turn the radio on; startHid() will be called from btEventCallback
// once BT_RADIO_STATE_ON fires.
LOG_I(TAG, "BT radio not on (state=%d), enabling...", (int)radioState);
_radioWasOff = true;
_radioEnabling = true;
bluetooth_set_radio_enabled(_btDevice, true);
}
} else {
_radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
// 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 (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
teardownBt();
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
}
}
void MediaKeys::handleButtonPress(uint32_t buttonId) {
if (!_hidDevice || !_isEnabled || buttonId >= 6) return;
if (!bluetooth_hid_device_is_connected(_hidDevice)) {
LOG_W(TAG, "Not connected, ignoring button %lu", buttonId);
return;
}
LOG_I(TAG, "Button %lu pressed", buttonId);
SendKeyData* data = new SendKeyData {_hidDevice, CONSUMER_USAGE[buttonId]};
if (xTaskCreate(sendKeyTask, "bt_key", 4096, data, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
LOG_E(TAG, "Failed to create send task");
delete data;
}
}
void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
_parent = parent;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
_switchWidget = tt_lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this);
_mainWrapper = lv_obj_create(parent);
lv_obj_set_style_bg_color(_mainWrapper, lv_palette_darken(LV_PALETTE_GREY, 4), LV_PART_MAIN);
lv_obj_set_width(_mainWrapper, LV_PCT(100));
lv_obj_set_flex_grow(_mainWrapper, 1);
lv_obj_set_style_pad_all(_mainWrapper, 4, LV_PART_MAIN);
lv_obj_set_style_border_width(_mainWrapper, 0, LV_PART_MAIN);
lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(_mainWrapper, LV_FLEX_FLOW_COLUMN);
_buttonMatrix = lv_btnmatrix_create(_mainWrapper);
lv_btnmatrix_set_map(_buttonMatrix, BTN_MAP);
lv_obj_set_style_text_font(_buttonMatrix, lvgl_get_text_font(FONT_SIZE_LARGE), LV_PART_ITEMS);
lv_obj_set_style_pad_all(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_pad_row(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_pad_column(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_border_width(_buttonMatrix, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(_buttonMatrix, 0, LV_PART_MAIN);
if (lv_display_get_horizontal_resolution(nullptr) <= 240 ||
lv_display_get_vertical_resolution(nullptr) <= 240) {
lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(70));
} else {
lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(85));
}
lv_obj_set_flex_grow(_buttonMatrix, 1);
lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this);
// Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits)
if (tt_lvgl_hardware_keyboard_is_available()) {
lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this);
_keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this);
lv_timer_pause(_keyHighlightTimer);
}
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (btDev && 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(_switchWidget, LV_STATE_CHECKED);
handleSwitchToggle(true);
}
}
}
void MediaKeys::onHide(AppHandle /*appHandle*/) {
_radioEnabling = false;
_isEnabled = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr;
}
_activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
_parent = nullptr;
_mainWrapper = nullptr;
_switchWidget = nullptr;
_buttonMatrix = nullptr;
}