Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/actions/build-app/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@ inputs:
app_name:
description: The name of the app directory
required: true
save_cache:
description: Save the ESP-IDF/SDK compiler cache after building (one job per run should do this)
required: false
default: 'false'
upload_artifact:
description: Upload the built app as a workflow artifact (artifact names must be unique per run)
required: false
default: 'true'

runs:
using: "composite"
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# After checkout, which cleans the workspace. The SDK compiles its ESP-IDF/SDK parts through
# ccache, in .ccache/<platform>. The newest cache of any earlier run is restored.
- name: 'Restore compiler cache'
uses: actions/cache/restore@v4
with:
path: .ccache
key: ccache-idf-v6.1-${{ github.run_id }}
restore-keys: ccache-idf-v6.1-
- name: 'Build'
uses: espressif/esp-idf-ci-action@v1
with:
Expand All @@ -19,8 +35,16 @@ runs:
# The export reset prevents an error when building other targets
# The default environment variable is set to "esp32" by the GitHub Action
# tt runs in the app's folder, so it uses the SDK version of this app's manifest
command: 'export IDF_TARGET= && cd Apps/${{ inputs.app_name }} && python ../../tt.py app build'
# TACTILITY_CCACHE_DIR points the SDK's compiler cache into the workspace, so it can be cached
command: 'export IDF_TARGET= && export TACTILITY_CCACHE_DIR="$(pwd)/.ccache" && cd Apps/${{ inputs.app_name }} && python ../../tt.py app build && (for d in "$TACTILITY_CCACHE_DIR"/*/; do [ -d "$d" ] && echo "ccache $d" && ccache -d "$d" -s; done; true)'
- name: 'Save compiler cache'
if: inputs.save_cache == 'true'
uses: actions/cache/save@v4
with:
path: .ccache
key: ccache-idf-v6.1-${{ github.run_id }}
- name: 'Upload Artifact'
if: inputs.upload_artifact == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.app_name }}
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,23 @@ jobs:
persist-credentials: false
- id: set-matrix
run: python3 Scripts/gh-generate-app-matrix.py
# Fills the compiler cache that the Build jobs restore: HelloWorld targets every ESP32 platform
WarmBuildCache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build"
uses: ./.github/actions/build-app
with:
app_name: HelloWorld
save_cache: 'true'
Comment thread
KenVanHoeylandt marked this conversation as resolved.
# The Build matrix also builds HelloWorld and uploads its artifact
upload_artifact: 'false'
Build:
runs-on: ubuntu-latest
needs: GenerateAppMatrix
needs: [GenerateAppMatrix, WarmBuildCache]
strategy:
matrix: ${{ fromJSON(needs.GenerateAppMatrix.outputs.matrix) }}
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.DS_Store

build/
.ccache/
cmake-build-*/
CMakeCache.txt
CMakeFiles
Expand Down
187 changes: 142 additions & 45 deletions Apps/MediaKeys/main/Source/MediaKeys.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ const uint16_t CONSUMER_USAGE[6] = {
0x00E9, // 5 – VOL_UP: Volume Increment
};

constexpr uint32_t WORKER_STACK_SIZE = 6144;
constexpr UBaseType_t WORKER_QUEUE_LENGTH = 8;

enum class WorkerCommandType : uint8_t {
EnableRadio,
StartHid,
StartHidAfterRadioOn,
Disable,
Shutdown,
};

struct WorkerCommand {
WorkerCommandType type;
};

