#include "AppLoader.h" #include #include #include #include #include "Battery.h" namespace CrossPoint { std::vector AppLoader::scanApps() { std::vector apps; if (!isSDReady()) { Serial.printf("[%lu] [AppLoader] SD card not ready\n", millis()); return apps; } FsFile appsDir = SdMan.open(APPS_BASE_PATH, O_RDONLY); if (!appsDir || !appsDir.isDirectory()) { Serial.printf("[%lu] [AppLoader] Apps directory not found: %s\n", millis(), APPS_BASE_PATH); if (appsDir) appsDir.close(); return apps; } char name[128]; FsFile entry = appsDir.openNextFile(); while (entry) { if (entry.isDirectory()) { entry.getName(name, sizeof(name)); String appPath = APPS_BASE_PATH; if (!appPath.endsWith("/")) { appPath += "/"; } appPath += name; String manifestPath = buildManifestPath(appPath); AppManifest manifest = parseManifest(manifestPath); if (!manifest.name.isEmpty()) { apps.emplace_back(manifest, appPath); Serial.printf("[%lu] [AppLoader] Found app: %s\n", millis(), manifest.name.c_str()); } else { Serial.printf("[%lu] [AppLoader] Skipping directory (no valid manifest): %s\n", millis(), name); } } entry.close(); entry = appsDir.openNextFile(); } appsDir.close(); Serial.printf("[%lu] [AppLoader] Found %u app(s)\n", millis(), apps.size()); return apps; } AppManifest AppLoader::parseManifest(const String& path) { AppManifest manifest; if (!isSDReady()) { Serial.printf("[%lu] [AppLoader] SD card not ready, cannot parse manifest\n", millis()); return manifest; } if (!SdMan.exists(path.c_str())) { Serial.printf("[%lu] [AppLoader] Manifest file not found: %s\n", millis(), path.c_str()); return manifest; } FsFile file = SdMan.open(path.c_str(), O_RDONLY); if (!file) { Serial.printf("[%lu] [AppLoader] Failed to open manifest file: %s\n", millis(), path.c_str()); return manifest; } const size_t fileSize = file.size(); if (fileSize == 0) { Serial.printf("[%lu] [AppLoader] Manifest file is empty: %s\n", millis(), path.c_str()); file.close(); return manifest; } if (fileSize > MAX_MANIFEST_SIZE) { Serial.printf("[%lu] [AppLoader] Manifest file too large (%u bytes, max %u): %s\n", millis(), fileSize, MAX_MANIFEST_SIZE, path.c_str()); file.close(); return manifest; } std::unique_ptr buffer(new char[fileSize + 1]); const size_t bytesRead = file.read(buffer.get(), fileSize); buffer[bytesRead] = '\0'; file.close(); if (bytesRead != fileSize) { Serial.printf("[%lu] [AppLoader] Failed to read complete manifest file (read %u of %u bytes): %s\n", millis(), bytesRead, fileSize, path.c_str()); return manifest; } // Handle UTF-8 BOM if the manifest was created by an editor that writes it. const char* json = buffer.get(); if (bytesRead >= 3 && static_cast(json[0]) == 0xEF && static_cast(json[1]) == 0xBB && static_cast(json[2]) == 0xBF) { json += 3; } JsonDocument doc; const DeserializationError error = deserializeJson(doc, json); if (error) { Serial.printf("[%lu] [AppLoader] JSON parse error in %s: %s\n", millis(), path.c_str(), error.c_str()); return manifest; } if (doc["name"].is()) { manifest.name = doc["name"].as(); } else { Serial.printf("[%lu] [AppLoader] Missing or invalid 'name' field in: %s\n", millis(), path.c_str()); return manifest; } if (doc["version"].is()) { manifest.version = doc["version"].as(); } else { manifest.version = "1.0.0"; } if (doc["description"].is()) { manifest.description = doc["description"].as(); } else { manifest.description = ""; } if (doc["author"].is()) { manifest.author = doc["author"].as(); } else { manifest.author = "Unknown"; } if (doc["minFirmware"].is()) { manifest.minFirmware = doc["minFirmware"].as(); } else { manifest.minFirmware = "0.0.0"; } return manifest; } bool AppLoader::flashApp(const String& binPath, ProgressCallback callback) { if (!isSDReady()) { Serial.printf("[%lu] [AppLoader] SD card not ready, cannot flash app\n", millis()); return false; } const uint16_t batteryPercentage = battery.readPercentage(); if (batteryPercentage < 20) { Serial.printf("[%lu] [AppLoader] Battery: %u%% - TOO LOW\n", millis(), batteryPercentage); Serial.printf("[%lu] [AppLoader] Flash aborted: battery below 20%%\n", millis()); return false; } Serial.printf("[%lu] [AppLoader] Battery: %u%% - OK\n", millis(), batteryPercentage); if (!SdMan.exists(binPath.c_str())) { Serial.printf("[%lu] [AppLoader] App binary not found: %s\n", millis(), binPath.c_str()); return false; } FsFile file = SdMan.open(binPath.c_str(), O_RDONLY); if (!file) { Serial.printf("[%lu] [AppLoader] Failed to open app binary: %s\n", millis(), binPath.c_str()); return false; } const size_t fileSize = file.size(); if (fileSize == 0) { Serial.printf("[%lu] [AppLoader] App binary is empty: %s\n", millis(), binPath.c_str()); file.close(); return false; } uint8_t magicByte = 0; const size_t magicRead = file.read(&magicByte, 1); if (magicRead != 1 || magicByte != 0xE9) { Serial.printf("[%lu] [AppLoader] Invalid firmware magic byte: 0x%02X\n", millis(), magicByte); file.close(); return false; } file.close(); file = SdMan.open(binPath.c_str(), O_RDONLY); if (!file) { Serial.printf("[%lu] [AppLoader] Failed to reopen app binary: %s\n", millis(), binPath.c_str()); return false; } const esp_partition_t* running = esp_ota_get_running_partition(); if (!running) { Serial.printf("[%lu] [AppLoader] Failed to get running partition\n", millis()); file.close(); return false; } const esp_partition_t* target = esp_ota_get_next_update_partition(NULL); if (!target) { Serial.printf("[%lu] [AppLoader] No OTA partition available\n", millis()); file.close(); return false; } if (target->address == running->address) { Serial.printf("[%lu] [AppLoader] Target partition matches running partition, aborting\n", millis()); file.close(); return false; } if (fileSize >= target->size) { Serial.printf("[%lu] [AppLoader] Firmware too large (%u bytes, max %u)\n", millis(), fileSize, target->size); file.close(); return false; } Serial.printf("[%lu] [AppLoader] Flashing to partition: %s (offset: 0x%06X)\n", millis(), target->label, target->address); esp_ota_handle_t otaHandle = 0; esp_err_t err = esp_ota_begin(target, fileSize, &otaHandle); if (err != ESP_OK) { Serial.printf("[%lu] [AppLoader] OTA begin failed: %d\n", millis(), err); file.close(); return false; } if (callback) { callback(0, fileSize); } size_t totalWritten = 0; // Larger chunks reduce SD/OTA overhead significantly. // 32KB is a good balance on ESP32-C3: faster writes without blowing RAM. static constexpr size_t flashChunkSize = 32 * 1024; static uint8_t buffer[flashChunkSize]; size_t lastNotifiedPercent = 0; while (totalWritten < fileSize) { const size_t remaining = fileSize - totalWritten; const size_t toRead = remaining < flashChunkSize ? remaining : flashChunkSize; const size_t bytesRead = file.read(buffer, toRead); if (bytesRead == 0) { Serial.printf("[%lu] [AppLoader] Failed to read firmware data\n", millis()); esp_ota_end(otaHandle); file.close(); return false; } err = esp_ota_write(otaHandle, buffer, bytesRead); if (err != ESP_OK) { Serial.printf("[%lu] [AppLoader] OTA write failed at %u/%u bytes: %d\n", millis(), totalWritten, fileSize, err); esp_ota_end(otaHandle); file.close(); return false; } totalWritten += bytesRead; if (callback) { const size_t percent = (totalWritten * 100) / fileSize; // Throttle UI updates; each screen refresh is ~400ms. if (percent >= lastNotifiedPercent + 10 || percent == 100) { lastNotifiedPercent = percent; callback(totalWritten, fileSize); } } } file.close(); err = esp_ota_end(otaHandle); if (err != ESP_OK) { Serial.printf("[%lu] [AppLoader] OTA end failed: %d\n", millis(), err); return false; } err = esp_ota_set_boot_partition(target); if (err != ESP_OK) { Serial.printf("[%lu] [AppLoader] Failed to set boot partition: %d\n", millis(), err); return false; } Serial.printf("[%lu] [AppLoader] Flash complete. Rebooting...\n", millis()); esp_restart(); return true; } String AppLoader::buildManifestPath(const String& appDir) const { String path = appDir; if (!path.endsWith("/")) { path += "/"; } path += MANIFEST_FILENAME; return path; } bool AppLoader::isSDReady() const { return SdMan.ready(); } }