1 Commits

Author SHA1 Message Date
Adolfo Reyna 9db2d3a3fa Implement Mp3Player dynamic application 2026-06-28 22:24:17 -04:00
31 changed files with 2569 additions and 776 deletions
-21
View File
@@ -1,21 +0,0 @@
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 }}
+1 -9
View File
@@ -1,10 +1,5 @@
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:
@@ -16,11 +11,8 @@ 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'
id: release run: python release.py cdn_files/
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:
-21
View File
@@ -16,8 +16,6 @@ jobs:
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:
@@ -25,26 +23,7 @@ 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 }}
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.brainfuck sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Brainfuck interpreter id=one.tactility.brainfuck
app.description=Brainfuck esoteric language interpreter versionName=0.2.0
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.breakout sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Breakout id=one.tactility.breakout
app.description=Classic brick-breaking arcade game versionName=0.2.0
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.calculator sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Calculator id=one.tactility.calculator
versionName=0.3.0
versionCode=3
name=Calculator
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.diceware sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Diceware id=one.tactility.diceware
versionName=0.3.0
versionCode=3
name=Diceware
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.epubreader sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=Epub Reader id=one.tactility.epubreader
app.description=Epub and text file reader. Requires PSRAM! versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.gpio sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=GPIO id=one.tactility.gpio
versionName=0.4.0
versionCode=4
name=GPIO
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.graphicsdemo sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Graphics Demo id=one.tactility.graphicsdemo
versionName=0.3.0
versionCode=3
name=Graphics Demo
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.helloworld sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Hello World id=one.tactility.helloworld
versionName=0.3.0
versionCode=3
name=Hello World
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.m5unittest sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=M5 Unit Test id=one.tactility.m5unittest
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.magic8ball sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Magic 8-Ball id=one.tactility.magic8ball
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32s3,esp32p4 [target]
app.id=one.tactility.mediakeys sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32s3,esp32p4
app.version.code=4 [app]
app.name=Media Keys id=one.tactility.mediakeys
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. 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.
@@ -12,5 +12,5 @@ endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(RobotArm) project(Mp3Player)
tactility_project(RobotArm) tactility_project(Mp3Player)
+6
View File
@@ -0,0 +1,6 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
+427
View File
@@ -0,0 +1,427 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#define MINIMP3_IMPLEMENTATION
#define MINIMP3_NO_SIMD
#include "minimp3.h"
#define TAG "Mp3Player"
#define MP3_INPUT_BUFFER_SIZE 16384
typedef enum {
STATE_IDLE,
STATE_PLAYING,
STATE_PAUSED
} PlaybackState;
typedef struct {
struct Device* i2s_dev;
char filepath[512];
PlaybackState state;
// UI elements
lv_obj_t* lbl_title;
lv_obj_t* lbl_status;
lv_obj_t* bar_progress;
lv_obj_t* btn_play_pause;
lv_obj_t* btn_stop;
// Playback state variables
FILE* file;
mp3dec_t decoder;
uint8_t* input_buf;
mp3d_sample_t* pcm_buf;
size_t buffered_bytes;
bool eof;
int volume;
int sample_rate;
int channels;
// Progress calculation
size_t file_size;
size_t bytes_read_total;
TaskHandle_t playback_task_handle;
} AppCtx;
static AppCtx g_ctx;
/* ─── Update UI ─── */
static void update_ui(AppCtx* ctx) {
if (!ctx->lbl_status) return;
switch (ctx->state) {
case STATE_PLAYING:
lv_label_set_text(ctx->lbl_status, "Playing...");
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE " Pause");
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
break;
case STATE_PAUSED:
lv_label_set_text(ctx->lbl_status, "Paused");
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY " Play");
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
break;
case STATE_IDLE:
default:
if (ctx->filepath[0] != '\0') {
lv_label_set_text(ctx->lbl_status, "Ready");
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
} else {
lv_label_set_text(ctx->lbl_status, "No file loaded");
lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED);
}
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY " Play");
lv_obj_add_state(ctx->btn_stop, LV_STATE_DISABLED);
lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF);
break;
}
}
/* ─── Playback Task ─── */
static void mp3_playback_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
ctx->file = fopen(ctx->filepath, "rb");
if (!ctx->file) {
ESP_LOGE(TAG, "Failed to open file: %s", ctx->filepath);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
lv_label_set_text(ctx->lbl_status, "Error: File not found");
update_ui(ctx);
tt_lvgl_unlock();
ctx->playback_task_handle = NULL;
vTaskDelete(NULL);
return;
}
fseek(ctx->file, 0, SEEK_END);
ctx->file_size = ftell(ctx->file);
fseek(ctx->file, 0, SEEK_SET);
ctx->bytes_read_total = 0;
mp3dec_init(&ctx->decoder);
ctx->buffered_bytes = 0;
ctx->eof = false;
ctx->sample_rate = 0;
ctx->channels = 0;
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
while (ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
// Fill input buffer
if (!ctx->eof && ctx->buffered_bytes < MP3_INPUT_BUFFER_SIZE) {
size_t to_read = MP3_INPUT_BUFFER_SIZE - ctx->buffered_bytes;
size_t read_bytes = fread(ctx->input_buf + ctx->buffered_bytes, 1, to_read, ctx->file);
ctx->buffered_bytes += read_bytes;
ctx->bytes_read_total += read_bytes;
if (read_bytes == 0) {
ctx->eof = true;
}
}
if (ctx->buffered_bytes == 0 && ctx->eof) {
ESP_LOGI(TAG, "Reached EOF");
break;
}
// Decode one frame
mp3dec_frame_info_t info;
memset(&info, 0, sizeof(info));
int samples = mp3dec_decode_frame(&ctx->decoder, ctx->input_buf, (int)ctx->buffered_bytes, ctx->pcm_buf, &info);
if (info.frame_bytes <= 0) {
if (ctx->eof) {
break;
}
// Move 1 byte forward to resync
memmove(ctx->input_buf, ctx->input_buf + 1, --ctx->buffered_bytes);
continue;
}
size_t consumed = (size_t)info.frame_bytes;
ctx->buffered_bytes -= consumed;
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
if (samples > 0) {
// Configure I2S if format changed
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
struct I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = (uint32_t)info.hz,
.bits_per_sample = 16,
.channel_left = 0,
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to set config: %d", err);
break;
}
ctx->sample_rate = info.hz;
ctx->channels = info.channels;
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
}
// Adjust volume
int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
size_t sample_count = (size_t)samples * info.channels;
for (size_t i = 0; i < sample_count; ++i) {
int32_t scaled = (int32_t)samples_ptr[i] * vol / 100;
samples_ptr[i] = (int16_t)scaled;
}
// Play audio to I2S
size_t offset = 0;
size_t data_size = sample_count * sizeof(int16_t);
bool write_err = false;
while (offset < data_size && ctx->state == STATE_PLAYING) {
size_t written = 0;
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "I2S write failed: %d", err);
write_err = true;
break;
}
offset += written;
}
if (write_err) {
break;
}
}
// Update progress UI
if (ctx->file_size > 0) {
int pct = (int)((ctx->bytes_read_total - ctx->buffered_bytes) * 100 / ctx->file_size);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
tt_lvgl_lock(portMAX_DELAY);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
taskYIELD();
}
fclose(ctx->file);
ctx->file = NULL;
ESP_LOGI(TAG, "Playback task finished");
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
update_ui(ctx);
tt_lvgl_unlock();
ctx->playback_task_handle = NULL;
vTaskDelete(NULL);
}
/* ─── Callbacks ─── */
static void on_play_pause_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->filepath[0] == '\0') return;
if (ctx->state == STATE_IDLE) {
ctx->state = STATE_PLAYING;
update_ui(ctx);
xTaskCreate(mp3_playback_task, "mp3_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED;
update_ui(ctx);
} else if (ctx->state == STATE_PAUSED) {
ctx->state = STATE_PLAYING;
update_ui(ctx);
}
}
static void on_stop_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (ctx->state == STATE_IDLE) return;
ctx->state = STATE_IDLE;
update_ui(ctx);
}
/* ─── App Lifecycle ─── */
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.volume = 80;
// Allocate memory buffers
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
// Find device
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (!g_ctx.i2s_dev) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
}
// Parse launch parameters
BundleHandle bundle = tt_app_get_parameters(app);
if (bundle) {
char file_param[256] = {0};
if (tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
// Safely resolve SD card path
if (strncmp(file_param, "/sdcard", 7) == 0) {
strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath) - 1);
} else {
if (file_param[0] == '/') {
snprintf(g_ctx.filepath, sizeof(g_ctx.filepath), "/sdcard%s", file_param);
} else {
snprintf(g_ctx.filepath, sizeof(g_ctx.filepath), "/sdcard/%s", file_param);
}
}
}
}
// ─── UI Layout ───
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Player Card (rounded box)
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_size(card, lv_pct(90), lv_pct(70));
lv_obj_align(card, LV_ALIGN_CENTER, 0, 15);
lv_obj_set_style_radius(card, 15, 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(card, 2, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(card, 15, 0);
lv_obj_set_style_pad_gap(card, 15, 0);
// Audio Icon
lv_obj_t* icon = lv_label_create(card);
lv_label_set_text(icon, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_color(icon, lv_color_hex(0x89B4FA), 0);
// Track Title
g_ctx.lbl_title = lv_label_create(card);
lv_obj_set_width(g_ctx.lbl_title, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_title, lv_color_hex(0xCDD6F4), 0);
if (g_ctx.filepath[0] != '\0') {
const char* last_slash = strrchr(g_ctx.filepath, '/');
const char* filename = last_slash ? last_slash + 1 : g_ctx.filepath;
lv_label_set_text(g_ctx.lbl_title, filename);
lv_label_set_long_mode(g_ctx.lbl_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
} else {
lv_label_set_text(g_ctx.lbl_title, "No File Parameter");
}
// Playback Status
g_ctx.lbl_status = lv_label_create(card);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xA6ADC8), 0);
// Progress Bar
g_ctx.bar_progress = lv_bar_create(card);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(85), 8);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
// Controls Container
lv_obj_t* ctrl_box = lv_obj_create(card);
lv_obj_remove_style_all(ctrl_box);
lv_obj_set_size(ctrl_box, lv_pct(90), 45);
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Play/Pause Button
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_play_pause, 100, 36);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 18, 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " Play");
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Stop Button
g_ctx.btn_stop = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_stop, 100, 36);
lv_obj_set_style_radius(g_ctx.btn_stop, 18, 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xF38BA8), 0);
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP " Stop");
lv_obj_center(lbl_stop);
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
if (!g_ctx.i2s_dev) {
lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
lv_label_set_text(g_ctx.lbl_status, "Error: Out of Memory");
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
} else {
g_ctx.state = STATE_IDLE;
update_ui(&g_ctx);
// Auto-play if a file parameter was provided
if (g_ctx.filepath[0] != '\0') {
g_ctx.state = STATE_PLAYING;
update_ui(&g_ctx);
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
}
}
}
static void onHideApp(AppHandle app, void* data) {
if (g_ctx.state != STATE_IDLE) {
g_ctx.state = STATE_IDLE; // Signal task to stop
}
// Wait for the task to finish self-deletion to prevent resource leaks
while (g_ctx.playback_task_handle != NULL) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (g_ctx.input_buf) {
free(g_ctx.input_buf);
g_ctx.input_buf = NULL;
}
if (g_ctx.pcm_buf) {
free(g_ctx.pcm_buf);
g_ctx.pcm_buf = NULL;
}
}
int main(int argc, char* argv[]) {
tt_app_register((AppRegistration) {
.onShow = onShowApp,
.onHide = onHideApp
});
return 0;
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mp3player
versionName=1.0.0
versionCode=1
name=MP3 Player
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.mystifydemo sdk=0.7.0-dev
app.version.name=0.6.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=6 [app]
app.name=Mystify Demo id=one.tactility.mystifydemo
versionName=0.3.0
versionCode=3
name=Mystify Demo
-6
View File
@@ -1,6 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK lwip
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function)
-429
View File
@@ -1,429 +0,0 @@
#include <tt_app.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "esp_log.h"
#define TAG "RobotArm"
#define ARM_HOST "192.168.68.103"
#define ARM_PORT 80
#define ARM_PATH "/api/mcp"
#define NUM_JOINTS 6
#define SEQ_MAX 16
typedef struct {
const char* name;
const char* cute;
uint32_t bg;
uint32_t accent;
int home, rmin, rmax;
int value, last_sent;
} Joint;
static Joint joints[NUM_JOINTS] = {
{"base","Base",0xFFD6E0,0xFF8FA8,70,0,180,70,-1},
{"shoulder","Shldr",0xD6E8FF,0x8FB6FF,40,0,70,40,-1},
{"elbow","Elbow",0xFFE8C5,0xFFB86A,20,0,120,20,-1},
{"pitch","Pitch",0xD5F0D5,0x88D488,90,30,150,90,-1},
{"roll","Roll",0xE8D5FF,0xB088FF,90,30,150,90,-1},
{"gripper","Grip",0xFFF0B3,0xFFD060,90,0,180,90,-1},
};
typedef struct { int v[NUM_JOINTS]; } Frame;
static Frame seq[SEQ_MAX];
static int seq_len = 0, seq_idx = -1;
static bool seq_playing = false;
static int seq_play_pos = 0;
typedef struct {
AppHandle app;
lv_obj_t* status;
lv_obj_t* sliders[6];
lv_obj_t* vals[6];
lv_obj_t* seq_label;
lv_obj_t* play_label;
lv_obj_t* blocks[SEQ_MAX];
int pend[6];
bool has[6];
int ticks[6];
lv_timer_t* poll;
lv_timer_t* seq_timer;
} Ctx;
static Ctx* g = NULL;
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
if(fd<0) return -1;
struct sockaddr_in s; memset(&s,0,sizeof(s));
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
struct timeval tv={5,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
char hdr[256];
int bl=strlen(body);
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
int tot=0;
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
out[tot]='\0'; close(fd);
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
return tot>0?0:-4;
}
static bool jget(const char* js,const char* key,int* out){
const char* p=strstr(js,key);
if(!p) return false;
p+=strlen(key);
while(*p && *p!=':'){
p++;
if(!*p) return false;
}
p++;
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
int sign=1;
if(*p=='-'){sign=-1;p++;}
float v=0,frac=0.1f;
bool dot=false,got=false;
while(*p){
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
else if(*p=='.'&&!dot) dot=true;
else break;
p++;
}
if(!got) return false;
*out=(int)(v*sign+0.5f);
return true;
}
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
int v; bool ok=true;
if(jget(r,"base",&v)) *b=v; else ok=false;
if(jget(r,"shoulder",&v)) *s=v; else ok=false;
if(jget(r,"elbow",&v)) *e=v; else ok=false;
if(jget(r,"pitch",&v)) *p=v; else ok=false;
if(jget(r,"roll",&v)) *ro=v; else ok=false;
if(jget(r,"gripper",&v)) *gr=v; else ok=false;
return ok;
}
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
char resp[2048]; memset(resp,0,sizeof(resp));
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
return parse_state(resp,b,s,e,p,ro,gr);
}
static bool rpc_move(const char* j,int a){
char body[300]; snprintf(body,sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
j,a);
char r[1024]; memset(r,0,sizeof(r));
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
}
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
char body[420]; snprintf(body,sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
b,s,e,p,ro,gr,dur);
char r[1024]; memset(r,0,sizeof(r));
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
return rc==0;
}
static bool rpc_home(void){
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
}
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
static void apply_ui(void){
if(!g) return;
for(int i=0;i<NUM_JOINTS;i++){
if(g->sliders[i]) lv_slider_set_value(g->sliders[i],joints[i].value,LV_ANIM_OFF);
if(g->vals[i]){char buf[8]; snprintf(buf,sizeof(buf),"%d",joints[i].value); lv_label_set_text(g->vals[i],buf);}
}
}
static void refresh_arm(void){
set_status("Reading .103...");
int b,s,e,p,ro,gr;
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
joints[0].value=b; joints[1].value=s; joints[2].value=e;
joints[3].value=p; joints[4].value=ro; joints[5].value=gr;
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
apply_ui();
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
} else set_status("No .103");
}
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
static void poll_cb(lv_timer_t* t){
(void)t; if(!g) return;
for(int i=0;i<NUM_JOINTS;i++){
if(!g->has[i]) continue;
g->ticks[i]++; if(g->ticks[i]<3) continue;
g->has[i]=false; g->ticks[i]=0;
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
joints[i].last_sent=tgt; joints[i].value=tgt;
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
bool ok=rpc_move(joints[i].name,tgt);
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
break;
}
}
static void slider_cb(lv_event_t* e){
int idx=(int)(intptr_t)lv_event_get_user_data(e);
int v=(int)lv_slider_get_value(lv_event_get_target(e));
joints[idx].value=v;
if(g){
if(g->vals[idx]){char b[8]; snprintf(b,sizeof(b),"%d",v); lv_label_set_text(g->vals[idx],b);}
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
}
}
/* ── sequencer ── */
static void update_seq_ui(void){
if(!g) return;
if(g->seq_label){
if(seq_len==0) lv_label_set_text(g->seq_label,"empty");
else {char b[16]; snprintf(b,sizeof(b),"%d/%d", (seq_idx>=0?seq_idx+1:seq_len), seq_len); lv_label_set_text(g->seq_label,b);}
}
for(int i=0;i<SEQ_MAX;i++){
if(!g->blocks[i]) continue;
if(i<seq_len){
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_COVER,0);
if(i==seq_idx){
lv_obj_set_style_border_width(g->blocks[i],3,0);
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0xFFFFFF),0);
} else {
lv_obj_set_style_border_width(g->blocks[i],2,0);
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0x3A3A3A),0);
}
} else {
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_30,0);
lv_obj_set_style_border_width(g->blocks[i],0,0);
}
}
}
static void load_frame(int fidx){
if(fidx<0||fidx>=seq_len) return;
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=seq[fidx].v[i]; joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false;}}
apply_ui(); seq_idx=fidx; update_seq_ui();
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
}
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
static void seq_rem_cb(void){
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
int rem=seq_idx;
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
if(seq_idx>=seq_len) seq_idx=seq_len-1;
load_frame(seq_idx);
}
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
static void seq_timer_cb(lv_timer_t* t){
(void)t; if(!seq_playing||seq_len==0) return;
int f=seq_play_pos%seq_len;
int* v=seq[f].v;
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
set_status("Seq fail"); seq_playing=false;
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
return;
}
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
apply_ui(); seq_idx=f; update_seq_ui();
char b[20]; snprintf(b,sizeof(b),"Play %d/%d",f+1,seq_len); set_status(b);
seq_play_pos++; if(seq_play_pos>=seq_len) seq_play_pos=0;
}
static void seq_play_cb(lv_event_t* e){
(void)e; if(seq_len==0){set_status("No frames"); return;}
seq_playing=!seq_playing;
if(g&&g->play_label) lv_label_set_text(g->play_label, seq_playing?"Pause":"Play");
if(seq_playing){seq_play_pos=(seq_idx>=0?seq_idx:0); set_status("Playing");} else set_status("Paused");
}
static void seq_add_ev(lv_event_t* e){(void)e; seq_add_cb();}
static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
(void)data;
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
c->app=app; g=c;
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
lv_obj_t* root=lv_obj_create(parent);
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
lv_obj_set_style_border_width(root,0,0);
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
lv_obj_set_scroll_dir(root, LV_DIR_VER);
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
for(int i=0;i<4;i++){
lv_obj_t* b=lv_btn_create(brow); lv_obj_set_size(b,56,18);
lv_obj_set_style_bg_color(b,lv_color_hex(btns[i].col),0); lv_obj_set_style_radius(b,8,0);
lv_obj_set_pos(b,i*62+2,0);
lv_obj_t* l=lv_label_create(b); lv_label_set_text(l,btns[i].t);
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(l,lv_color_hex(0x4A356A),0); lv_obj_center(l);
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
}
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
lv_obj_t* grid=lv_obj_create(root);
lv_obj_set_size(grid,LV_PCT(100),196);
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
for(int i=0;i<NUM_JOINTS;i++){
int col=i%3, row=i/3;
lv_obj_t* card=lv_obj_create(grid);
lv_obj_set_size(card,102,96);
lv_obj_set_pos(card,col*104+2,row*98);
lv_obj_set_style_bg_color(card,lv_color_hex(joints[i].bg),0);
lv_obj_set_style_bg_opa(card,LV_OPA_90,0);
lv_obj_set_style_radius(card,12,0);
lv_obj_set_style_border_width(card,0,0);
lv_obj_set_style_pad_all(card,3,0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* name=lv_label_create(card);
lv_label_set_text(name,joints[i].cute);
lv_obj_set_style_text_font(name,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(name,lv_color_hex(0x4A356A),0); lv_obj_set_pos(name,2,0);
c->vals[i]=lv_label_create(card);
char vb[8]; snprintf(vb,sizeof(vb),"%d",joints[i].value);
lv_label_set_text(c->vals[i],vb);
lv_obj_set_style_text_font(c->vals[i],lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->vals[i],lv_color_hex(0xC04060),0); lv_obj_set_pos(c->vals[i],40,0);
lv_obj_t* rlbl=lv_label_create(card);
char rb[12]; snprintf(rb,sizeof(rb),"%d-%d",joints[i].rmin,joints[i].rmax);
lv_label_set_text(rlbl,rb);
lv_obj_set_style_text_font(rlbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(rlbl,lv_color_hex(0xA090A0),0); lv_obj_set_pos(rlbl,58,0);
lv_obj_t* sl=lv_slider_create(card);
c->sliders[i]=sl;
lv_obj_set_size(sl,22,72);
lv_obj_set_pos(sl,40,20);
lv_slider_set_range(sl,joints[i].rmin,joints[i].rmax);
lv_slider_set_value(sl,joints[i].value,LV_ANIM_OFF);
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_MAIN);
lv_obj_set_style_bg_opa(sl,LV_OPA_80,LV_PART_MAIN);
lv_obj_set_style_radius(sl,10,LV_PART_MAIN);
lv_obj_set_style_bg_color(sl,lv_color_hex(joints[i].accent),LV_PART_INDICATOR);
lv_obj_set_style_radius(sl,10,LV_PART_INDICATOR);
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
lv_obj_set_style_border_color(sl,lv_color_hex(joints[i].accent),LV_PART_KNOB);
lv_obj_set_style_border_width(sl,2,LV_PART_KNOB);
lv_obj_set_style_radius(sl,12,LV_PART_KNOB);
lv_obj_set_style_pad_all(sl,3,LV_PART_KNOB);
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
}
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
visual: colored blocks, no info, current highlighted, larger buttons */
lv_obj_t* seqbar=lv_obj_create(root);
lv_obj_set_size(seqbar,316,96);
lv_obj_set_pos(seqbar,2,232);
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
lv_obj_set_style_radius(seqbar,12,0);
lv_obj_set_style_border_width(seqbar,1,0);
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
lv_obj_set_style_pad_all(seqbar,4,0);
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
for(int i=0;i<5;i++){
lv_obj_t* b=lv_btn_create(seqbar);
lv_obj_set_size(b,(i==2)?56:38,32);
lv_obj_set_style_bg_color(b,lv_color_hex(sbtns[i].col),0);
lv_obj_set_style_radius(b,8,0);
int xs[5]={64,110,158,220,260};
lv_obj_set_pos(b,xs[i],0);
lv_obj_t* l=lv_label_create(b); if(sbtns[i].play) c->play_label=l;
lv_label_set_text(l,sbtns[i].t);
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
}
uint32_t blk_cols[16]={
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
};
for(int i=0;i<SEQ_MAX;i++){
lv_obj_t* blk=lv_btn_create(seqbar);
lv_obj_set_size(blk,22,22);
lv_obj_set_pos(blk,(i%8)*24+2,(i/8)*24+38);
lv_obj_set_style_bg_color(blk,lv_color_hex(blk_cols[i]),0);
lv_obj_set_style_radius(blk,6,0);
lv_obj_set_style_border_width(blk,0,0);
lv_obj_set_style_bg_opa(blk,LV_OPA_30,0);
c->blocks[i]=blk;
lv_obj_add_event_cb(blk,block_click_cb,LV_EVENT_CLICKED,(void*)(intptr_t)i);
}
update_seq_ui();
c->poll=lv_timer_create(poll_cb,100,NULL);
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
lv_timer_create(init_cb,600,NULL);
}
static void onHide(AppHandle app,void* data){
(void)app;(void)data;
if(g){
if(g->poll) lv_timer_delete(g->poll);
if(g->seq_timer) lv_timer_delete(g->seq_timer);
free(g); g=NULL;
}
seq_playing=false;
}
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
-13
View File
@@ -1,13 +0,0 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.robotarm
versionName=0.1.0
versionCode=1
name=Robot Arm
description=Cute controller for MCP robot arm
+10 -7
View File
@@ -1,7 +1,10 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.serialconsole sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=Serial Console id=one.tactility.serialconsole
versionName=0.4.0
versionCode=4
name=Serial Console
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.snake sdk=0.7.0-dev
app.version.name=0.8.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=8 [app]
app.name=Snake id=one.tactility.snake
app.description=Classic Snake game versionName=0.5.0
versionCode=5
name=Snake
description=Classic Snake game
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.tamatac sdk=0.7.0-dev
app.version.name=0.4.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=4 [app]
app.name=TamaTac id=one.tactility.tamatac
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. versionName=0.1.0
versionCode=1
name=TamaTac
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.todolist sdk=0.7.0-dev
app.version.name=0.5.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=5 [app]
app.name=Todo List id=one.tactility.todolist
app.description=Simple task list manager versionName=0.2.0
versionCode=2
name=Todo List
description=Simple task list manager
+11 -8
View File
@@ -1,8 +1,11 @@
manifest.version=0.2 [manifest]
target.sdk=0.8.0-dev version=0.1
target.platforms=esp32,esp32s3,esp32c6,esp32p4 [target]
app.id=one.tactility.twoeleven sdk=0.7.0-dev
app.version.name=0.7.0 platforms=esp32,esp32s3,esp32c6,esp32p4
app.version.code=7 [app]
app.name=2048 id=one.tactility.twoeleven
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). 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).
-70
View File
@@ -1,70 +0,0 @@
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
)
+39 -54
View File
@@ -1,23 +1,14 @@
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):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
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
@@ -71,49 +62,51 @@ 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.version.code"] version_code = manifest["app"]["versionCode"]
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 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))
def get_git_commit_hash():
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
def manifest_config_to_flat_json(manifest): def manifest_config_to_flat_json(manifest):
"""Convert a flat (V2) manifest dict into a flat JSON-like dict. """Convert a ConfigParser manifest into a flat JSON-like dict.
Expected keys: Expected sections/keys (case-insensitive for keys):
app.id -> appId - [app]
app.version.name -> appVersionName id -> appId
app.version.code -> appVersionCode (int) versionName -> appVersionName
app.name -> appName versionCode -> appVersionCode (int)
app.description -> appDescription (optional; default "") name -> appName
target.sdk -> targetSdk description -> appDescription (optional; default "")
target.platforms -> targetPlatforms (comma-separated list) - [target]
sdk -> targetSdk
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 = manifest.get("app.id", "") app_id = get_opt("app", "id", "")
app_version_name = manifest.get("app.version.name", "") app_version_name = get_opt("app", "versionName", "")
app_version_code_raw = manifest.get("app.version.code", "0") app_version_code_raw = get_opt("app", "versionCode", "0")
app_name = manifest.get("app.name", "") app_name = get_opt("app", "name", "")
app_description = manifest.get("app.description", "") or "" app_description = get_opt("app", "description", "") or ""
# Coerce version code to int safely # Coerce version code to int safely
try: try:
@@ -121,8 +114,8 @@ def manifest_config_to_flat_json(manifest):
except Exception: except Exception:
app_version_code = 0 app_version_code = 0
target_sdk = manifest.get("target.sdk", "") target_sdk = get_opt("target", "sdk", "")
platforms_raw = manifest.get("target.platforms", "") platforms_raw = get_opt("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)
@@ -149,23 +142,15 @@ 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]}")
+36 -23
View File
@@ -1,3 +1,4 @@
import configparser
import json import json
import os import os
import re import re
@@ -12,7 +13,7 @@ import tarfile
from urllib.parse import urlparse from urllib.parse import urlparse
ttbuild_path = ".tactility" ttbuild_path = ".tactility"
ttbuild_version = "4.1.0" ttbuild_version = "3.5.1"
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
@@ -105,17 +106,9 @@ 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):
properties = {} config = configparser.RawConfigParser()
with open(path, "r") as file: config.read(path)
for line in file: return config
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
@@ -192,7 +185,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}/sdk/{sdkconfig_filename}", target_path): if not download_file(f"{ttbuild_cdn}/{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
@@ -238,12 +231,32 @@ def read_manifest():
return read_properties_file("manifest.properties") return read_properties_file("manifest.properties")
def validate_manifest(manifest): def validate_manifest(manifest):
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"): # [manifest]
if key not in manifest: if not "manifest" in manifest:
exit_with_error(f"Invalid manifest format: {key} not found") 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")
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):
@@ -252,7 +265,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]
@@ -499,7 +512,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)
@@ -508,7 +521,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")
@@ -557,7 +570,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}
@@ -601,7 +614,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}
@@ -657,7 +670,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")