diff --git a/.github/workflows/release-firmware.yml b/.github/workflows/release-firmware.yml index 87918f8c..408d5cf0 100644 --- a/.github/workflows/release-firmware.yml +++ b/.github/workflows/release-firmware.yml @@ -134,7 +134,7 @@ jobs: # Fetch all published releases and download their firmware assets # so versioned firmware is available same-origin on GitHub Pages. # With keep_files: true, each release only needs to be downloaded once. - for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[] | select(.draft == false and .prerelease == false) | select(any(.assets[]; .name == "pyxis-release.json")) | .tag_name'); do + for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[] | select(.draft == false) | select(any(.assets[]; .name == "pyxis-release.json")) | .tag_name'); do dir="docs/flasher/firmware/releases/${tag}" # Skip if we already have this version's firmware (e.g., current tagged build) if python tools/validate_pyxis_web_release.py --directory "${dir}" --version "${tag}"; then diff --git a/docs/flasher/index.html b/docs/flasher/index.html index 74271253..5f153fda 100644 --- a/docs/flasher/index.html +++ b/docs/flasher/index.html @@ -189,19 +189,29 @@ .version-select-group select { width: 100%; padding: 0.625rem 1rem; - background: rgba(0,0,0,0.3); + background-color: #111827; color: var(--text); border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; font-family: inherit; font-size: 1rem; cursor: pointer; + color-scheme: dark; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2394a3b8' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 1rem center; } + .version-select-group select option { + background-color: #111827; + color: var(--text); + } + + .version-select-group select option:disabled { + color: var(--muted); + } + .version-select-group select:focus { outline: none; border-color: var(--primary); @@ -321,7 +331,6 @@
  • Download a compatible MUI map ZIP from Oxed's Map Tile Downloader. - Choose OSM Bright, Dark Matter, Positron, or Toner.
  • Turn the T-Deck off, remove its microSD card, and insert the card into your computer.
  • Choose the downloaded ZIP below, enter a map name and pack ID, and wait for local validation.
  • @@ -424,7 +433,7 @@ const CONNECT_TIMEOUT_MS = 15000; const EXPECTED_CHIP = 'ESP32-S3'; - // Fail closed until the GitHub API supplies a published, non-prerelease version. + // Fail closed until the GitHub API supplies a published release with audited metadata. let firmwarePathPrefix = null; let selectedReleaseMetadata = null; let customFirmwareBytes = null; @@ -835,9 +844,10 @@ if (!resp.ok) throw new Error(`GitHub API returned HTTP ${resp.status}`); const releases = await resp.json(); let publishedCount = 0; + let defaultStableOption = null; for (const release of releases) { - if (release.draft || release.prerelease) continue; + if (release.draft) continue; const assetNames = release.assets.map(a => a.name); if (!assetNames.includes(RELEASE_METADATA_ASSET)) continue; if (!assetNames.includes('firmware.bin')) continue; @@ -852,12 +862,18 @@ const option = document.createElement('option'); const hasAll = REQUIRED_FULL_ASSETS.every(f => assetNames.includes(f)); + const releaseChannel = release.prerelease ? ' — Pre-release' : ''; option.value = release.tag_name; - option.textContent = release.tag_name + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : ''); + option.textContent = release.tag_name + + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : '') + + releaseChannel; option.dataset.downloadUrl = prefix; option.dataset.hasFullAssets = hasAll ? 'true' : 'false'; option.releaseMetadata = metadata; versionSelect.appendChild(option); + if (!release.prerelease && defaultStableOption === null) { + defaultStableOption = option; + } publishedCount++; } @@ -872,8 +888,8 @@ versionSelect.options[0].textContent = 'Select a published release...'; versionSelect.disabled = false; - if (customFirmwareSelectionToken === 0) { - versionSelect.selectedIndex = 1; + if (customFirmwareSelectionToken === 0 && defaultStableOption !== null) { + versionSelect.value = defaultStableOption.value; selectPublishedRelease(); } } catch (e) { @@ -1002,7 +1018,7 @@ flashBtn.addEventListener('click', flash); // Initialize with no flashable target; loadVersions enables flashing only - // after selecting a published, non-prerelease GitHub release. + // after selecting a published GitHub release with audited metadata. loadVersions(); diff --git a/lib/storage/LittleFSInitializationPolicy.h b/lib/storage/LittleFSInitializationPolicy.h new file mode 100644 index 00000000..502cf19c --- /dev/null +++ b/lib/storage/LittleFSInitializationPolicy.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace Storage { + +constexpr std::size_t LITTLEFS_BLANK_SCAN_CHUNK_SIZE = 4096; + +template +bool mount_or_initialize_erased_littlefs( + Mount mount, + ResolvePartition resolve_partition, + ReadPartition read_partition, + FormatAndMount format_and_mount) { + if (mount()) return true; + + std::size_t partition_size = 0; + if (!resolve_partition(partition_size) || partition_size == 0) return false; + + uint8_t buffer[LITTLEFS_BLANK_SCAN_CHUNK_SIZE]; + std::size_t offset = 0; + while (offset < partition_size) { + const std::size_t remaining = partition_size - offset; + const std::size_t chunk = + remaining < sizeof(buffer) ? remaining : sizeof(buffer); + if (!read_partition(offset, buffer, chunk)) return false; + for (std::size_t i = 0; i < chunk; ++i) { + if (buffer[i] != 0xFF) return false; + } + offset += chunk; + } + + return format_and_mount(); +} + +} // namespace Storage diff --git a/src/main.cpp b/src/main.cpp index 96c51e71..639766df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,8 @@ #include // placement new #include +#include "storage/LittleFSInitializationPolicy.h" + // Reticulum #include #include @@ -964,6 +966,8 @@ static void pump_ntp_sync_if_pending() { } } +static constexpr const char* LITTLEFS_PARTITION_LABEL = "spiffs"; + void setup_hardware() { INFO("\n=== Hardware Initialization ==="); @@ -981,7 +985,44 @@ void setup_hardware() { // Pyxis's lib/universal_filesystem/ is now dead code on this build path and // can be deleted once the graft lands. static microStore::Adapters::LittleFSFileSystem fs("/littlefs"); - persistent_storage_ready = fs.init(false); + + // Mount non-destructively first: never format on a mount failure by itself, + // because a corrupt filesystem holds user conversations that must be preserved. + // + // An erased or uninitialized LittleFS partition is all 0xFF. Its mount fails, + // but formatting is safe because the partition contains no programmed bytes. + // Any nonblank or unreadable partition remains untouched and falls through to + // recovery mode. The policy is portable so every destructive branch is covered + // by host tests while these callbacks retain the real ESP32 operations. + const esp_partition_t* littlefs_partition = nullptr; + persistent_storage_ready = Storage::mount_or_initialize_erased_littlefs( + []() { return fs.init(false); }, + [&littlefs_partition](size_t& size) { + littlefs_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_DATA_SPIFFS, + LITTLEFS_PARTITION_LABEL); + if (!littlefs_partition) { + ERROR("Unable to locate LittleFS partition for blank check"); + return false; + } + size = littlefs_partition->size; + return true; + }, + [&littlefs_partition](size_t offset, uint8_t* buffer, size_t size) { + if (esp_partition_read(littlefs_partition, offset, buffer, size) != ESP_OK) { + ERROR("Failed to read LittleFS partition for blank check"); + return false; + } + return true; + }, + []() { + // Use LittleFS.begin() directly because microStore's adapter returns + // immediately when its non-destructive mount fails. + INFO("Blank LittleFS partition; formatting once"); + return LittleFS.begin( + true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL); + }); location_filesystem_available = persistent_storage_ready; if (!persistent_storage_ready) { ERROR("FileSystem mount failed; preserving persistent data"); diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py index d70938b0..2d628f46 100644 --- a/tests/build_scripts/test_message_persistence_contract.py +++ b/tests/build_scripts/test_message_persistence_contract.py @@ -147,7 +147,14 @@ def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum(): setup = function_body(source, "void setup()", "void loop()") loop = source[source.index("void loop()"):] - assert "persistent_storage_ready = fs.init(false);" in source + assert "Storage::mount_or_initialize_erased_littlefs(" in source + assert "[]() { return fs.init(false); }" in source + assert """littlefs_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_DATA_SPIFFS, + LITTLEFS_PARTITION_LABEL);""" in source + assert 'static constexpr const char* LITTLEFS_PARTITION_LABEL = "spiffs";' in source + assert 'true, "/littlefs", 10, LITTLEFS_PARTITION_LABEL' in source assert "Persistent storage unavailable" in source assert "USB serial recovery remains available." in source assert setup.index("if (!persistent_storage_ready)") < setup.index("setup_reticulum();") diff --git a/tests/build_scripts/test_patch_littlefs_paths.py b/tests/build_scripts/test_patch_littlefs_paths.py index 2009d714..e6e6d8af 100644 --- a/tests/build_scripts/test_patch_littlefs_paths.py +++ b/tests/build_scripts/test_patch_littlefs_paths.py @@ -132,11 +132,23 @@ def test_persistent_partitions_do_not_overlap_app_slots(): if not line or line.startswith("#"): continue fields = [field.strip() for field in line.split(",")] - rows.append((fields[0], int(fields[3], 0), int(fields[4], 0))) + rows.append( + ( + fields[0], + fields[1], + fields[2], + int(fields[3], 0), + int(fields[4], 0), + ) + ) - by_name = {name: (offset, offset + size) for name, offset, size in rows} - persistent = [by_name["nvs"], by_name["spiffs"]] - applications = [by_name["app0"], by_name["app1"]] + by_name = { + name: (partition_type, subtype, offset, offset + size) + for name, partition_type, subtype, offset, size in rows + } + assert by_name["spiffs"] == ("data", "spiffs", 0x610000, 0x7F0000) + persistent = [by_name["nvs"][2:], by_name["spiffs"][2:]] + applications = [by_name["app0"][2:], by_name["app1"][2:]] for data_start, data_end in persistent: for app_start, app_end in applications: diff --git a/tests/build_scripts/test_web_flasher_release_safety.py b/tests/build_scripts/test_web_flasher_release_safety.py index b22cf090..82049c5d 100644 --- a/tests/build_scripts/test_web_flasher_release_safety.py +++ b/tests/build_scripts/test_web_flasher_release_safety.py @@ -20,13 +20,37 @@ def test_flasher_starts_disabled_until_a_published_release_is_selected(): assert '