Merge origin/main into main - integrate EspNowBridge + upstream SDK 0.8.0-dev updates

- Merged CI workflow to include all local apps + EspNowBridge
- HelloWorld manifest now V2 format from upstream (sdk 0.8.0-dev)
- Kept local SfxEngine enhancements (polyphonic gate, auto-normalization)
- Brings main up to date with origin/main 25b966a
This commit is contained in:
Adolfo
2026-07-21 11:16:16 -04:00
35 changed files with 1277 additions and 281 deletions
+21
View File
@@ -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 }}
+9 -1
View File
@@ -1,5 +1,10 @@
name: Release Apps name: Release Apps
outputs:
sdk_version:
description: 'Common SDK version shared by all bundled apps'
value: ${{ steps.release.outputs.sdk_version }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -11,8 +16,11 @@ runs:
run: rsync -av downloaded_apps/*/*.app cdn_files/ run: rsync -av downloaded_apps/*/*.app cdn_files/
shell: bash shell: bash
- name: 'Create CDN release files' - name: 'Create CDN release files'
run: python release.py cdn_files/ id: release
shell: bash shell: bash
run: |
python release.py cdn_files/
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+22 -1
View File
@@ -12,10 +12,12 @@ jobs:
Build: Build:
strategy: strategy:
matrix: 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: "Build" - name: "Build"
uses: ./.github/actions/build-app uses: ./.github/actions/build-app
with: with:
@@ -23,7 +25,26 @@ jobs:
Bundle: Bundle:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [Build] needs: [Build]
outputs:
sdk_version: ${{ steps.release.outputs.sdk_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Build" - name: "Build"
id: release
uses: ./.github/actions/release-apps 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 }}
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.brainfuck
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.brainfuck app.name=Brainfuck interpreter
versionName=0.2.0 app.description=Brainfuck esoteric language interpreter
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
-1
View File
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
if (!sfxEngine) { if (!sfxEngine) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
sfxEngine->setEnabled(soundEnabled); sfxEngine->setEnabled(soundEnabled);
} }
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.breakout
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.breakout app.name=Breakout
versionName=0.2.0 app.description=Classic brick-breaking arcade game
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.calculator
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.calculator app.name=Calculator
versionName=0.3.0
versionCode=3
name=Calculator
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.diceware
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.diceware app.name=Diceware
versionName=0.3.0
versionCode=3
name=Diceware
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.epubreader
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.epubreader app.name=Epub Reader
versionName=0.1.0 app.description=Epub and text file reader. Requires PSRAM!
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(EspNowBridge)
tactility_project(EspNowBridge)
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK bootloader_support esp_app_format
)
@@ -0,0 +1,739 @@
#include "EspNowBridge.h"
#include <tactility/device.h>
#include <tactility/drivers/wifi.h>
#include <tactility/wifi_auto_scan.h>
#include <tactility/firmware/firmware.h>
#include <tt_app.h>
#include <tt_app_fileselection.h>
#include <tt_bundle.h>
#include <tt_lock.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <esp_app_desc.h>
#include <esp_app_format.h>
#include <esp_system.h>
#include <tactility/log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm>
#include <cinttypes>
#include <cstdio>
#include <cstring>
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<long>(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<long>(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<long>(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<long>(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*> 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<UiDispatchPayload*>(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<std::string*>(context));
}
void freeString(void* context) { delete static_cast<std::string*>(context); }
void workSetProgress(EspNowBridge& app, void* context) {
app.setProgress(*static_cast<int*>(context));
}
void freeInt(void* context) { delete static_cast<int*>(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<size_t>(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<long>(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<EspNowBridge*>(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<EspNowBridge*>(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<EspNowBridge*>(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;
}
}
}
@@ -0,0 +1,108 @@
#pragma once
#include <TactilityCpp/App.h>
#include <atomic>
#include <optional>
#include <string>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <tactility/drivers/wifi.h>
/** 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<bool> 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<EspNowBridge*> 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<int> 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<AutoScanPauseGuard> 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);
};
+11
View File
@@ -0,0 +1,11 @@
#include "EspNowBridge.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<EspNowBridge>();
return 0;
}
}
+8
View File
@@ -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.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.gpio
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.gpio app.name=GPIO
versionName=0.4.0
versionCode=4
name=GPIO
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.graphicsdemo
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.graphicsdemo app.name=Graphics Demo
versionName=0.3.0
versionCode=3
name=Graphics Demo
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.helloworld
platforms=esp32s3 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.helloworld app.name=Hello World
versionName=0.3.0
versionCode=3
name=Hello World
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.m5unittest
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.m5unittest app.name=M5 Unit Test
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.magic8ball
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.magic8ball app.name=Magic 8-Ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
+46 -14
View File
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode(); 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) { void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF"); LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled; _isEnabled = enabled;
if (enabled) { if (enabled) {
_btDevice = bluetooth_find_first_ready_device(); _btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
if (!_btDevice) { if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found"); LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false; _isEnabled = false;
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
return; 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"); bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event. // Register callback before enabling radio so we don't miss the state-change event.
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
} else { } else {
_radioEnabling = false; _radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); 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 (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); teardownBt();
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); 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); 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*/) { 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; _radioEnabling = false;
_radioWasOff = false; _isEnabled = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
teardownBt();
if (_keyHighlightTimer) { if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer); lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr; _keyHighlightTimer = nullptr;
+6 -3
View File
@@ -2,6 +2,7 @@
#include <TactilityCpp/App.h> #include <TactilityCpp/App.h>
#include <lvgl.h> #include <lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h> #include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h> #include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h> #include <tt_app.h>
@@ -23,9 +24,10 @@ class MediaKeys final : public App {
struct Device* _hidDevice = nullptr; struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread // State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false}; std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off) std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
// Static event callbacks // Static event callbacks
static void onSwitchToggled(lv_event_t* e); static void onSwitchToggled(lv_event_t* e);
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
static void sendKeyTask(void* param); static void sendKeyTask(void* param);
// Instance methods called by static callbacks // Instance methods called by static callbacks
void teardownBt(); // remove callback + stop HID + restore radio/device state
void handleSwitchToggle(bool enabled); void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId); void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON void startHid(); // called once radio is confirmed ON
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32s3,esp32p4
sdk=0.7.0-dev app.id=one.tactility.mediakeys
platforms=esp32s3,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.mediakeys app.name=Media Keys
versionName=0.1.0 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.
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.
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.mystifydemo
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.6.0
[app] app.version.code=6
id=one.tactility.mystifydemo app.name=Mystify Demo
versionName=0.3.0
versionCode=3
name=Mystify Demo
+7 -10
View File
@@ -1,10 +1,7 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.serialconsole
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.serialconsole app.name=Serial Console
versionName=0.4.0
versionCode=4
name=Serial Console
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.snake
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.8.0
[app] app.version.code=8
id=one.tactility.snake app.name=Snake
versionName=0.5.0 app.description=Classic Snake game
versionCode=5
name=Snake
description=Classic Snake game
-1
View File
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
if (sfxEngine == nullptr) { if (sfxEngine == nullptr) {
sfxEngine = new SfxEngine(); sfxEngine = new SfxEngine();
sfxEngine->start(); sfxEngine->start();
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
// Load settings // Load settings
bool soundEnabled; bool soundEnabled;
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.tamatac
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.4.0
[app] app.version.code=4
id=one.tactility.tamatac app.name=TamaTac
versionName=0.1.0 app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.todolist
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.5.0
[app] app.version.code=5
id=one.tactility.todolist app.name=Todo List
versionName=0.2.0 app.description=Simple task list manager
versionCode=2
name=Todo List
description=Simple task list manager
+8 -11
View File
@@ -1,11 +1,8 @@
[manifest] manifest.version=0.2
version=0.1 target.sdk=0.8.0-dev
[target] target.platforms=esp32,esp32s3,esp32c6,esp32p4
sdk=0.7.0-dev app.id=one.tactility.twoeleven
platforms=esp32,esp32s3,esp32c6,esp32p4 app.version.name=0.7.0
[app] app.version.code=7
id=one.tactility.twoeleven app.name=2048
versionName=0.4.0 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).
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).
+70
View File
@@ -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
)
+4 -5
View File
@@ -35,11 +35,8 @@ if (!engine->start()) {
ESP_LOGE(TAG, "Failed to start SfxEngine"); ESP_LOGE(TAG, "Failed to start SfxEngine");
return; return;
} }
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
engine->play(SfxId::Coin); // Predefined SFX engine->play(SfxId::Coin); // Predefined SFX
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
engine->setVolume(0.7f); // Volume control
engine->stop(); engine->stop();
delete engine; delete engine;
@@ -87,9 +84,11 @@ idf_component_register(
- `void stopVoice(voice)` - Stop specific voice - `void stopVoice(voice)` - Stop specific voice
### Settings ### Settings
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
- `void setEnabled(bool)` - Mute/unmute - `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) ### Mixing (consistent with SoundEngine)
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on) - `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
+55 -40
View File
@@ -1,14 +1,23 @@
import json import json
import subprocess
import tarfile import tarfile
import os import os
import tempfile import tempfile
import configparser
import sys import sys
from datetime import datetime, UTC
def read_properties_file(path): def read_properties_file(path):
config = configparser.RawConfigParser() properties = {}
config.read(path) with open(path, "r") as file:
return config 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): def get_manifest(appPath):
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz """Extract only the file named 'manifest.properties' from the given tar/tar.gz
@@ -62,51 +71,49 @@ def get_manifest(appPath):
return None return None
def get_versioned_file_name(manifest): def get_versioned_file_name(manifest):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
version_code = manifest["app"]["versionCode"] version_code = manifest["app.version.code"]
return f"{app_id}-{version_code}.app" return f"{app_id}-{version_code}.app"
def get_os_version(manifest): def get_os_version(manifest):
sdk = manifest["target"]["sdk"] sdk = manifest["target.sdk"]
# Remove trailing hyphen suffix if present # Remove trailing hyphen suffix if present
if "-" in sdk: if "-" in sdk:
return sdk.rsplit("-", 1)[0].strip() return sdk.rsplit("-", 1)[0].strip()
else: else:
return sdk return sdk
def manifest_config_to_flat_json(manifest): def check_and_get_sdk_version(manifest_map):
"""Convert a ConfigParser manifest into a flat JSON-like dict. """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): def get_git_commit_hash():
- [app] return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
id -> appId
versionName -> appVersionName def manifest_config_to_flat_json(manifest):
versionCode -> appVersionCode (int) """Convert a flat (V2) manifest dict into a flat JSON-like dict.
name -> appName
description -> appDescription (optional; default "") Expected keys:
- [target] app.id -> appId
sdk -> targetSdk app.version.name -> appVersionName
platforms -> targetPlatforms (comma-separated list) 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. 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 # Map values
app_id = get_opt("app", "id", "") app_id = manifest.get("app.id", "")
app_version_name = get_opt("app", "versionName", "") app_version_name = manifest.get("app.version.name", "")
app_version_code_raw = get_opt("app", "versionCode", "0") app_version_code_raw = manifest.get("app.version.code", "0")
app_name = get_opt("app", "name", "") app_name = manifest.get("app.name", "")
app_description = get_opt("app", "description", "") or "" app_description = manifest.get("app.description", "") or ""
# Coerce version code to int safely # Coerce version code to int safely
try: try:
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
except Exception: except Exception:
app_version_code = 0 app_version_code = 0
target_sdk = get_opt("target", "sdk", "") target_sdk = manifest.get("target.sdk", "")
platforms_raw = get_opt("target", "platforms", "") 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 [] 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) filename = get_versioned_file_name(manifest)
@@ -142,15 +149,23 @@ if __name__ == "__main__":
sys.exit() sys.exit()
app_directory = sys.argv[1] app_directory = sys.argv[1]
manifest_map = {} manifest_map = {}
output_json = {
"apps": []
}
any_manifest = None any_manifest = None
if os.path.exists(app_directory): if os.path.exists(app_directory):
for file in os.listdir(app_directory): for file in os.listdir(app_directory):
if file.endswith(".app"): if file.endswith(".app"):
file_path = os.path.join(app_directory, file) file_path = os.path.join(app_directory, file)
manifest_map[file_path] = get_manifest(file_path) 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 # Rename files and collect manifest data into output json object
for file_path in manifest_map.keys(): for file_path in manifest_map.keys():
print(f"Processing {file_path}: {manifest_map[file_path]}") print(f"Processing {file_path}: {manifest_map[file_path]}")
+23 -36
View File
@@ -1,4 +1,3 @@
import configparser
import json import json
import os import os
import re import re
@@ -13,7 +12,7 @@ import tarfile
from urllib.parse import urlparse from urllib.parse import urlparse
ttbuild_path = ".tactility" ttbuild_path = ".tactility"
ttbuild_version = "3.5.1" ttbuild_version = "4.1.0"
ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666 ttport = 6666
@@ -106,9 +105,17 @@ def get_url(ip, path):
return f"http://{ip}:{ttport}{path}" return f"http://{ip}:{ttport}{path}"
def read_properties_file(path): def read_properties_file(path):
config = configparser.RawConfigParser() properties = {}
config.read(path) with open(path, "r") as file:
return config 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 #endregion Core
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets: for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}" sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename) 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}") exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers #endregion SDK helpers
@@ -231,32 +238,12 @@ def read_manifest():
return read_properties_file("manifest.properties") return read_properties_file("manifest.properties")
def validate_manifest(manifest): def validate_manifest(manifest):
# [manifest] for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
if not "manifest" in manifest: if key not in manifest:
exit_with_error("Invalid manifest format: [manifest] not found") exit_with_error(f"Invalid manifest format: {key} 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")
def is_valid_manifest_platform(manifest, platform): def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target"]["platforms"].split(",") manifest_platforms = manifest["target.platforms"].split(",")
return platform in manifest_platforms return platform in manifest_platforms
def validate_manifest_platform(manifest, platform): def validate_manifest_platform(manifest, platform):
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform): def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None: if requested_platform == "" or requested_platform is None:
return manifest["target"]["platforms"].split(",") return manifest["target.platforms"].split(",")
else: else:
validate_manifest_platform(manifest, requested_platform) validate_manifest_platform(manifest, requested_platform)
return [requested_platform] return [requested_platform]
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
if use_local_sdk: if use_local_sdk:
global local_base_path global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_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): if should_fetch_sdkconfig_files(platforms_to_build):
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() sdk_json = read_sdk_json()
validate_self(sdk_json) validate_self(sdk_json)
# Build # Build
sdk_version = manifest["target"]["sdk"] sdk_version = manifest["target.sdk"]
if not use_local_sdk: if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build): if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs") 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}") print_status_error(f"Device info request failed: {e}")
def run_action(manifest, ip): def run_action(manifest, ip):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
print_status_busy("Running") print_status_busy("Running")
url = get_url(ip, "/app/run") url = get_url(ip, "/app/run")
params = {'id': app_id} params = {'id': app_id}
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
return False return False
def uninstall_action(manifest, ip): def uninstall_action(manifest, ip):
app_id = manifest["app"]["id"] app_id = manifest["app.id"]
print_status_busy("Uninstalling") print_status_busy("Uninstalling")
url = get_url(ip, "/app/uninstall") url = get_url(ip, "/app/uninstall")
params = {'id': app_id} params = {'id': app_id}
@@ -670,7 +657,7 @@ if __name__ == "__main__":
exit_with_error("manifest.properties not found") exit_with_error("manifest.properties not found")
manifest = read_manifest() manifest = read_manifest()
validate_manifest(manifest) validate_manifest(manifest)
all_platform_targets = manifest["target"]["platforms"].split(",") all_platform_targets = manifest["target.platforms"].split(",")
# Update SDK cache (tool.json) # Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_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") exit_with_error("Failed to retrieve SDK info")