diff --git a/.github/actions/publish-apps/action.yml b/.github/actions/publish-apps/action.yml new file mode 100644 index 0000000..e9d7cac --- /dev/null +++ b/.github/actions/publish-apps/action.yml @@ -0,0 +1,21 @@ +name: Publish Apps + +inputs: + sdk_version: + description: The SDK version that determines the path on the CDN + required: true + +runs: + using: 'composite' + steps: + - name: 'Download cdn-files' + uses: actions/download-artifact@v4 + with: + name: 'cdn-files' + path: cdn_files + - name: 'Install boto3' + shell: bash + run: pip install boto3 + - name: 'Upload files' + shell: bash + run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }} diff --git a/.github/actions/release-apps/action.yml b/.github/actions/release-apps/action.yml index 9c9b715..0b4b08e 100644 --- a/.github/actions/release-apps/action.yml +++ b/.github/actions/release-apps/action.yml @@ -1,5 +1,10 @@ name: Release Apps +outputs: + sdk_version: + description: 'Common SDK version shared by all bundled apps' + value: ${{ steps.release.outputs.sdk_version }} + runs: using: 'composite' steps: @@ -11,8 +16,11 @@ runs: run: rsync -av downloaded_apps/*/*.app cdn_files/ shell: bash - name: 'Create CDN release files' - run: python release.py cdn_files/ + id: release shell: bash + run: | + python release.py cdn_files/ + echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT" - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d73f6c..283a339 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,10 +12,12 @@ jobs: Build: strategy: matrix: - app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] + app_name: [AudioTest, BibleVerse, BookPlayer, Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GameBoy, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, McpScreen, MediaKeys, Mp3Player, MystifyDemo, PocketDungeon, ReynaBot, RobotArm, SerialConsole, Snake, TamaTac, TodoList, TwoEleven, VoiceRecorder] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: "Build" uses: ./.github/actions/build-app with: @@ -23,7 +25,26 @@ jobs: Bundle: runs-on: ubuntu-latest needs: [Build] + outputs: + sdk_version: ${{ steps.release.outputs.sdk_version }} steps: - uses: actions/checkout@v4 - name: "Build" + id: release uses: ./.github/actions/release-apps + PublishApps: + runs-on: ubuntu-latest + needs: [Bundle] + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: "Publish Apps" + env: + CDN_ID: ${{ secrets.CDN_ID }} + CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }} + CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }} + uses: ./.github/actions/publish-apps + with: + sdk_version: ${{ needs.Bundle.outputs.sdk_version }} diff --git a/Apps/Brainfuck/manifest.properties b/Apps/Brainfuck/manifest.properties index 33bc65d..2ebec5e 100644 --- a/Apps/Brainfuck/manifest.properties +++ b/Apps/Brainfuck/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.brainfuck -versionName=0.2.0 -versionCode=2 -name=Brainfuck interpreter -description=Brainfuck esoteric language interpreter +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.brainfuck +app.version.name=0.5.0 +app.version.code=5 +app.name=Brainfuck interpreter +app.description=Brainfuck esoteric language interpreter 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/Breakout/manifest.properties b/Apps/Breakout/manifest.properties index 61c9393..b4afc3c 100644 --- a/Apps/Breakout/manifest.properties +++ b/Apps/Breakout/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.breakout -versionName=0.2.0 -versionCode=2 -name=Breakout -description=Classic brick-breaking arcade game +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.breakout +app.version.name=0.5.0 +app.version.code=5 +app.name=Breakout +app.description=Classic brick-breaking arcade game diff --git a/Apps/Calculator/manifest.properties b/Apps/Calculator/manifest.properties index 00d51ae..4745112 100644 --- a/Apps/Calculator/manifest.properties +++ b/Apps/Calculator/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.calculator -versionName=0.3.0 -versionCode=3 -name=Calculator +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.calculator +app.version.name=0.6.0 +app.version.code=6 +app.name=Calculator diff --git a/Apps/Diceware/manifest.properties b/Apps/Diceware/manifest.properties index 4e2f25a..03b3b16 100644 --- a/Apps/Diceware/manifest.properties +++ b/Apps/Diceware/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.diceware -versionName=0.3.0 -versionCode=3 -name=Diceware +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.diceware +app.version.name=0.6.0 +app.version.code=6 +app.name=Diceware diff --git a/Apps/EpubReader/manifest.properties b/Apps/EpubReader/manifest.properties index 2f09899..66d82b1 100644 --- a/Apps/EpubReader/manifest.properties +++ b/Apps/EpubReader/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32s3,esp32p4 -[app] -id=one.tactility.epubreader -versionName=0.1.0 -versionCode=1 -name=Epub Reader -description=Epub and text file reader. Requires PSRAM! +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32s3,esp32p4 +app.id=one.tactility.epubreader +app.version.name=0.4.0 +app.version.code=4 +app.name=Epub Reader +app.description=Epub and text file reader. Requires PSRAM! 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/GPIO/manifest.properties b/Apps/GPIO/manifest.properties index f95d8a2..d15e2f8 100644 --- a/Apps/GPIO/manifest.properties +++ b/Apps/GPIO/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.gpio -versionName=0.4.0 -versionCode=4 -name=GPIO +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.gpio +app.version.name=0.7.0 +app.version.code=7 +app.name=GPIO diff --git a/Apps/GraphicsDemo/manifest.properties b/Apps/GraphicsDemo/manifest.properties index 574be53..cc3c7fd 100644 --- a/Apps/GraphicsDemo/manifest.properties +++ b/Apps/GraphicsDemo/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.graphicsdemo -versionName=0.3.0 -versionCode=3 -name=Graphics Demo +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.graphicsdemo +app.version.name=0.6.0 +app.version.code=6 +app.name=Graphics Demo diff --git a/Apps/HelloWorld/manifest.properties b/Apps/HelloWorld/manifest.properties index 8528901..4f4e73c 100644 --- a/Apps/HelloWorld/manifest.properties +++ b/Apps/HelloWorld/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32s3 -[app] -id=one.tactility.helloworld -versionName=0.3.0 -versionCode=3 -name=Hello World +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.helloworld +app.version.name=0.6.0 +app.version.code=6 +app.name=Hello World diff --git a/Apps/M5UnitTest/manifest.properties b/Apps/M5UnitTest/manifest.properties index 0874bb3..5c747be 100644 --- a/Apps/M5UnitTest/manifest.properties +++ b/Apps/M5UnitTest/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32s3,esp32p4 -[app] -id=one.tactility.m5unittest -versionName=0.1.0 -versionCode=1 -name=M5 Unit Test +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32s3,esp32p4 +app.id=one.tactility.m5unittest +app.version.name=0.4.0 +app.version.code=4 +app.name=M5 Unit Test diff --git a/Apps/Magic8Ball/manifest.properties b/Apps/Magic8Ball/manifest.properties index c61a6dc..6c7d091 100644 --- a/Apps/Magic8Ball/manifest.properties +++ b/Apps/Magic8Ball/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.magic8ball -versionName=0.2.0 -versionCode=2 -name=Magic 8-Ball +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.magic8ball +app.version.name=0.5.0 +app.version.code=5 +app.name=Magic 8-Ball 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/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index 55e5085..cfdf11b 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -1,11 +1,8 @@ -[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. +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32s3,esp32p4 +app.id=one.tactility.mediakeys +app.version.name=0.4.0 +app.version.code=4 +app.name=Media Keys +app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. diff --git a/Apps/MystifyDemo/manifest.properties b/Apps/MystifyDemo/manifest.properties index 70cc330..1ad4703 100644 --- a/Apps/MystifyDemo/manifest.properties +++ b/Apps/MystifyDemo/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.mystifydemo -versionName=0.3.0 -versionCode=3 -name=Mystify Demo +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.mystifydemo +app.version.name=0.6.0 +app.version.code=6 +app.name=Mystify Demo diff --git a/Apps/SerialConsole/manifest.properties b/Apps/SerialConsole/manifest.properties index 65aef8b..0411dc1 100644 --- a/Apps/SerialConsole/manifest.properties +++ b/Apps/SerialConsole/manifest.properties @@ -1,10 +1,7 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.serialconsole -versionName=0.4.0 -versionCode=4 -name=Serial Console +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.serialconsole +app.version.name=0.7.0 +app.version.code=7 +app.name=Serial Console diff --git a/Apps/Snake/manifest.properties b/Apps/Snake/manifest.properties index 20b8662..410637c 100644 --- a/Apps/Snake/manifest.properties +++ b/Apps/Snake/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.snake -versionName=0.5.0 -versionCode=5 -name=Snake -description=Classic Snake game +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.snake +app.version.name=0.8.0 +app.version.code=8 +app.name=Snake +app.description=Classic Snake game 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/Apps/TamaTac/manifest.properties b/Apps/TamaTac/manifest.properties index 2b4f1eb..17c2ada 100644 --- a/Apps/TamaTac/manifest.properties +++ b/Apps/TamaTac/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.tamatac -versionName=0.1.0 -versionCode=1 -name=TamaTac -description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.tamatac +app.version.name=0.4.0 +app.version.code=4 +app.name=TamaTac +app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. diff --git a/Apps/TodoList/manifest.properties b/Apps/TodoList/manifest.properties index a31f30d..5af158c 100644 --- a/Apps/TodoList/manifest.properties +++ b/Apps/TodoList/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.todolist -versionName=0.2.0 -versionCode=2 -name=Todo List -description=Simple task list manager +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.todolist +app.version.name=0.5.0 +app.version.code=5 +app.name=Todo List +app.description=Simple task list manager diff --git a/Apps/TwoEleven/manifest.properties b/Apps/TwoEleven/manifest.properties index 09f31ce..66a0261 100644 --- a/Apps/TwoEleven/manifest.properties +++ b/Apps/TwoEleven/manifest.properties @@ -1,11 +1,8 @@ -[manifest] -version=0.1 -[target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 -[app] -id=one.tactility.twoeleven -versionName=0.4.0 -versionCode=4 -name=2048 -description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). +manifest.version=0.2 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +app.id=one.tactility.twoeleven +app.version.name=0.7.0 +app.version.code=7 +app.name=2048 +app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). diff --git a/Buildscripts/CDN/upload-app-files.py b/Buildscripts/CDN/upload-app-files.py new file mode 100644 index 0000000..eab6986 --- /dev/null +++ b/Buildscripts/CDN/upload-app-files.py @@ -0,0 +1,70 @@ +import os +import sys +import boto3 + +SHELL_COLOR_RED = "\033[91m" +SHELL_COLOR_ORANGE = "\033[93m" +SHELL_COLOR_RESET = "\033[m" + +def print_warning(message): + print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}") + +def print_error(message): + print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}") + +def print_help(): + print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]") + print("") + print("Options:") + print(" --index-only Upload only apps.json") + +def exit_with_error(message): + print_error(message) + sys.exit(1) + +def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool): + if not os.path.exists(path): + exit_with_error(f"Path not found: {path}") + s3 = boto3.client( + service_name="s3", + endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com", + aws_access_key_id=cloudflare_token_name, + aws_secret_access_key=cloudflare_token_value, + region_name="auto" + ) + files_to_upload = os.listdir(path) + if index_only: + files_to_upload = [f for f in files_to_upload if f == 'apps.json'] + else: + # Ensure apps.json is uploaded last so it never references files that + # haven't finished uploading yet. + files_to_upload.sort(key=lambda f: f == 'apps.json') + counter = 1 + total = len(files_to_upload) + for file_name in files_to_upload: + object_path = f"apps/{sdk_version}/{file_name}" + print(f"[{counter}/{total}] Uploading {file_name} to {object_path}") + file_path = os.path.join(path, file_name) + try: + s3.upload_file(file_path, "tactility", object_path) + except Exception as e: + exit_with_error(f"Failed to upload {file_name}: {str(e)}") + counter += 1 + +if __name__ == "__main__": + print("Tactility CDN Apps Uploader") + if "--help" in sys.argv: + print_help() + sys.exit() + # Argument validation + if len(sys.argv) < 6: + print_help() + sys.exit(1) + main( + path=sys.argv[1], + sdk_version=sys.argv[2], + cloudflare_account_id=sys.argv[3], + cloudflare_token_name=sys.argv[4], + cloudflare_token_value=sys.argv[5], + index_only="--index-only" in sys.argv + ) 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/release.py b/release.py index 094b1b6..4456f5f 100644 --- a/release.py +++ b/release.py @@ -1,14 +1,23 @@ import json +import subprocess import tarfile import os import tempfile -import configparser import sys +from datetime import datetime, UTC def read_properties_file(path): - config = configparser.RawConfigParser() - config.read(path) - return config + properties = {} + with open(path, "r") as file: + for line in file: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + key, sep, value = line.partition("=") + if not sep: + continue + properties[key.strip()] = value.strip() + return properties def get_manifest(appPath): """Extract only the file named 'manifest.properties' from the given tar/tar.gz @@ -62,51 +71,49 @@ def get_manifest(appPath): return None def get_versioned_file_name(manifest): - app_id = manifest["app"]["id"] - version_code = manifest["app"]["versionCode"] + app_id = manifest["app.id"] + version_code = manifest["app.version.code"] return f"{app_id}-{version_code}.app" def get_os_version(manifest): - sdk = manifest["target"]["sdk"] + sdk = manifest["target.sdk"] # Remove trailing hyphen suffix if present if "-" in sdk: return sdk.rsplit("-", 1)[0].strip() else: return sdk -def manifest_config_to_flat_json(manifest): - """Convert a ConfigParser manifest into a flat JSON-like dict. +def check_and_get_sdk_version(manifest_map): + """Ensure all apps target the same (simplified) SDK version and return it.""" + versions = {get_os_version(manifest) for manifest in manifest_map.values()} + if len(versions) != 1: + print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.") + sys.exit(1) + return next(iter(versions)) - Expected sections/keys (case-insensitive for keys): - - [app] - id -> appId - versionName -> appVersionName - versionCode -> appVersionCode (int) - name -> appName - description -> appDescription (optional; default "") - - [target] - sdk -> targetSdk - platforms -> targetPlatforms (comma-separated list) +def get_git_commit_hash(): + return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip() + +def manifest_config_to_flat_json(manifest): + """Convert a flat (V2) manifest dict into a flat JSON-like dict. + + Expected keys: + app.id -> appId + app.version.name -> appVersionName + app.version.code -> appVersionCode (int) + app.name -> appName + app.description -> appDescription (optional; default "") + target.sdk -> targetSdk + target.platforms -> targetPlatforms (comma-separated list) Unknown/missing values fall back to sensible defaults per requirements. """ - def get_opt(section, option, default=None): - if not manifest.has_section(section): - return default - # try exact option then lowercase (RawConfigParser lowercases by default) - if manifest.has_option(section, option): - return manifest.get(section, option) - low = option.lower() - if manifest.has_option(section, low): - return manifest.get(section, low) - return default - # Map values - app_id = get_opt("app", "id", "") - app_version_name = get_opt("app", "versionName", "") - app_version_code_raw = get_opt("app", "versionCode", "0") - app_name = get_opt("app", "name", "") - app_description = get_opt("app", "description", "") or "" + app_id = manifest.get("app.id", "") + app_version_name = manifest.get("app.version.name", "") + app_version_code_raw = manifest.get("app.version.code", "0") + app_name = manifest.get("app.name", "") + app_description = manifest.get("app.description", "") or "" # Coerce version code to int safely try: @@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest): except Exception: app_version_code = 0 - target_sdk = get_opt("target", "sdk", "") - platforms_raw = get_opt("target", "platforms", "") + target_sdk = manifest.get("target.sdk", "") + platforms_raw = manifest.get("target.platforms", "") target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else [] filename = get_versioned_file_name(manifest) @@ -142,15 +149,23 @@ if __name__ == "__main__": sys.exit() app_directory = sys.argv[1] manifest_map = {} - output_json = { - "apps": [] - } any_manifest = None if os.path.exists(app_directory): for file in os.listdir(app_directory): if file.endswith(".app"): file_path = os.path.join(app_directory, file) manifest_map[file_path] = get_manifest(file_path) + # All bundled apps must target the same SDK version; this becomes the CDN path segment + sdk_version = check_and_get_sdk_version(manifest_map) + with open("sdk_version.txt", "w") as f: + f.write(sdk_version) + print(f"SDK version: {sdk_version}") + output_json = { + "sdkVersion": sdk_version, + "created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), + "gitCommit": get_git_commit_hash(), + "apps": [] + } # Rename files and collect manifest data into output json object for file_path in manifest_map.keys(): print(f"Processing {file_path}: {manifest_map[file_path]}") @@ -164,4 +179,4 @@ if __name__ == "__main__": any_manifest = manifest # Write JSON output_json_path = os.path.join(app_directory, "apps.json") - write_json(output_json_path, output_json) \ No newline at end of file + write_json(output_json_path, output_json) diff --git a/tactility.py b/tactility.py index 8215ec2..769de53 100644 --- a/tactility.py +++ b/tactility.py @@ -1,4 +1,3 @@ -import configparser import json import os import re @@ -13,7 +12,7 @@ import tarfile from urllib.parse import urlparse ttbuild_path = ".tactility" -ttbuild_version = "3.5.1" +ttbuild_version = "4.1.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 @@ -106,9 +105,17 @@ def get_url(ip, path): return f"http://{ip}:{ttport}{path}" def read_properties_file(path): - config = configparser.RawConfigParser() - config.read(path) - return config + properties = {} + with open(path, "r") as file: + for line in file: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + key, sep, value = line.partition("=") + if not sep: + continue + properties[key.strip()] = value.strip() + return properties #endregion Core @@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets): for platform in platform_targets: sdkconfig_filename = f"sdkconfig.app.{platform}" target_path = os.path.join(ttbuild_path, sdkconfig_filename) - if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): + if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path): exit_with_error(f"Failed to download sdkconfig file for {platform}") #endregion SDK helpers @@ -231,32 +238,12 @@ def read_manifest(): return read_properties_file("manifest.properties") def validate_manifest(manifest): - # [manifest] - if not "manifest" in manifest: - exit_with_error("Invalid manifest format: [manifest] not found") - if not "version" in manifest["manifest"]: - exit_with_error("Invalid manifest format: [manifest] version not found") - # [target] - if not "target" in manifest: - exit_with_error("Invalid manifest format: [target] not found") - if not "sdk" in manifest["target"]: - exit_with_error("Invalid manifest format: [target] sdk not found") - if not "platforms" in manifest["target"]: - exit_with_error("Invalid manifest format: [target] platforms not found") - # [app] - if not "app" in manifest: - exit_with_error("Invalid manifest format: [app] not found") - if not "id" in manifest["app"]: - exit_with_error("Invalid manifest format: [app] id not found") - if not "versionName" in manifest["app"]: - exit_with_error("Invalid manifest format: [app] versionName not found") - if not "versionCode" in manifest["app"]: - exit_with_error("Invalid manifest format: [app] versionCode not found") - if not "name" in manifest["app"]: - exit_with_error("Invalid manifest format: [app] name not found") + for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"): + if key not in manifest: + exit_with_error(f"Invalid manifest format: {key} not found") def is_valid_manifest_platform(manifest, platform): - manifest_platforms = manifest["target"]["platforms"].split(",") + manifest_platforms = manifest["target.platforms"].split(",") return platform in manifest_platforms def validate_manifest_platform(manifest, platform): @@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform): def get_manifest_target_platforms(manifest, requested_platform): if requested_platform == "" or requested_platform is None: - return manifest["target"]["platforms"].split(",") + return manifest["target.platforms"].split(",") else: validate_manifest_platform(manifest, requested_platform) return [requested_platform] @@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build): if use_local_sdk: global local_base_path local_base_path = os.environ.get("TACTILITY_SDK_PATH") - validate_local_sdks(platforms_to_build, manifest["target"]["sdk"]) + validate_local_sdks(platforms_to_build, manifest["target.sdk"]) if should_fetch_sdkconfig_files(platforms_to_build): fetch_sdkconfig_files(platforms_to_build) @@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build): sdk_json = read_sdk_json() validate_self(sdk_json) # Build - sdk_version = manifest["target"]["sdk"] + sdk_version = manifest["target.sdk"] if not use_local_sdk: if not sdk_download_all(sdk_version, platforms_to_build): exit_with_error("Failed to download one or more SDKs") @@ -570,7 +557,7 @@ def get_device_info(ip): print_status_error(f"Device info request failed: {e}") def run_action(manifest, ip): - app_id = manifest["app"]["id"] + app_id = manifest["app.id"] print_status_busy("Running") url = get_url(ip, "/app/run") params = {'id': app_id} @@ -614,7 +601,7 @@ def install_action(ip, platforms): return False def uninstall_action(manifest, ip): - app_id = manifest["app"]["id"] + app_id = manifest["app.id"] print_status_busy("Uninstalling") url = get_url(ip, "/app/uninstall") params = {'id': app_id} @@ -670,7 +657,7 @@ if __name__ == "__main__": exit_with_error("manifest.properties not found") manifest = read_manifest() validate_manifest(manifest) - all_platform_targets = manifest["target"]["platforms"].split(",") + all_platform_targets = manifest["target.platforms"].split(",") # Update SDK cache (tool.json) if not use_local_sdk and should_update_tool_json() and not update_tool_json(): exit_with_error("Failed to retrieve SDK info")