#include "EspNowBridge.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static constexpr auto* TAG = "EspNowBridge"; static constexpr size_t CHUNK_SIZE = 1500; static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000; static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192; AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); } AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); } // Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of // 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by // an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by // ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader, // just enough to locate the app partition inside a merged/factory bin. static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000; static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32) static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50 static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB; static constexpr uint8_t PARTITION_TYPE_APP = 0x00; static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00; static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10; struct __attribute__((packed)) PartitionEntry { uint16_t magic; uint8_t type; uint8_t subtype; uint32_t offset; uint32_t size; char name[16]; uint32_t flags; }; static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes"); /** * Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for * the app partition to flash: prefers "factory" if present, otherwise the first OTA slot * (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains. * @return true if an app partition was found, with appOffset/appSize set to its location * within the file (these are the same as the absolute flash offsets the merged bin preserves). */ static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) { if (fseek(file, static_cast(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) { return false; } bool foundFactory = false; bool foundOta0 = false; size_t factoryOffset = 0, factorySize = 0; size_t ota0Offset = 0, ota0Size = 0; for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) { PartitionEntry entry; if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) { break; } if (entry.magic == PARTITION_MD5_MAGIC) { break; } if (entry.magic != PARTITION_ENTRY_MAGIC) { break; } if (entry.type == PARTITION_TYPE_APP) { if (entry.subtype == PARTITION_SUBTYPE_FACTORY) { foundFactory = true; factoryOffset = entry.offset; factorySize = entry.size; } else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) { foundOta0 = true; ota0Offset = entry.offset; ota0Size = entry.size; } } } if (foundFactory) { appOffset = factoryOffset; appSize = factorySize; return true; } if (foundOta0) { appOffset = ota0Offset; appSize = ota0Size; return true; } return false; } /** * Validates the app image at the given file offset and extracts its version string. The actual * transfer size used for the OTA loop is just the real remaining file size from appOffset (see * performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/ * hash padding drifts a bit short of the real length, so we just use the file size instead. */ static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) { esp_image_header_t imageHeader; if (fseek(file, static_cast(appOffset), SEEK_SET) != 0 || fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) { if (errorOut != nullptr) { *errorOut = "Failed to read image header"; } return false; } if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) { if (errorOut != nullptr) { *errorOut = "Selected file is not a valid firmware image (bad magic)"; } return false; } // Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before // streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end() // would eventually catch this too, but only after the entire transfer already completed. if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) { if (errorOut != nullptr) { char buf[96]; snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6", (unsigned)imageHeader.chip_id); *errorOut = buf; } return false; } esp_image_segment_header_t segmentHeader; size_t firstSegmentOffset = appOffset + sizeof(imageHeader); if (fseek(file, static_cast(firstSegmentOffset), SEEK_SET) != 0 || fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) { if (errorOut != nullptr) { *errorOut = "Failed to read first segment header"; } return false; } esp_app_desc_t appDesc; size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader); if (fseek(file, static_cast(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) { strncpy(versionOut, appDesc.version, versionOutLen - 1); versionOut[versionOutLen - 1] = '\0'; } else { strncpy(versionOut, "unknown", versionOutLen - 1); versionOut[versionOutLen - 1] = '\0'; } return true; } static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) { FirmwareInfo info = {}; if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) { return false; } if (info.name[0] != '\0') { snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)", (unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name); } else { snprintf(versionOut, versionOutLen, "%u.%u.%u", (unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch); } return true; } /** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves * reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */ static bool activateSupported(uint32_t major, uint32_t minor) { return (major > 2) || (major == 2 && minor > 5); } std::atomic EspNowBridge::liveInstance_{nullptr}; void EspNowBridge::onCreate(AppHandle app) { appHandle_ = app; taskDoneSemaphore_ = xSemaphoreCreateBinary(); liveInstance_ = this; } void EspNowBridge::onDestroy(AppHandle /*app*/) { // Clear liveInstance_ first so any task still running bails out at its next liveInstance_ // check instead of continuing to touch this instance's members. liveInstance_ = nullptr; // Wait for any outstanding background task (OTA update, transport-wait) to actually finish - // the app framework frees this instance shortly after onDestroy() returns, so a task that // outlives it would dereference freed memory. while (outstandingTasks_.load() > 0) { if (taskDoneSemaphore_ != nullptr) { xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000)); } } if (taskDoneSemaphore_ != nullptr) { vSemaphoreDelete(taskDoneSemaphore_); taskDoneSemaphore_ = nullptr; } } void EspNowBridge::refreshCurrentVersion() { char versionStr[32]; if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) { lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr); } else { lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)"); } } bool EspNowBridge::isWifiRadioOn() { if (wifiDevice_ == nullptr) { return false; } WifiRadioState radioState = WIFI_RADIO_STATE_OFF; if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) { return false; } // ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge // just needs the radio + esp_hosted transport up, not a completed AP connection. return radioState == WIFI_RADIO_STATE_ON; } void EspNowBridge::refreshWifiPrompt() { if (isWifiRadioOn()) { lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); setUpdateButtonsDisabled(false); } else { lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); setUpdateButtonsDisabled(true); } } void EspNowBridge::setUpdateButtonsDisabled(bool disabled) { if (disabled) { lv_obj_add_state(updateButton_, LV_STATE_DISABLED); lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED); } else { lv_obj_clear_state(updateButton_, LV_STATE_DISABLED); lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED); } } void EspNowBridge::setStatus(const std::string& text) { lv_label_set_text(statusLabel_, text.c_str()); } void EspNowBridge::setProgress(int percent) { lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF); } namespace { struct UiDispatchPayload { EspNowBridge* instance; void (*work)(EspNowBridge&, void*); void* context; void (*freeContext)(void*); }; } void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) { auto* payload = new UiDispatchPayload{this, work, context, freeContext}; // lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a // non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi() // repeatedly during the transfer, and without this lock most of those calls were silently // racing LVGL's own task and getting lost (only the very last status update, right before // esp_restart(), happened to land - everything else stayed stuck at "Waiting for // co-processor link..."). bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME); if (!locked) { // Without the lock, lv_async_call() itself would be touching LVGL's internal timer list // unguarded - and if it happened to still enqueue successfully, the callback below would // later fire against `payload` after we've already freed it here. Drop the update instead. if (freeContext != nullptr) { freeContext(context); } delete payload; return; } lv_result_t result = lv_async_call([](void* userData) { auto* payload = static_cast(userData); if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) { payload->work(*payload->instance, payload->context); } if (payload->freeContext != nullptr) { payload->freeContext(payload->context); } delete payload; }, payload); tt_lvgl_unlock(); if (result != LV_RESULT_OK) { if (freeContext != nullptr) { freeContext(context); } delete payload; } } namespace { void workSetStatus(EspNowBridge& app, void* context) { app.setStatus(*static_cast(context)); } void freeString(void* context) { delete static_cast(context); } void workSetProgress(EspNowBridge& app, void* context) { app.setProgress(*static_cast(context)); } void freeInt(void* context) { delete static_cast(context); } } // namespace void EspNowBridge::performUpdate(const std::string& filePath) { dispatchToUi([](EspNowBridge& app, void*) { app.setUpdateButtonsDisabled(true); app.setProgress(0); app.setStatus("Waiting for co-processor link..."); }, nullptr, nullptr); if (firmwareOps_ == nullptr) { dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("This WiFi device has no updatable co-processor"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) { dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Co-processor link not available - update cancelled"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } FILE* file = fopen(filePath.c_str(), "rb"); if (file == nullptr) { dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to open selected file"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } fseek(file, 0, SEEK_END); long fileSizeSigned = ftell(file); if (fileSizeSigned <= 0) { fclose(file); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to determine file size"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } size_t fileSize = static_cast(fileSizeSigned); // Support both a plain app image (starting with the app image header at offset 0) and a // merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether // a valid partition table is found at PARTITION_TABLE_OFFSET. size_t appOffset = 0; size_t partitionSize = 0; bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize); if (isMergedBin && appOffset >= fileSize) { fclose(file); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } char newVersion[32]; std::string parseError; if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) { fclose(file); dispatchToUi(workSetStatus, new std::string(parseError), freeString); dispatchToUi([](EspNowBridge& app, void*) { app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } // Merged bins pad the app partition to its declared size; a plain app image is exactly as // long as the app itself. Transfer whichever is smaller. size_t remainingInFile = fileSize - appOffset; size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile; std::string versionStr(newVersion); { char buf[64]; snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str()); dispatchToUi(workSetStatus, new std::string(buf), freeString); } // Held on the app instance (not a local variable) so it outlives this function - see // heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts // (moot, since esp_restart() doesn't return) or if the update fails early below. heldAutoScanPauseGuard_.emplace(); FirmwareUpdateRequest updateRequest = {}; updateRequest.image_size = firmwareSize; FirmwareUpdateHandle* handle = nullptr; if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) { fclose(file); heldAutoScanPauseGuard_.reset(); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to start OTA on co-processor"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } if (fseek(file, static_cast(appOffset), SEEK_SET) != 0) { fclose(file); firmwareOps_->abort(handle); heldAutoScanPauseGuard_.reset(); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to seek to firmware start"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } uint8_t chunk[CHUNK_SIZE]; size_t sent = 0; bool writeFailed = false; int lastReportedPercent = -1; while (sent < firmwareSize) { size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent); size_t actuallyRead = fread(chunk, 1, toRead, file); if (actuallyRead != toRead) { LOG_E(TAG, "Failed to read file at offset %zu", sent); writeFailed = true; break; } if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) { LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent); writeFailed = true; break; } // Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no // backoff before giving up and restarting the host. Back-to-back chunk writes with zero // gap were observed to saturate the bus enough to trigger a genuine SDIO timeout // mid-transfer, not just around the post-activate reboot. vTaskDelay(pdMS_TO_TICKS(5)); sent += actuallyRead; // Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent // display-bus activity during the transfer was implicated in SDIO transport crashes // under sustained OTA write load. int percent = (int)((sent * 100) / firmwareSize); if (percent != lastReportedPercent) { dispatchToUi(workSetProgress, new int(percent), freeInt); lastReportedPercent = percent; } } fclose(file); if (writeFailed) { firmwareOps_->abort(handle); heldAutoScanPauseGuard_.reset(); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Update failed while transferring firmware"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } if (firmwareOps_->finish(handle) != ERROR_NONE) { heldAutoScanPauseGuard_.reset(); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to finalize OTA on co-processor"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } // Check the *currently running* (pre-update) slave version - the new image isn't running // yet - and skip straight to the required host restart for older slaves. FirmwareInfo runningInfo = {}; bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE && activateSupported(runningInfo.fw_major, runningInfo.fw_minor); if (canActivate) { if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) { heldAutoScanPauseGuard_.reset(); dispatchToUi([](EspNowBridge& app, void*) { app.setStatus("Failed to activate new firmware - co-processor still running old firmware"); app.setUpdateButtonsDisabled(false); }, nullptr, nullptr); return; } } // heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host // restarts itself immediately below, and there's no safe window to resume normal WiFi // activity before that. { char buf[80]; if (canActivate) { snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str()); } else { snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str()); } dispatchToUi(workSetStatus, new std::string(buf), freeString); } // Give the status message above a moment to actually be seen before the restart cuts the // display, then restart. vTaskDelay(pdMS_TO_TICKS(1500)); esp_restart(); } void EspNowBridge::updateTaskEntry(void* arg) { auto* self = static_cast(arg); self->performUpdate(self->pendingUpdateFilePath_); self->updateTask_ = nullptr; if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { xSemaphoreGive(self->taskDoneSemaphore_); } vTaskDelete(nullptr); } void EspNowBridge::startUpdateTask(const std::string& filePath) { if (updateTask_ != nullptr) { return; } pendingUpdateFilePath_ = filePath; outstandingTasks_.fetch_add(1); if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) { outstandingTasks_.fetch_sub(1); } } void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) { auto* self = liveInstance_.load(); if (self == nullptr || !self->isWifiRadioOn()) { return; } self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file(); } // Name of the slave bridge firmware bundled in this app's assets/ folder // lets users flash the known-good bridge firmware without needing to source/copy a // .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays // available too, for factory-image downgrades or custom builds. static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin"; void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) { auto* self = liveInstance_.load(); if (self == nullptr || !self->isWifiRadioOn()) { return; } char assetPath[256] = {}; size_t assetPathSize = sizeof(assetPath); tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize); if (assetPath[0] == '\0') { LOG_E(TAG, "Failed to resolve bundled firmware asset path"); return; } self->startUpdateTask(assetPath); } void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) { auto* self = liveInstance_.load(); if (self == nullptr || self->wifiDevice_ == nullptr) { return; } device_start(self->wifiDevice_); // start_device() allocates a fresh driver context (Platforms/platform-esp32's // esp32_wifi.cpp), which wipes any event callback registered before the device was started - // re-register now that it's actually running. Also refresh once directly rather than relying // solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the // co-processor firmware version below isn't available yet. wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent); self->refreshWifiPrompt(); self->refreshCurrentVersion(); // The co-processor RPC transport isn't up the instant device_start() returns - it comes up // asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after // enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport // actually comes up (WifiEvent only covers radio/station state, not transport readiness), so // wait for it explicitly on a background task and refresh once it's ready. if (self->firmwareOps_ != nullptr) { self->outstandingTasks_.fetch_add(1); if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { self->outstandingTasks_.fetch_sub(1); } } } void EspNowBridge::waitForTransportTaskEntry(void* arg) { auto* self = static_cast(arg); constexpr uint32_t WAIT_TIMEOUT_MS = 10000; // liveInstance_ must be checked before touching any member of self - if onDestroy() already // ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a // use-after-free even just to read the pointer. if (liveInstance_.load() == self && self->firmwareOps_ != nullptr && self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS) && liveInstance_.load() == self) { self->dispatchToUi([](EspNowBridge& app, void*) { app.refreshCurrentVersion(); }, nullptr, nullptr); } if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { xSemaphoreGive(self->taskDoneSemaphore_); } vTaskDelete(nullptr); } void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) { auto* self = static_cast(callbackContext); if (liveInstance_.load() != self) { return; } self->dispatchToUi([](EspNowBridge& app, void*) { app.refreshWifiPrompt(); app.refreshCurrentVersion(); }, nullptr, nullptr); } void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) { isShown_ = true; lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); auto* wrapper = lv_obj_create(parent); lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT); lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_flex_grow(wrapper, 1); currentVersionLabel_ = lv_label_create(wrapper); lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT); enableWifiButton_ = lv_button_create(wrapper); lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr); auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_); lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)"); lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT); updateBundledButton_ = lv_button_create(wrapper); lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr); auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_); lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware"); lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT); updateButton_ = lv_button_create(wrapper); lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr); auto* updateButtonLabel = lv_label_create(updateButton_); lv_label_set_text(updateButtonLabel, "Update from SD card..."); lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT); progressBar_ = lv_bar_create(wrapper); lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6)); lv_bar_set_range(progressBar_, 0, 100); lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF); statusLabel_ = lv_label_create(wrapper); lv_label_set_text(statusLabel_, "Ready"); wifiDevice_ = wifi_find_first_registered_device(); if (wifiDevice_ != nullptr) { wifi_add_event_callback(wifiDevice_, this, onWifiEvent); if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) { firmwareOps_ = nullptr; firmwareCtx_ = nullptr; } } refreshCurrentVersion(); refreshWifiPrompt(); // If an SD-card file was picked before this onShow() ran (FileSelection tears down and // rebuilds this app's whole widget tree), perform the update now that widgets are valid // again. The bundled-firmware button doesn't go through this path - it calls // startUpdateTask() directly since there's no separate app launch/result round trip involved. if (!pendingUpdateFilePath_.empty()) { std::string path = std::move(pendingUpdateFilePath_); pendingUpdateFilePath_.clear(); startUpdateTask(path); } } void EspNowBridge::onHide(AppHandle /*app*/) { isShown_ = false; if (wifiDevice_ != nullptr) { wifi_remove_event_callback(wifiDevice_, onWifiEvent); wifiDevice_ = nullptr; } } void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) { if (launchId != pickFileLaunchId_) { return; } pickFileLaunchId_ = 0; if (result == APP_RESULT_OK && resultData != nullptr) { char pathBuf[256] = {}; if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) { pendingUpdateFilePath_ = pathBuf; } } }