Fixes and new apps (#30)

- M5 Unit Modules library + M5 Unit Test app
- Minor fixes for TodoList, TwoEleven, Snake, Brainfuck and Breakout
- Fixed SerialConsole to use the uart controller as it was broken in one of the many updates
- Fixed keyboard input in TwoEleven, Breakout, Magic8Ball and Snake
- Bluetooth Media Keys app, supports pressing physical keys to trigger the corresponding buttonmatrix button
- Epub Reader app
This commit is contained in:
Shadowtrance
2026-06-07 23:56:32 +10:00
committed by GitHub
parent 1f959c8bbf
commit dbf850c434
127 changed files with 11156 additions and 174 deletions
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(MediaKeys)
tactility_project(MediaKeys)
+11
View File
@@ -0,0 +1,11 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK
)
+347
View File
@@ -0,0 +1,347 @@
#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::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled;
if (enabled) {
_btDevice = bluetooth_find_first_ready_device();
if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
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();
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
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);
}
void MediaKeys::onHide(AppHandle /*appHandle*/) {
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
_radioEnabling = false;
_radioWasOff = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr;
}
_activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
_parent = nullptr;
_mainWrapper = nullptr;
_switchWidget = nullptr;
_buttonMatrix = nullptr;
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <TactilityCpp/App.h>
#include <lvgl.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h>
#include <tt_lvgl_keyboard.h>
#include <atomic>
class MediaKeys final : public App {
// UI elements
lv_obj_t* _parent = nullptr;
lv_obj_t* _mainWrapper = nullptr;
lv_obj_t* _switchWidget = nullptr;
lv_obj_t* _buttonMatrix = nullptr;
lv_timer_t* _keyHighlightTimer = nullptr;
uint32_t _activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
bool _keyboardActive = false; // true when matrix has focus group + editing mode
// HAL device handles
struct Device* _btDevice = nullptr;
struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
// Static event callbacks
static void onSwitchToggled(lv_event_t* e);
static void onButtonPressed(lv_event_t* e);
static void onKeyEvent(lv_event_t* e);
static void onKeyHighlightTimer(lv_timer_t* t);
static void btEventCallback(struct Device* device, void* context, struct BtEvent event);
static void sendKeyTask(void* param);
// Instance methods called by static callbacks
void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON
void enterKeyMode();
void exitKeyMode();
public:
MediaKeys() = default;
MediaKeys(const MediaKeys&) = delete;
MediaKeys& operator=(const MediaKeys&) = delete;
MediaKeys(MediaKeys&&) = delete;
MediaKeys& operator=(MediaKeys&&) = delete;
void onShow(AppHandle appHandle, lv_obj_t* parent) override;
void onHide(AppHandle appHandle) override;
};
+11
View File
@@ -0,0 +1,11 @@
#include "MediaKeys.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<MediaKeys>();
return 0;
}
}
+11
View File
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.mediakeys
versionName=0.1.0
versionCode=1
name=Media Keys
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.