mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-28 23:00:28 +00:00
Merge origin/main into feat/map-style-picker
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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_",
|
||||
|
||||
Reference in New Issue
Block a user