// Data passed to the one-shot key-send task
struct SendKeyData {
struct Device* hidDevice;
Expand All @@ -47,7 +62,8 @@ struct SendKeyData {

void enterKeyMode(Context* ctx);
void exitKeyMode(Context* ctx);
void startHid(Context* ctx);
void startHid(Context* ctx, bool renameAfterStart);
void postWorkerCommand(Context* ctx, WorkerCommandType type);
void handleButtonPress(Context* ctx, uint32_t buttonId);

// ---- Static task / callback implementations ----
Expand Down Expand Up @@ -171,16 +187,9 @@ void handleBtEvent(Context* ctx, const BtEvent& event) {
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 (lvgl_try_lock(1000)) {
// Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false).
// Also skip touching widgets if this window has been buried in the meantime
// (window_manager may have already deleted them) - same reasoning as
// onKeyHighlightTimer above.
if (ctx->radioEnabling && window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) {
startHid(ctx);
}
lvgl_unlock();
// Radio is now up. The worker starts HID only if the user didn't switch off in the meantime.
if (ctx->radioEnabling) {
postWorkerCommand(ctx, WorkerCommandType::StartHidAfterRadioOn);
}
} else if (event.radio_state == BT_RADIO_STATE_OFF && ctx->isEnabled) {
// Radio dropped while we were active - revert UI
Expand All @@ -205,31 +214,69 @@ void handleBtEvent(Context* ctx, const BtEvent& event) {

// ---- Instance-equivalent functions ----

void startHid(Context* ctx) {
// Called once the BT radio is confirmed ON (either already was, or just came up).
// May be called from handleBtEvent() on the app's own task - LVGL must already be locked by caller.
void postWorkerCommand(Context* ctx, WorkerCommandType type) {
if (ctx->workerQueue == nullptr) return;
WorkerCommand command { type };
if (xQueueSend(ctx->workerQueue, &command, 0) != pdTRUE) {
LOG_E(TAG, "Worker queue full");
}
}

// Takes the LVGL lock from the worker. @return false when the lock couldn't be taken
bool lockLvgl() {
for (int i = 0; i < 5; i++) {
if (lvgl_try_lock(1000)) return true;
}
LOG_W(TAG, "Failed to acquire LVGL lock");
return false;
}

// Runs on the worker. Reverts the switch when HID can't start.
void showHidFailed(Context* ctx) {
ctx->isEnabled = false;
if (!lockLvgl()) return;
if (window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED && ctx->switchWidget) {
lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED);
}
lvgl_unlock();
}

// Runs on the worker, once the BT radio is confirmed ON (either already was, or just came up).
void startHid(Context* ctx, bool renameAfterStart) {
ctx->radioEnabling = false;

ctx->hidDevice = bluetooth_hid_device_get();
if (!ctx->hidDevice) {
Device* hidDevice = bluetooth_hid_device_get();
if (!hidDevice) {
LOG_E(TAG, "BLE HID device unavailable after radio on");
ctx->isEnabled = false;
if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED);
showHidFailed(ctx);
return;
}

error_t err = bluetooth_hid_device_start(ctx->hidDevice, BT_HID_DEVICE_MODE_KEYBOARD);
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);
device_put(ctx->hidDevice);
ctx->hidDevice = nullptr;
ctx->isEnabled = false;
if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED);
device_put(hidDevice);
showHidFailed(ctx);
return;
}

if (ctx->mainWrapper) lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN);
if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx);
ctx->hidDevice = hidDevice;

// Rename only after HID is up: with the radio on, a rename restarts advertising
// right away, and the GATT profile switch in bluetooth_hid_device_start() asserts in
// NimBLE (ble_svc_gap_init) if it runs while that advertising is still active.
// Once HID is active, the rename just restarts HID advertising with the new name.
if (renameAfterStart) bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME);

// Skip touching widgets if the window was buried in the meantime (window_manager may have
// already deleted them), same reasoning as onKeyHighlightTimer above.
// Also skip if the switch was turned off while HID was starting: handleSwitchToggle already
// hid the controls and queued Disable, which stops HID.
if (!lockLvgl()) return;
if (ctx->isEnabled && window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) {
if (ctx->mainWrapper) lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN);
if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx);
}
lvgl_unlock();
Comment thread
KenVanHoeylandt marked this conversation as resolved.
}

// Restores the radio to the state we found it in when we turned it on ourselves. Does not touch
Expand All @@ -240,6 +287,43 @@ void restoreRadioIfNeeded(Context* ctx) {
ctx->radioWasOff = false;
}

void workerTask(void* param) {
auto* ctx = static_cast<Context*>(param);
WorkerCommand command;
bool running = true;
while (running) {
xQueueReceive(ctx->workerQueue, &command, portMAX_DELAY);
switch (command.type) {
case WorkerCommandType::EnableRadio:
bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME);
bluetooth_set_radio_enabled(ctx->btDevice, true);
break;
case WorkerCommandType::StartHid:
// Re-check here to avoid a race with a switch-off that happened after the command was queued
if (ctx->isEnabled) startHid(ctx, true);
break;
case WorkerCommandType::StartHidAfterRadioOn:
// Re-check here to avoid a race with a switch-off that happened after the event was queued
if (ctx->isEnabled && ctx->radioEnabling) startHid(ctx, false);
break;
case WorkerCommandType::Disable:
if (ctx->hidDevice) {
bluetooth_hid_device_stop(ctx->hidDevice);
device_put(ctx->hidDevice);
ctx->hidDevice = nullptr;
}
restoreRadioIfNeeded(ctx);
break;
case WorkerCommandType::Shutdown:
restoreRadioIfNeeded(ctx);
running = false;
break;
}
}
xSemaphoreGive(ctx->workerDone);
vTaskDelete(nullptr);
}

