diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 559827f..5487e92 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: Build: strategy: matrix: - app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] + app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Apps/Breakout/main/Source/Breakout.cpp b/Apps/Breakout/main/Source/Breakout.cpp index 7e12b83..4c9553a 100644 --- a/Apps/Breakout/main/Source/Breakout.cpp +++ b/Apps/Breakout/main/Source/Breakout.cpp @@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { if (!sfxEngine) { sfxEngine = new SfxEngine(); sfxEngine->start(); - sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet); sfxEngine->setEnabled(soundEnabled); } diff --git a/Apps/EspNowBridge/CMakeLists.txt b/Apps/EspNowBridge/CMakeLists.txt new file mode 100644 index 0000000..119c9cd --- /dev/null +++ b/Apps/EspNowBridge/CMakeLists.txt @@ -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(EspNowBridge) +tactility_project(EspNowBridge) diff --git a/Apps/EspNowBridge/assets/espnow_bridge_slave_c6.bin b/Apps/EspNowBridge/assets/espnow_bridge_slave_c6.bin new file mode 100644 index 0000000..ce6e3f8 Binary files /dev/null and b/Apps/EspNowBridge/assets/espnow_bridge_slave_c6.bin differ diff --git a/Apps/EspNowBridge/main/CMakeLists.txt b/Apps/EspNowBridge/main/CMakeLists.txt new file mode 100644 index 0000000..121a360 --- /dev/null +++ b/Apps/EspNowBridge/main/CMakeLists.txt @@ -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 bootloader_support esp_app_format +) diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp new file mode 100644 index 0000000..01b495e --- /dev/null +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp @@ -0,0 +1,739 @@ +#include "EspNowBridge.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include + +static constexpr auto* TAG = "EspNowBridge"; +static constexpr size_t CHUNK_SIZE = 1500; +static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000; +static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192; + +AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); } +AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); } + +// Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of +// 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by +// an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by +// ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader, +// just enough to locate the app partition inside a merged/factory bin. +static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000; +static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32) +static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50 +static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB; +static constexpr uint8_t PARTITION_TYPE_APP = 0x00; +static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00; +static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10; + +struct __attribute__((packed)) PartitionEntry { + uint16_t magic; + uint8_t type; + uint8_t subtype; + uint32_t offset; + uint32_t size; + char name[16]; + uint32_t flags; +}; +static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes"); + +/** + * Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for + * the app partition to flash: prefers "factory" if present, otherwise the first OTA slot + * (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains. + * @return true if an app partition was found, with appOffset/appSize set to its location + * within the file (these are the same as the absolute flash offsets the merged bin preserves). + */ +static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) { + if (fseek(file, static_cast(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) { + return false; + } + + bool foundFactory = false; + bool foundOta0 = false; + size_t factoryOffset = 0, factorySize = 0; + size_t ota0Offset = 0, ota0Size = 0; + + for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) { + PartitionEntry entry; + if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) { + break; + } + if (entry.magic == PARTITION_MD5_MAGIC) { + break; + } + if (entry.magic != PARTITION_ENTRY_MAGIC) { + break; + } + if (entry.type == PARTITION_TYPE_APP) { + if (entry.subtype == PARTITION_SUBTYPE_FACTORY) { + foundFactory = true; + factoryOffset = entry.offset; + factorySize = entry.size; + } else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) { + foundOta0 = true; + ota0Offset = entry.offset; + ota0Size = entry.size; + } + } + } + + if (foundFactory) { + appOffset = factoryOffset; + appSize = factorySize; + return true; + } + if (foundOta0) { + appOffset = ota0Offset; + appSize = ota0Size; + return true; + } + return false; +} + +/** + * Validates the app image at the given file offset and extracts its version string. The actual + * transfer size used for the OTA loop is just the real remaining file size from appOffset (see + * performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/ + * hash padding drifts a bit short of the real length, so we just use the file size instead. + */ +static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) { + esp_image_header_t imageHeader; + if (fseek(file, static_cast(appOffset), SEEK_SET) != 0 || + fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) { + if (errorOut != nullptr) { + *errorOut = "Failed to read image header"; + } + return false; + } + + if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) { + if (errorOut != nullptr) { + *errorOut = "Selected file is not a valid firmware image (bad magic)"; + } + return false; + } + + // Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before + // streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end() + // would eventually catch this too, but only after the entire transfer already completed. + if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) { + if (errorOut != nullptr) { + char buf[96]; + snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6", + (unsigned)imageHeader.chip_id); + *errorOut = buf; + } + return false; + } + + esp_image_segment_header_t segmentHeader; + size_t firstSegmentOffset = appOffset + sizeof(imageHeader); + if (fseek(file, static_cast(firstSegmentOffset), SEEK_SET) != 0 || + fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) { + if (errorOut != nullptr) { + *errorOut = "Failed to read first segment header"; + } + return false; + } + + esp_app_desc_t appDesc; + size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader); + if (fseek(file, static_cast(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) { + strncpy(versionOut, appDesc.version, versionOutLen - 1); + versionOut[versionOutLen - 1] = '\0'; + } else { + strncpy(versionOut, "unknown", versionOutLen - 1); + versionOut[versionOutLen - 1] = '\0'; + } + + return true; +} + +static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) { + FirmwareInfo info = {}; + if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) { + return false; + } + + if (info.name[0] != '\0') { + snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)", + (unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name); + } else { + snprintf(versionOut, versionOutLen, "%u.%u.%u", + (unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch); + } + return true; +} + +/** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves + * reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */ +static bool activateSupported(uint32_t major, uint32_t minor) { + return (major > 2) || (major == 2 && minor > 5); +} + +std::atomic EspNowBridge::liveInstance_{nullptr}; + +void EspNowBridge::onCreate(AppHandle app) { + appHandle_ = app; + taskDoneSemaphore_ = xSemaphoreCreateBinary(); + liveInstance_ = this; +} + +void EspNowBridge::onDestroy(AppHandle /*app*/) { + // Clear liveInstance_ first so any task still running bails out at its next liveInstance_ + // check instead of continuing to touch this instance's members. + liveInstance_ = nullptr; + + // Wait for any outstanding background task (OTA update, transport-wait) to actually finish - + // the app framework frees this instance shortly after onDestroy() returns, so a task that + // outlives it would dereference freed memory. + while (outstandingTasks_.load() > 0) { + if (taskDoneSemaphore_ != nullptr) { + xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000)); + } + } + + if (taskDoneSemaphore_ != nullptr) { + vSemaphoreDelete(taskDoneSemaphore_); + taskDoneSemaphore_ = nullptr; + } +} + +void EspNowBridge::refreshCurrentVersion() { + char versionStr[32]; + if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) { + lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr); + } else { + lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)"); + } +} + +bool EspNowBridge::isWifiRadioOn() { + if (wifiDevice_ == nullptr) { + return false; + } + WifiRadioState radioState = WIFI_RADIO_STATE_OFF; + if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) { + return false; + } + // ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge + // just needs the radio + esp_hosted transport up, not a completed AP connection. + return radioState == WIFI_RADIO_STATE_ON; +} + +void EspNowBridge::refreshWifiPrompt() { + if (isWifiRadioOn()) { + lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); + setUpdateButtonsDisabled(false); + } else { + lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); + setUpdateButtonsDisabled(true); + } +} + +void EspNowBridge::setUpdateButtonsDisabled(bool disabled) { + if (disabled) { + lv_obj_add_state(updateButton_, LV_STATE_DISABLED); + lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED); + } else { + lv_obj_clear_state(updateButton_, LV_STATE_DISABLED); + lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED); + } +} + +void EspNowBridge::setStatus(const std::string& text) { + lv_label_set_text(statusLabel_, text.c_str()); +} + +void EspNowBridge::setProgress(int percent) { + lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF); +} + +namespace { +struct UiDispatchPayload { + EspNowBridge* instance; + void (*work)(EspNowBridge&, void*); + void* context; + void (*freeContext)(void*); +}; +} + +void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) { + auto* payload = new UiDispatchPayload{this, work, context, freeContext}; + // lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a + // non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi() + // repeatedly during the transfer, and without this lock most of those calls were silently + // racing LVGL's own task and getting lost (only the very last status update, right before + // esp_restart(), happened to land - everything else stayed stuck at "Waiting for + // co-processor link..."). + bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME); + if (!locked) { + // Without the lock, lv_async_call() itself would be touching LVGL's internal timer list + // unguarded - and if it happened to still enqueue successfully, the callback below would + // later fire against `payload` after we've already freed it here. Drop the update instead. + if (freeContext != nullptr) { + freeContext(context); + } + delete payload; + return; + } + + lv_result_t result = lv_async_call([](void* userData) { + auto* payload = static_cast(userData); + if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) { + payload->work(*payload->instance, payload->context); + } + if (payload->freeContext != nullptr) { + payload->freeContext(payload->context); + } + delete payload; + }, payload); + tt_lvgl_unlock(); + + if (result != LV_RESULT_OK) { + if (freeContext != nullptr) { + freeContext(context); + } + delete payload; + } +} + +namespace { + +void workSetStatus(EspNowBridge& app, void* context) { + app.setStatus(*static_cast(context)); +} +void freeString(void* context) { delete static_cast(context); } + +void workSetProgress(EspNowBridge& app, void* context) { + app.setProgress(*static_cast(context)); +} +void freeInt(void* context) { delete static_cast(context); } + +} // namespace + +void EspNowBridge::performUpdate(const std::string& filePath) { + dispatchToUi([](EspNowBridge& app, void*) { + app.setUpdateButtonsDisabled(true); + app.setProgress(0); + app.setStatus("Waiting for co-processor link..."); + }, nullptr, nullptr); + + if (firmwareOps_ == nullptr) { + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("This WiFi device has no updatable co-processor"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) { + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Co-processor link not available - update cancelled"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + FILE* file = fopen(filePath.c_str(), "rb"); + if (file == nullptr) { + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to open selected file"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + fseek(file, 0, SEEK_END); + long fileSizeSigned = ftell(file); + if (fileSizeSigned <= 0) { + fclose(file); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to determine file size"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + size_t fileSize = static_cast(fileSizeSigned); + + // Support both a plain app image (starting with the app image header at offset 0) and a + // merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether + // a valid partition table is found at PARTITION_TABLE_OFFSET. + size_t appOffset = 0; + size_t partitionSize = 0; + bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize); + if (isMergedBin && appOffset >= fileSize) { + fclose(file); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + char newVersion[32]; + std::string parseError; + if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) { + fclose(file); + dispatchToUi(workSetStatus, new std::string(parseError), freeString); + dispatchToUi([](EspNowBridge& app, void*) { + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + // Merged bins pad the app partition to its declared size; a plain app image is exactly as + // long as the app itself. Transfer whichever is smaller. + size_t remainingInFile = fileSize - appOffset; + size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile; + + std::string versionStr(newVersion); + { + char buf[64]; + snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str()); + dispatchToUi(workSetStatus, new std::string(buf), freeString); + } + + // Held on the app instance (not a local variable) so it outlives this function - see + // heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts + // (moot, since esp_restart() doesn't return) or if the update fails early below. + heldAutoScanPauseGuard_.emplace(); + + FirmwareUpdateRequest updateRequest = {}; + updateRequest.image_size = firmwareSize; + FirmwareUpdateHandle* handle = nullptr; + if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) { + fclose(file); + heldAutoScanPauseGuard_.reset(); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to start OTA on co-processor"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + if (fseek(file, static_cast(appOffset), SEEK_SET) != 0) { + fclose(file); + firmwareOps_->abort(handle); + heldAutoScanPauseGuard_.reset(); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to seek to firmware start"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + uint8_t chunk[CHUNK_SIZE]; + size_t sent = 0; + bool writeFailed = false; + int lastReportedPercent = -1; + + while (sent < firmwareSize) { + size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent); + size_t actuallyRead = fread(chunk, 1, toRead, file); + if (actuallyRead != toRead) { + LOG_E(TAG, "Failed to read file at offset %zu", sent); + writeFailed = true; + break; + } + + if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) { + LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent); + writeFailed = true; + break; + } + + // Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no + // backoff before giving up and restarting the host. Back-to-back chunk writes with zero + // gap were observed to saturate the bus enough to trigger a genuine SDIO timeout + // mid-transfer, not just around the post-activate reboot. + vTaskDelay(pdMS_TO_TICKS(5)); + + sent += actuallyRead; + + // Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent + // display-bus activity during the transfer was implicated in SDIO transport crashes + // under sustained OTA write load. + int percent = (int)((sent * 100) / firmwareSize); + if (percent != lastReportedPercent) { + dispatchToUi(workSetProgress, new int(percent), freeInt); + lastReportedPercent = percent; + } + } + + fclose(file); + + if (writeFailed) { + firmwareOps_->abort(handle); + heldAutoScanPauseGuard_.reset(); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Update failed while transferring firmware"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + if (firmwareOps_->finish(handle) != ERROR_NONE) { + heldAutoScanPauseGuard_.reset(); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to finalize OTA on co-processor"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + + // Check the *currently running* (pre-update) slave version - the new image isn't running + // yet - and skip straight to the required host restart for older slaves. + FirmwareInfo runningInfo = {}; + bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE + && activateSupported(runningInfo.fw_major, runningInfo.fw_minor); + + if (canActivate) { + if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) { + heldAutoScanPauseGuard_.reset(); + dispatchToUi([](EspNowBridge& app, void*) { + app.setStatus("Failed to activate new firmware - co-processor still running old firmware"); + app.setUpdateButtonsDisabled(false); + }, nullptr, nullptr); + return; + } + } + + // heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host + // restarts itself immediately below, and there's no safe window to resume normal WiFi + // activity before that. + { + char buf[80]; + if (canActivate) { + snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str()); + } else { + snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str()); + } + dispatchToUi(workSetStatus, new std::string(buf), freeString); + } + + // Give the status message above a moment to actually be seen before the restart cuts the + // display, then restart. + vTaskDelay(pdMS_TO_TICKS(1500)); + esp_restart(); +} + +void EspNowBridge::updateTaskEntry(void* arg) { + auto* self = static_cast(arg); + self->performUpdate(self->pendingUpdateFilePath_); + self->updateTask_ = nullptr; + if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { + xSemaphoreGive(self->taskDoneSemaphore_); + } + vTaskDelete(nullptr); +} + +void EspNowBridge::startUpdateTask(const std::string& filePath) { + if (updateTask_ != nullptr) { + return; + } + pendingUpdateFilePath_ = filePath; + outstandingTasks_.fetch_add(1); + if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) { + outstandingTasks_.fetch_sub(1); + } +} + +void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) { + auto* self = liveInstance_.load(); + if (self == nullptr || !self->isWifiRadioOn()) { + return; + } + self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file(); +} + +// Name of the slave bridge firmware bundled in this app's assets/ folder +// lets users flash the known-good bridge firmware without needing to source/copy a +// .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays +// available too, for factory-image downgrades or custom builds. +static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin"; + +void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) { + auto* self = liveInstance_.load(); + if (self == nullptr || !self->isWifiRadioOn()) { + return; + } + char assetPath[256] = {}; + size_t assetPathSize = sizeof(assetPath); + tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize); + if (assetPath[0] == '\0') { + LOG_E(TAG, "Failed to resolve bundled firmware asset path"); + return; + } + self->startUpdateTask(assetPath); +} + +void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) { + auto* self = liveInstance_.load(); + if (self == nullptr || self->wifiDevice_ == nullptr) { + return; + } + device_start(self->wifiDevice_); + // start_device() allocates a fresh driver context (Platforms/platform-esp32's + // esp32_wifi.cpp), which wipes any event callback registered before the device was started - + // re-register now that it's actually running. Also refresh once directly rather than relying + // solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the + // co-processor firmware version below isn't available yet. + wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent); + self->refreshWifiPrompt(); + self->refreshCurrentVersion(); + + // The co-processor RPC transport isn't up the instant device_start() returns - it comes up + // asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after + // enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport + // actually comes up (WifiEvent only covers radio/station state, not transport readiness), so + // wait for it explicitly on a background task and refresh once it's ready. + if (self->firmwareOps_ != nullptr) { + self->outstandingTasks_.fetch_add(1); + if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { + self->outstandingTasks_.fetch_sub(1); + } + } +} + +void EspNowBridge::waitForTransportTaskEntry(void* arg) { + auto* self = static_cast(arg); + constexpr uint32_t WAIT_TIMEOUT_MS = 10000; + // liveInstance_ must be checked before touching any member of self - if onDestroy() already + // ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a + // use-after-free even just to read the pointer. + if (liveInstance_.load() == self && self->firmwareOps_ != nullptr + && self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS) + && liveInstance_.load() == self) { + self->dispatchToUi([](EspNowBridge& app, void*) { + app.refreshCurrentVersion(); + }, nullptr, nullptr); + } + if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { + xSemaphoreGive(self->taskDoneSemaphore_); + } + vTaskDelete(nullptr); +} + +void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) { + auto* self = static_cast(callbackContext); + if (liveInstance_.load() != self) { + return; + } + self->dispatchToUi([](EspNowBridge& app, void*) { + app.refreshWifiPrompt(); + app.refreshCurrentVersion(); + }, nullptr, nullptr); +} + +void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) { + isShown_ = true; + + 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, app); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + + currentVersionLabel_ = lv_label_create(wrapper); + lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT); + + enableWifiButton_ = lv_button_create(wrapper); + lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_); + lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)"); + lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT); + + updateBundledButton_ = lv_button_create(wrapper); + lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_); + lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware"); + lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT); + + updateButton_ = lv_button_create(wrapper); + lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* updateButtonLabel = lv_label_create(updateButton_); + lv_label_set_text(updateButtonLabel, "Update from SD card..."); + lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT); + + progressBar_ = lv_bar_create(wrapper); + lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6)); + lv_bar_set_range(progressBar_, 0, 100); + lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF); + + statusLabel_ = lv_label_create(wrapper); + lv_label_set_text(statusLabel_, "Ready"); + + wifiDevice_ = wifi_find_first_registered_device(); + if (wifiDevice_ != nullptr) { + wifi_add_event_callback(wifiDevice_, this, onWifiEvent); + if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) { + firmwareOps_ = nullptr; + firmwareCtx_ = nullptr; + } + } + + refreshCurrentVersion(); + refreshWifiPrompt(); + + // If an SD-card file was picked before this onShow() ran (FileSelection tears down and + // rebuilds this app's whole widget tree), perform the update now that widgets are valid + // again. The bundled-firmware button doesn't go through this path - it calls + // startUpdateTask() directly since there's no separate app launch/result round trip involved. + if (!pendingUpdateFilePath_.empty()) { + std::string path = std::move(pendingUpdateFilePath_); + pendingUpdateFilePath_.clear(); + startUpdateTask(path); + } +} + +void EspNowBridge::onHide(AppHandle /*app*/) { + isShown_ = false; + if (wifiDevice_ != nullptr) { + wifi_remove_event_callback(wifiDevice_, onWifiEvent); + wifiDevice_ = nullptr; + } +} + +void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) { + if (launchId != pickFileLaunchId_) { + return; + } + pickFileLaunchId_ = 0; + + if (result == APP_RESULT_OK && resultData != nullptr) { + char pathBuf[256] = {}; + if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) { + pendingUpdateFilePath_ = pathBuf; + } + } +} diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.h b/Apps/EspNowBridge/main/Source/EspNowBridge.h new file mode 100644 index 0000000..fda4e05 --- /dev/null +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.h @@ -0,0 +1,108 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See + * tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the + * REBOOT comment in EspNowBridge.cpp). */ +class AutoScanPauseGuard { +public: + AutoScanPauseGuard(); + ~AutoScanPauseGuard(); + AutoScanPauseGuard(const AutoScanPauseGuard&) = delete; + AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete; +}; + +class EspNowBridge final : public App { +public: + EspNowBridge() = default; + EspNowBridge(const EspNowBridge&) = delete; + EspNowBridge& operator=(const EspNowBridge&) = delete; + + void onCreate(AppHandle app) override; + void onDestroy(AppHandle app) override; + void onShow(AppHandle app, lv_obj_t* parent) override; + void onHide(AppHandle app) override; + void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; + + // Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run + // outside any member-function's lexical scope, unlike the inline lambdas in performUpdate()) + // can call them. + void setStatus(const std::string& text); + void setProgress(int percent); + +private: + AppHandle appHandle_ = nullptr; + AppLaunchId pickFileLaunchId_ = 0; + std::string pendingUpdateFilePath_; + Device* wifiDevice_ = nullptr; + + // Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no + // updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go + // through this generic interface, not any esp_hosted-specific API directly. + const FirmwareOps* firmwareOps_ = nullptr; + void* firmwareCtx_ = nullptr; + + // Set once in onShow(), false once onHide() tears the widget tree down - checked (via + // dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the + // WiFi-event callback can both outlive a hide/app-switch. + std::atomic isShown_{false}; + + // Only one EspNowBridge instance is ever live at a time (app loader owns a single instance + // per running app), so a single static "is this instance still current" pointer, guarded by + // an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA + // worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before + // touching any member, instead of holding a shared_ptr to keep `this` alive. + static std::atomic liveInstance_; + + TaskHandle_t updateTask_ = nullptr; + + // Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running + // against this instance's members. onDestroy() must wait for this to hit 0 before returning - + // the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp), + // so any task still touching `this` past that point is a use-after-free. + std::atomic outstandingTasks_{0}; + SemaphoreHandle_t taskDoneSemaphore_ = nullptr; + + // Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap + // between performUpdate() returning and the automatic restart - see performUpdate(). + std::optional heldAutoScanPauseGuard_; + + lv_obj_t* currentVersionLabel_ = nullptr; + lv_obj_t* statusLabel_ = nullptr; + lv_obj_t* progressBar_ = nullptr; + lv_obj_t* updateButton_ = nullptr; + lv_obj_t* updateBundledButton_ = nullptr; + lv_obj_t* enableWifiButton_ = nullptr; + + void refreshCurrentVersion(); + bool isWifiRadioOn(); + void refreshWifiPrompt(); + /** Enables/disables both update-trigger buttons together - only one performUpdate() can run + * at a time (see updateTask_), regardless of which button started it. */ + void setUpdateButtonsDisabled(bool disabled); + /** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is + * still this instance (checked at dispatch time and again right before running, on the LVGL + * task) and isShown_ is true (this app's widget tree exists). */ + void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)); + void performUpdate(const std::string& filePath); + void startUpdateTask(const std::string& filePath); + + static void updateTaskEntry(void* arg); + static void onUpdateButtonClicked(lv_event_t* event); + static void onUpdateBundledButtonClicked(lv_event_t* event); + static void onEnableWifiButtonClicked(lv_event_t* event); + static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event); + static void waitForTransportTaskEntry(void* arg); +}; diff --git a/Apps/EspNowBridge/main/Source/main.cpp b/Apps/EspNowBridge/main/Source/main.cpp new file mode 100644 index 0000000..1e415d9 --- /dev/null +++ b/Apps/EspNowBridge/main/Source/main.cpp @@ -0,0 +1,11 @@ +#include "EspNowBridge.h" +#include + +extern "C" { + +int main(int argc, char* argv[]) { + registerApp(); + return 0; +} + +} diff --git a/Apps/EspNowBridge/manifest.properties b/Apps/EspNowBridge/manifest.properties new file mode 100644 index 0000000..17009e9 --- /dev/null +++ b/Apps/EspNowBridge/manifest.properties @@ -0,0 +1,8 @@ +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32p4 +app.id=one.tactility.espnowbridge +app.version.name=0.1.0 +app.version.code=1 +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/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index 4e5d8ab..3ae6b1a 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -211,12 +211,29 @@ void MediaKeys::startHid() { 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 = bluetooth_find_first_ready_device(); + _btDevice = device_find_first_by_type(&BLUETOOTH_TYPE); if (!_btDevice) { LOG_E(TAG, "No Bluetooth device found"); _isEnabled = false; @@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) { 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. @@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) { } 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); - if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); - if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false); - _radioWasOff = false; - _btDevice = nullptr; - _hidDevice = nullptr; + teardownBt(); if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); } } @@ -321,19 +349,23 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) { } 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*/) { - 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; - + _isEnabled = false; if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); + teardownBt(); if (_keyHighlightTimer) { lv_timer_delete(_keyHighlightTimer); _keyHighlightTimer = nullptr; diff --git a/Apps/MediaKeys/main/Source/MediaKeys.h b/Apps/MediaKeys/main/Source/MediaKeys.h index 825a061..eedc5be 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.h +++ b/Apps/MediaKeys/main/Source/MediaKeys.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -23,9 +24,10 @@ class MediaKeys final : public App { struct Device* _hidDevice = nullptr; // State - accessed from both LVGL thread and BT callback thread - std::atomic _isEnabled {false}; - std::atomic _radioEnabling{false}; // true while waiting for radio to come ON - std::atomic _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off) + 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) // Static event callbacks static void onSwitchToggled(lv_event_t* e); @@ -36,6 +38,7 @@ class MediaKeys final : public App { static void sendKeyTask(void* param); // Instance methods called by static callbacks + void teardownBt(); // remove callback + stop HID + restore radio/device state void handleSwitchToggle(bool enabled); void handleButtonPress(uint32_t buttonId); void startHid(); // called once radio is confirmed ON diff --git a/Apps/TamaTac/main/Source/TamaTac.cpp b/Apps/TamaTac/main/Source/TamaTac.cpp index ac22b66..b60a703 100644 --- a/Apps/TamaTac/main/Source/TamaTac.cpp +++ b/Apps/TamaTac/main/Source/TamaTac.cpp @@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) { if (sfxEngine == nullptr) { sfxEngine = new SfxEngine(); sfxEngine->start(); - sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal); // Load settings bool soundEnabled; diff --git a/Libraries/SfxEngine/Include/SfxEngine.h b/Libraries/SfxEngine/Include/SfxEngine.h index 4e96e52..8002f3a 100644 --- a/Libraries/SfxEngine/Include/SfxEngine.h +++ b/Libraries/SfxEngine/Include/SfxEngine.h @@ -26,6 +26,7 @@ #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" @@ -161,12 +162,6 @@ class SfxEngine { // Settings void setEnabled(bool enabled) { enabled_ = enabled; } bool isEnabled() const { return enabled_; } - void setVolume(float vol) { masterVolume_ = (vol < 0) ? 0 : (vol > 1.0f) ? 1.0f : vol; } - float getVolume() const { return masterVolume_; } - - // Volume presets (consistent with SoundEngine naming) - enum class VolumePreset { Quiet, Normal, Loud }; - void applyVolumePreset(VolumePreset preset); // Polyphonic gate (consistent with SoundEngine) void setPolyphonicGateEnabled(bool enabled) { polyphonicGateEnabled_ = enabled; } @@ -277,14 +272,21 @@ class SfxEngine { // State //-------------------------------------------------------------------------- - Device* i2sDevice_ = nullptr; + Device* audioStreamDevice_ = nullptr; + AudioStreamHandle audioStreamHandle_ = nullptr; TaskHandle_t task_ = nullptr; SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits QueueHandle_t msgQueue_ = nullptr; volatile bool running_ = false; volatile bool enabled_ = true; - volatile float masterVolume_ = 0.5f; + + // Cached system output volume (0..1), refreshed periodically from the shared + // audio_stream device so the preset acts as a relative multiplier on top of it + // rather than an absolute level (a "Quiet" preset should sound quiet relative + // to whatever the user has the system volume set to, not in absolute terms). + float systemVolumeMix_ = 1.0f; + int systemVolumePollCounter_ = 0; // Polyphonic gate volatile bool polyphonicGateEnabled_ = true; diff --git a/Libraries/SfxEngine/README.md b/Libraries/SfxEngine/README.md index 7d7ecc3..ab716ca 100644 --- a/Libraries/SfxEngine/README.md +++ b/Libraries/SfxEngine/README.md @@ -35,11 +35,8 @@ if (!engine->start()) { ESP_LOGE(TAG, "Failed to start SfxEngine"); return; } -engine->applyVolumePreset(SfxEngine::VolumePreset::Normal); - engine->play(SfxId::Coin); // Predefined SFX engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms -engine->setVolume(0.7f); // Volume control engine->stop(); delete engine; @@ -87,9 +84,11 @@ idf_component_register( - `void stopVoice(voice)` - Stop specific voice ### Settings -- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve) - `void setEnabled(bool)` - Mute/unmute -- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization) + +Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI), +not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low +system volumes, so there's no separate volume control here. ### Mixing (consistent with SoundEngine) - `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on) diff --git a/Libraries/SfxEngine/Source/SfxEngine.cpp b/Libraries/SfxEngine/Source/SfxEngine.cpp index a6fd942..1ae57fa 100644 --- a/Libraries/SfxEngine/Source/SfxEngine.cpp +++ b/Libraries/SfxEngine/Source/SfxEngine.cpp @@ -9,7 +9,7 @@ #include "SfxEngine.h" #include "SfxDefinitions.h" -#include +#include #include #include #include "esp_log.h" @@ -320,15 +320,17 @@ void SfxEngine::fillStereoBuffer(int16_t* buf, int samples) { // Apply polyphonic soft gate (proportional reduction when clipping threatened) mix = applyPolyphonicGate(mix, activeVoices); - // Apply master volume (exponential curve for perceptual linearity) - float volCurve = masterVolume_ * masterVolume_; - mix *= volCurve; - // Apply auto-normalization (consistent volume across different SFX) mix = applyAutoNormalization(mix); // Brick-wall limiter (final safety net before soft clip) mix = applyBrickWallLimiter(mix); + + // The shared system output volume (esp_codec_dev hardware attenuation, set via + // the Settings UI / audio_stream_set_volume) is the sole loudness control here -- + // a fixed app-side gain multiplier stacked on top of it just gets swamped at low + // system-volume levels, making any such control feel like it does nothing. + mix *= systemVolumeMix_; } // Cubic soft clip @@ -460,14 +462,28 @@ void SfxEngine::audioTaskFunc(void* param) { } } + // Periodically refresh the cached system output volume/enabled state (cheap pass- + // through to the shared kernel device; polled rather than read per-sample since it + // changes rarely and audio_stream_get_volume may take a lock). + if (self->systemVolumePollCounter_-- <= 0) { + self->systemVolumePollCounter_ = 32; // ~0.5s at 256 samples / 16kHz + + float systemVolumePercent = 100.0f; + bool systemOutputEnabled = true; + audio_stream_get_volume(self->audioStreamDevice_, AUDIO_CODEC_DIR_OUTPUT, &systemVolumePercent); + audio_stream_get_enabled(self->audioStreamDevice_, AUDIO_CODEC_DIR_OUTPUT, &systemOutputEnabled); + + self->systemVolumeMix_ = systemOutputEnabled ? (systemVolumePercent / 100.0f) : 0.0f; + } + // Fill audio buffer (member buffer to avoid stack pressure) self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES); - // Write to I2S - error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_, - sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100)); + // Write to the audio stream (resampled to the codec's native rate transparently) + error_t error = audio_stream_write(self->audioStreamHandle_, self->audioBuffer_, + sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100)); if (error != ERROR_NONE) { - ESP_LOGE(TAG, "I2S write error"); + ESP_LOGE(TAG, "Audio stream write error"); self->running_ = false; break; } @@ -475,7 +491,7 @@ void SfxEngine::audioTaskFunc(void* param) { // Flush silence memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_)); - i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50)); + audio_stream_write(self->audioStreamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50)); ESP_LOGI(TAG, "Audio task exiting"); @@ -494,33 +510,31 @@ void SfxEngine::audioTaskFunc(void* param) { bool SfxEngine::start() { if (running_) return true; - // Find I2S device - i2sDevice_ = nullptr; - device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) { + // Find audio stream device + audioStreamDevice_ = nullptr; + device_for_each_of_type(&AUDIO_STREAM_TYPE, &audioStreamDevice_, [](Device* device, void* context) { if (!device_is_ready(device)) return true; Device** devicePtr = static_cast(context); *devicePtr = device; return false; }); - if (i2sDevice_ == nullptr) { - ESP_LOGW(TAG, "No I2S device found"); + if (audioStreamDevice_ == nullptr) { + ESP_LOGW(TAG, "No audio stream device found"); return false; } - // Configure I2S - I2sConfig config = { - .communication_format = I2S_FORMAT_STAND_I2S, + // Open output stream (the kernel resamples to the codec's native rate transparently) + AudioStreamConfig config = { .sample_rate = SAMPLE_RATE, .bits_per_sample = 16, - .channel_left = 0, - .channel_right = 0 + .channels = 2 }; - error_t error = i2s_controller_set_config(i2sDevice_, &config); + error_t error = audio_stream_open_output(audioStreamDevice_, &config, &audioStreamHandle_); if (error != ERROR_NONE) { - ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error)); - i2sDevice_ = nullptr; + ESP_LOGE(TAG, "Failed to open audio output stream: %s", error_to_string(error)); + audioStreamDevice_ = nullptr; return false; } @@ -528,12 +542,14 @@ bool SfxEngine::start() { msgQueue_ = xQueueCreate(8, sizeof(QueueMsg)); if (msgQueue_ == nullptr) { ESP_LOGE(TAG, "Failed to create message queue"); - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + audio_stream_close(audioStreamHandle_); + audioStreamHandle_ = nullptr; + audioStreamDevice_ = nullptr; return false; } // Start audio task + systemVolumePollCounter_ = 0; // poll the system volume immediately on the first iteration running_ = true; BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_); if (result != pdPASS) { @@ -541,8 +557,9 @@ bool SfxEngine::start() { running_ = false; vQueueDelete(msgQueue_); msgQueue_ = nullptr; - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + audio_stream_close(audioStreamHandle_); + audioStreamHandle_ = nullptr; + audioStreamDevice_ = nullptr; return false; } @@ -551,19 +568,22 @@ bool SfxEngine::start() { } void SfxEngine::stop() { - if (!running_) return; - - // Create semaphore for deterministic shutdown - stopSemaphore_ = xSemaphoreCreateBinary(); - running_ = false; + // Guard on msgQueue_ (the resource marker), not running_ - the audio task can clear + // running_ itself on a write error and self-delete before stop() is ever called, which + // would otherwise make this early-return and leak audioStreamHandle_/msgQueue_. + if (msgQueue_ == nullptr && audioStreamHandle_ == nullptr) return; + + if (running_) { + // Only wait on the semaphore if the task might still be alive to signal it - if + // running_ is already false, the task already exited (and self-deleted) on its own. + stopSemaphore_ = xSemaphoreCreateBinary(); + running_ = false; - if (task_ != nullptr) { - // Wait for audio task to signal completion (up to 500ms) - if (stopSemaphore_ != nullptr) { + if (task_ != nullptr && stopSemaphore_ != nullptr) { xSemaphoreTake(stopSemaphore_, pdMS_TO_TICKS(500)); } - task_ = nullptr; } + task_ = nullptr; if (stopSemaphore_ != nullptr) { vSemaphoreDelete(stopSemaphore_); @@ -575,43 +595,15 @@ void SfxEngine::stop() { msgQueue_ = nullptr; } - if (i2sDevice_ != nullptr) { - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + if (audioStreamHandle_ != nullptr) { + audio_stream_close(audioStreamHandle_); + audioStreamHandle_ = nullptr; } + audioStreamDevice_ = nullptr; ESP_LOGI(TAG, "SfxEngine stopped"); } -void SfxEngine::applyVolumePreset(VolumePreset preset) { - switch (preset) { - case VolumePreset::Quiet: - masterVolume_ = 0.3f; - autoNormalize_ = true; - targetRms_ = 0.25f; - polyphonicGateEnabled_ = true; - softGateThreshold_ = 0.90f; - ESP_LOGI(TAG, "Applied Quiet preset"); - break; - case VolumePreset::Normal: - masterVolume_ = 0.5f; - autoNormalize_ = true; - targetRms_ = 0.35f; - polyphonicGateEnabled_ = true; - softGateThreshold_ = 0.95f; - ESP_LOGI(TAG, "Applied Normal preset"); - break; - case VolumePreset::Loud: - masterVolume_ = 0.75f; - autoNormalize_ = true; - targetRms_ = 0.45f; - polyphonicGateEnabled_ = true; - softGateThreshold_ = 0.98f; - ESP_LOGI(TAG, "Applied Loud preset"); - break; - } -} - void SfxEngine::play(SfxId sound) { if (!running_ || msgQueue_ == nullptr) return;