Merge origin/main into feat/map-style-picker

This commit is contained in:
torlando-agent[bot]
2026-08-12 18:22:33 +00:00
10 changed files with 304 additions and 18 deletions
+1 -1
View File
@@ -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
+24 -8
View File
@@ -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 @@
<li>
Download a compatible MUI map ZIP from
<a href="https://download.tiles.coalition.space/" target="_blank" rel="noopener noreferrer">Oxed's Map Tile Downloader</a>.
Choose OSM Bright, Dark Matter, Positron, or Toner.
</li>
<li>Turn the T-Deck off, remove its microSD card, and insert the card into your computer.</li>
<li>Choose the downloaded ZIP below, enter a map name and pack ID, and wait for local validation.</li>
@@ -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();
</script>
</body>
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace Storage {
constexpr std::size_t LITTLEFS_BLANK_SCAN_CHUNK_SIZE = 4096;
template <typename Mount, typename ResolvePartition, typename ReadPartition,
typename FormatAndMount>
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
+42 -1
View File
@@ -25,6 +25,8 @@
#include <new> // placement new
#include <soc/rtc_cntl_reg.h>
#include "storage/LittleFSInitializationPolicy.h"
// Reticulum
#include <microReticulum/Reticulum.h>
#include <microReticulum/Utilities/OS.h>
@@ -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");
@@ -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();")
@@ -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:
@@ -20,13 +20,37 @@ def test_flasher_starts_disabled_until_a_published_release_is_selected():
assert '<option value="latest">' not in source
def test_flasher_filters_drafts_and_prereleases_and_selects_a_versioned_path():
def test_firmware_version_menu_uses_high_contrast_dark_colors():
source = FLASHER.read_text()
assert "if (release.draft || release.prerelease) continue;" in source
assert ".version-select-group select option {" in source
assert "background-color: #111827;" in source
assert "color: var(--text);" in source
assert "color-scheme: dark;" in source
def test_flasher_filters_drafts_but_includes_prereleases_with_a_clear_label():
source = FLASHER.read_text()
assert "if (release.draft) continue;" in source
assert "if (release.draft || release.prerelease) continue;" not in source
assert "const releaseChannel = release.prerelease ? ' — Pre-release' : '';" in source
assert """option.textContent = release.tag_name
+ (release.name && release.name !== release.tag_name ? ` ${release.name}` : '')
+ releaseChannel;""" in source
assert "RELEASE_METADATA_ASSET = 'pyxis-release.json'" in source
assert "if (!assetNames.includes(RELEASE_METADATA_ASSET)) continue;" in source
assert "firmware/releases/${release.tag_name}/" in source
def test_flasher_defaults_to_a_stable_release_not_a_prerelease():
source = FLASHER.read_text()
assert "let defaultStableOption = null;" in source
assert "if (!release.prerelease && defaultStableOption === null)" in source
assert "if (customFirmwareSelectionToken === 0 && defaultStableOption !== null)" in source
assert "versionSelect.value = defaultStableOption.value;" in source
assert "versionSelect.selectedIndex = 1;" not in source
assert "selectPublishedRelease" in source
assert "flashBtn.disabled = false;" in source
@@ -67,6 +91,16 @@ def test_custom_firmware_upload_is_explicit_validated_and_update_only():
assert "const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked;" in source
def test_full_install_erases_flash_while_updates_preserve_persistent_partitions():
source = FLASHER.read_text()
flash = source[source.index("async function flash()") :]
assert "const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked;" in flash
assert "eraseAll: useFullInstall," in flash
assert "useFullInstall ? fw.full : fw.update" in flash
assert "{ offset: 0x610000" not in source
def test_custom_firmware_selection_invalidates_stale_async_results():
source = FLASHER.read_text()
handler = source[source.index("customFirmwareInput.addEventListener('change'"):source.index("function selectPublishedRelease")]
@@ -113,7 +147,8 @@ def test_tag_builds_cannot_overwrite_pages_and_only_main_deploys():
workflow = WORKFLOW.read_text()
assert workflow.count("if: github.ref == 'refs/heads/main'") >= 4
assert "select(.draft == false and .prerelease == false)" in workflow
assert "select(.draft == false)" in workflow
assert "select(.draft == false and .prerelease == false)" not in workflow
assert 'select(any(.assets[]; .name == "pyxis-release.json"))' in workflow
assert 'validate_pyxis_web_release.py --directory "${dir}" --version "${tag}"' in workflow
assert 'rm -rf "${dir}"' in workflow
@@ -163,6 +198,7 @@ def test_release_audit_rejects_stale_version_and_destructive_storage_firmware():
assert 'b"Firmware: v1.0.0"' in audit
assert 'b"FileSystem mount failed; preserving persistent data"' in audit
assert 'b"Blank LittleFS partition; formatting once"' in audit
assert '"git", "describe", "--tags", "--always", "--dirty"' in audit
assert 'os.environ.get("PYXIS_VERSION_OVERRIDE")' in audit
assert 'f"Firmware: {expected_version}".encode()' in audit
@@ -0,0 +1,116 @@
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <vector>
#include "LittleFSInitializationPolicy.h"
using Storage::mount_or_initialize_erased_littlefs;
static int passed = 0;
static int failed = 0;
#define CHECK(expr) do { if (expr) { ++passed; } else { ++failed; std::cerr << "FAIL line " << __LINE__ << ": " #expr "\n"; } } while (0)
struct Harness {
std::vector<uint8_t> partition;
bool mount_result = false;
bool resolve_result = true;
bool read_result = true;
std::size_t fail_read_at = SIZE_MAX;
bool format_mount_result = true;
int mount_calls = 0;
int resolve_calls = 0;
int read_calls = 0;
int format_mount_calls = 0;
bool run() {
return mount_or_initialize_erased_littlefs(
[this]() {
++mount_calls;
return mount_result;
},
[this](std::size_t& size) {
++resolve_calls;
if (!resolve_result) return false;
size = partition.size();
return true;
},
[this](std::size_t offset, uint8_t* output, std::size_t size) {
++read_calls;
if (!read_result || offset >= fail_read_at ||
offset + size > partition.size()) return false;
for (std::size_t i = 0; i < size; ++i) output[i] = partition[offset + i];
return true;
},
[this]() {
++format_mount_calls;
return format_mount_result;
});
}
};
int main() {
{
Harness harness;
harness.mount_result = true;
CHECK(harness.run());
CHECK(harness.mount_calls == 1);
CHECK(harness.resolve_calls == 0);
CHECK(harness.read_calls == 0);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
harness.partition.assign(8193, 0xFF);
CHECK(harness.run());
CHECK(harness.read_calls == 3);
CHECK(harness.format_mount_calls == 1);
}
{
Harness harness;
harness.partition.assign(8193, 0xFF);
harness.partition.back() = 0x00;
CHECK(!harness.run());
CHECK(harness.read_calls == 3);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
harness.resolve_result = false;
CHECK(!harness.run());
CHECK(harness.read_calls == 0);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
CHECK(!harness.run());
CHECK(harness.read_calls == 0);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
harness.partition.assign(4096, 0xFF);
harness.read_result = false;
CHECK(!harness.run());
CHECK(harness.read_calls == 1);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
harness.partition.assign(8192, 0xFF);
harness.fail_read_at = 4096;
CHECK(!harness.run());
CHECK(harness.read_calls == 2);
CHECK(harness.format_mount_calls == 0);
}
{
Harness harness;
harness.partition.assign(4096, 0xFF);
harness.format_mount_result = false;
CHECK(!harness.run());
CHECK(harness.format_mount_calls == 1);
}
std::cout << passed << " passed, " << failed << " failed\n";
return failed == 0 ? 0 : 1;
}
@@ -0,0 +1,19 @@
"""Compile and execute the portable LittleFS initialization policy regression."""
from pathlib import Path
from native_test import compile_and_run
HERE = Path(__file__).resolve().parent
PYXIS_ROOT = HERE.parent.parent
def test_littlefs_initialization_policy(tmp_path):
ran = compile_and_run(
tmp_path,
name="test_littlefs_initialization_policy",
sources=[HERE / "test_littlefs_initialization_policy.cpp"],
include_dirs=[PYXIS_ROOT / "lib" / "storage"],
)
assert "25 passed, 0 failed" in ran.stdout
+1
View File
@@ -34,6 +34,7 @@ EXCLUDED_STRINGS = (
)
REQUIRED_STRINGS = (
b"FileSystem mount failed; preserving persistent data",
b"Blank LittleFS partition; formatting once",
)
EXCLUDED_SYMBOLS = (
"test_call_",