void teardownBt(Context* ctx) {
// Unsubscribe FIRST - stops any in-flight BT events from firing against our (possibly
// already freed) UI widget pointers after this returns.
Expand All @@ -250,7 +334,17 @@ void teardownBt(Context* ctx) {
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
// 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->workerQueue != nullptr) {
// Blocking send: dropping Shutdown on a full queue would hang the workerDone wait below.
// The worker keeps draining the queue, so this completes.
WorkerCommand shutdown { WorkerCommandType::Shutdown };
xQueueSend(ctx->workerQueue, &shutdown, portMAX_DELAY);
xSemaphoreTake(ctx->workerDone, portMAX_DELAY);
vSemaphoreDelete(ctx->workerDone);
vQueueDelete(ctx->workerQueue);
ctx->workerDone = nullptr;
ctx->workerQueue = nullptr;
}
if (ctx->btDevice) device_put(ctx->btDevice);
if (ctx->hidDevice) device_put(ctx->hidDevice);
ctx->btDevice = nullptr;
Expand All @@ -272,38 +366,27 @@ void handleSwitchToggle(Context* ctx, bool enabled) {
enum BtRadioState radioState;
bluetooth_get_radio_state(ctx->btDevice, &radioState);

// The Bluetooth calls run on the worker: this can be the LVGL task, which must not touch flash
if (radioState == BT_RADIO_STATE_ON) {
// Radio already up - start HID immediately.
ctx->radioWasOff = false;
startHid(ctx);
// Rename only after HID is up: with the radio on, a rename restarts advertising
// right away, and the GATT profile switch in bluetooth_hid_device_start() asserts in
// NimBLE (ble_svc_gap_init) if it runs while that advertising is still active.
// Once HID is active, the rename just restarts HID advertising with the new name.
if (ctx->hidDevice) bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME);
postWorkerCommand(ctx, WorkerCommandType::StartHid);
} else {
// Radio off: renaming stores the name without touching advertising
bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME);
// Turn the radio on; startHid() will be called from handleBtEvent()
// Turn the radio on; the worker starts HID 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;
bluetooth_set_radio_enabled(ctx->btDevice, true);
postWorkerCommand(ctx, WorkerCommandType::EnableRadio);
}
} else {
ctx->radioEnabling = false;
if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(ctx);
// 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);
device_put(ctx->hidDevice);
}
ctx->hidDevice = nullptr;
restoreRadioIfNeeded(ctx);
// Explicit user toggle-off: the worker stops HID cleanly and restores the radio
// (safe here since the user intentionally disabled, so no race with app teardown).
postWorkerCommand(ctx, WorkerCommandType::Disable);
if (ctx->mainWrapper) lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN);
}
}
Expand Down Expand Up @@ -352,6 +435,20 @@ bool mediaKeysInitBt(Context* ctx) {
ctx->btDevice = nullptr;
return false;
}
ctx->workerQueue = xQueueCreate(WORKER_QUEUE_LENGTH, sizeof(WorkerCommand));
ctx->workerDone = xSemaphoreCreateBinary();
if (ctx->workerQueue == nullptr || ctx->workerDone == nullptr ||
xTaskCreate(workerTask, "mediakeys_bt", WORKER_STACK_SIZE, ctx, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
LOG_E(TAG, "Failed to start Bluetooth worker");
if (ctx->workerQueue) vQueueDelete(ctx->workerQueue);
if (ctx->workerDone) vSemaphoreDelete(ctx->workerDone);
ctx->workerQueue = nullptr;
ctx->workerDone = nullptr;
bluetooth_event_unsubscribe(ctx->btDevice, &ctx->btEventSub);
device_put(ctx->btDevice);
ctx->btDevice = nullptr;
return false;
}
return true;
}

Expand Down
7 changes: 7 additions & 0 deletions Apps/MediaKeys/main/Source/MediaKeys.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tactility/drivers/keyboard.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <atomic>

struct Context {
Expand All @@ -27,6 +30,10 @@ struct Context {
struct Device* hidDevice = nullptr;
struct BtEventSubscription btEventSub {};

// Runs the Bluetooth calls. They can touch flash, which the LVGL task (stack in external RAM) must not do.
QueueHandle_t workerQueue = nullptr;
SemaphoreHandle_t workerDone = nullptr;

// State - accessed from both LVGL thread and the app's own task (BT event poll loop)
std::atomic<bool> isEnabled {false};
std::atomic<bool> radioEnabling {false}; // true while waiting for radio to come ON
Expand Down
4 changes: 2 additions & 2 deletions Apps/MediaKeys/manifest.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ manifest.version=0.3
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
id=tactility.mediakeys
version.name=0.18.0
version.code=18
version.name=0.19.0
version.code=19
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.
app.0.id=tactility.mediakeys
Expand Down
13 changes: 13 additions & 0 deletions Apps/WebServer/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# tactility-cmakelists-version: 1
cmake_minimum_required(VERSION 3.20)

if (NOT DEFINED ENV{TACTILITY_SDK_PATH})
message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set")
endif()

get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE)
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")

tactility_project_pre(webserver)
project(webserver)
tactility_project_post(webserver)
Loading
Loading