Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ jobs:
- name: Build and validate release OTA candidate
run: |
version="$(python -c 'import json; print(json.load(open("docs/release-validation.json"))["candidate_version"])')"
python - "$version" <<'PY'
import json
import sys
from pathlib import Path

candidate_version = sys.argv[1]
metadata = json.loads(Path("micro/app_version.json").read_text())
if metadata.get("version") != candidate_version:
raise SystemExit(
"USB application version {} does not match candidate {}".format(
metadata.get("version"), candidate_version
)
)
PY
python scripts/build_ota_bundle.py \
--version "$version" \
--output candidate-assets/bambutton-ota.json
Expand Down
3 changes: 3 additions & 0 deletions micro/app_version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"version": "1.0.0"
}
13 changes: 12 additions & 1 deletion micro/ota_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"wifi.py",
)
REQUIRED_APP_FILES = APP_FILES
APP_VERSION_FILE = "app_version.json"
UPDATE_ROOT = ".bambutton"
BOOTSTRAP_ROOT = UPDATE_ROOT + "/bootstrap"
BOOTSTRAP_INSTALL_MARKER = UPDATE_ROOT + "/bootstrap-install.marker"
Expand Down Expand Up @@ -221,9 +222,19 @@ def migrate_legacy(self, source_root=None):
)
return False

version_data = self._read_json(source_root + "/" + APP_VERSION_FILE)
application_version = (
version_data.get("version") if isinstance(version_data, dict) else None
)
if (
not isinstance(application_version, str)
or not application_version.strip()
):
application_version = "legacy"

self._write_json(self._slot_path("app_a.tmp", "manifest.json"), {
"format": UPDATE_FORMAT,
"version": "legacy",
"version": application_version,
"release_notes": "Migrated from the flat-root application",
"minimum_bootloader": BOOTLOADER_VERSION,
"files": manifest_files,
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ packages = ["src/bambutton"]
"micro/wifi.py" = "bambutton/micro/wifi.py"
"micro/web_config.py" = "bambutton/micro/web_config.py"
"micro/config_example.json" = "bambutton/micro/config_example.json"
"micro/app_version.json" = "bambutton/micro/app_version.json"
"firmware" = "bambutton/firmware"

[tool.hatch.build.targets.sdist]
Expand All @@ -76,4 +77,5 @@ include = [
"/micro/wifi.py",
"/micro/web_config.py",
"/micro/config_example.json",
"/micro/app_version.json",
]
1 change: 1 addition & 0 deletions scripts/build_gui.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"wifi.py",
"web_config.py",
"config_example.json",
"app_version.json",
]


Expand Down
3 changes: 3 additions & 0 deletions scripts/push_micro.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ def main():
args = parser.parse_args()

files = sorted(MICRO_DIR.glob("*.py"))
app_version_file = MICRO_DIR / "app_version.json"
if app_version_file.exists():
files.append(app_version_file)
if args.no_main:
files = [path for path in files if path.name not in ("main.py", "boot.py")]

Expand Down
1 change: 1 addition & 0 deletions scripts/smoke_installed_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def check_installed_package():
"micro/app_main.py",
"micro/boot.py",
"micro/config_example.json",
"micro/app_version.json",
"micro/ota_manager.py",
"micro/web_config.py",
"firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin",
Expand Down
18 changes: 18 additions & 0 deletions src/bambutton/gui.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,27 @@ def run_installation_self_test():
"web_config.py",
"wifi.py",
"config_example.json",
"app_version.json",
)
missing = [name for name in required_files if not (MICRO_DIR / name).is_file()]
if missing:
raise RuntimeError(
"Bambutton installation is missing: {}".format(", ".join(missing))
)
try:
version_metadata = json.loads(
(MICRO_DIR / "app_version.json").read_text(encoding="utf-8")
)
except (OSError, ValueError) as exc:
raise RuntimeError(
"Bambutton application version metadata is invalid: {}".format(exc)
)
if (
not isinstance(version_metadata, dict)
or not isinstance(version_metadata.get("version"), str)
or not version_metadata["version"].strip()
):
raise RuntimeError("Bambutton application version metadata is invalid")
validate_firmware(first_firmware_file())


Expand Down Expand Up @@ -626,6 +641,9 @@ def push_micro_files(config_path, clean=False):
run_mpremote(mpremote_args("exec", CLEAN_BOARD_CODE))

files = sorted(MICRO_DIR.glob("*.py"))
app_version_file = MICRO_DIR / "app_version.json"
if app_version_file.is_file():
files.append(app_version_file)
run_mpremote(mpremote_args("exec", BOOTSTRAP_PREPARE_CODE))
for path in files:
run_mpremote(mpremote_args(
Expand Down
12 changes: 9 additions & 3 deletions tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ def test_installed_setup_self_test_checks_micro_files_and_firmware(
"config_example.json",
):
(micro_dir / name).write_text(name)
(micro_dir / "app_version.json").write_text('{"version":"1.0.0"}')
(firmware_dir / "esp32.bin").write_bytes(b"firmware")
monkeypatch.setattr(gui, "MICRO_DIR", micro_dir)
monkeypatch.setattr(gui, "DEFAULT_FIRMWARE_DIR", firmware_dir)
Expand Down Expand Up @@ -302,6 +303,7 @@ def test_push_micro_files_installs_selected_file_as_config_json(tmp_path, monkey
micro_dir.mkdir()
(micro_dir / "b_module.py").write_text("# b")
(micro_dir / "a_module.py").write_text("# a")
(micro_dir / "app_version.json").write_text('{"version":"1.0.0"}')
config_path = tmp_path / "saved-config.json"
config_path.write_text(json.dumps({"wifi": {"ssid": "shop"}}))
calls = []
Expand All @@ -315,9 +317,13 @@ def test_push_micro_files_installs_selected_file_as_config_json(tmp_path, monkey
assert calls[1][0] == "exec"
assert calls[2][1:] == [str(micro_dir / "a_module.py"), ":.bambutton/bootstrap/a_module.py"]
assert calls[3][1:] == [str(micro_dir / "b_module.py"), ":.bambutton/bootstrap/b_module.py"]
assert calls[4][1:] == [str(config_path), ":.bambutton/bootstrap/config.json"]
assert calls[5][0] == "exec"
assert calls[6] == ["reset"]
assert calls[4][1:] == [
str(micro_dir / "app_version.json"),
":.bambutton/bootstrap/app_version.json",
]
assert calls[5][1:] == [str(config_path), ":.bambutton/bootstrap/config.json"]
assert calls[6][0] == "exec"
assert calls[7] == ["reset"]


def test_push_micro_files_omits_config_for_web_gui_setup(tmp_path, monkeypatch):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_ota_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,59 @@ def test_stage_and_commit_use_inactive_slot_and_preserve_config(tmp_path):
def test_legacy_migration_copies_root_files_before_switching_pointer(tmp_path):
for filename in ota_manager.APP_FILES:
(tmp_path / filename).write_bytes((filename + "\n").encode())
(tmp_path / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}')
manager = ota_manager.OTAUpdateManager(root=str(tmp_path))

assert manager.migrate_legacy() is True
assert manager.status()["active_slot"] == "app_a"
assert manager.status()["pending"]["candidate"] == "app_a"
assert manager.status()["current_version"] == "1.0.0"
for filename in ota_manager.APP_FILES:
assert (tmp_path / ".bambutton/app_a" / filename).read_bytes() == (
filename + "\n"
).encode()


def test_genuine_legacy_migration_keeps_legacy_version_fallback(tmp_path):
for filename in ota_manager.REQUIRED_APP_FILES:
(tmp_path / filename).write_bytes((filename + "\n").encode())
manager = ota_manager.OTAUpdateManager(root=str(tmp_path))

assert manager.migrate_legacy() is True
assert manager.status()["current_version"] == "legacy"


def test_existing_ota_slot_format_remains_valid(tmp_path):
old_bundle = bundle("0.9.0")
manager = ota_manager.OTAUpdateManager(root=str(tmp_path))

manager.stage_bundle(old_bundle)
manager.commit_staged()
manager._verify_slot("app_a")

assert manager.status()["current_version"] == "0.9.0"


def test_usb_and_ota_installs_report_the_same_candidate_version(tmp_path):
usb_root = tmp_path / "usb"
usb_root.mkdir()
for filename in ota_manager.APP_FILES:
(usb_root / filename).write_bytes((filename + "\n").encode())
(usb_root / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}')
usb_manager = ota_manager.OTAUpdateManager(root=str(usb_root))
assert usb_manager.migrate_legacy() is True

ota_root = tmp_path / "ota"
ota_root.mkdir()
ota_manager_for_update = ota_manager.OTAUpdateManager(root=str(ota_root))
ota_manager_for_update.stage_bundle(bundle("1.0.0"))
ota_manager_for_update.commit_staged()

assert usb_manager.status()["current_version"] == (
ota_manager_for_update.status()["current_version"]
)


def test_confirmed_candidate_clears_pending_state(tmp_path):
manager = ota_manager.OTAUpdateManager(root=str(tmp_path))
manager.stage_bundle(bundle())
Expand Down Expand Up @@ -120,6 +162,7 @@ def test_bootstrap_candidate_failure_returns_to_legacy_main(tmp_path, monkeypatc
if filename == "app_main.py":
content = b"raise RuntimeError('broken bootstrap')\n"
(bootstrap / filename).write_bytes(content)
(bootstrap / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}')
(bootstrap / "main.py").write_text("stable loader\n")
(tmp_path / "main.py").write_text("LEGACY_BOOTED = True\n")

Expand Down Expand Up @@ -182,6 +225,7 @@ def test_bootstrap_migration_uses_staged_files_and_cleans_up(tmp_path, monkeypat
if filename == "app_main.py":
content = b"BOOTSTRAPPED = True\n"
(bootstrap / filename).write_bytes(content)
(bootstrap / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}')
(tmp_path / ".bambutton/bootstrap-install.marker").write_text(
"bambutton-bootstrap-v1\n"
)
Expand All @@ -191,6 +235,7 @@ def test_bootstrap_migration_uses_staged_files_and_cleans_up(tmp_path, monkeypat
manager.launch()

assert manager.status()["active_slot"] == "app_a"
assert manager.status()["current_version"] == "1.0.0"
assert not bootstrap.exists()
assert not (tmp_path / ".bambutton/bootstrap-install.marker").exists()
assert (tmp_path / ".bambutton/app_a/app_main.py").read_bytes() == (
Expand Down
1 change: 1 addition & 0 deletions tests/test_package_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"micro/app_main.py",
"micro/boot.py",
"micro/config_example.json",
"micro/app_version.json",
"micro/ota_manager.py",
"micro/web_config.py",
"firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin",
Expand Down
15 changes: 14 additions & 1 deletion tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import runpy
from pathlib import Path
import tomllib
Expand All @@ -12,6 +13,7 @@ def test_release_gui_includes_all_micro_python_modules():
assert "app_main.py" in build_script["MICRO_FILES"]
assert "ota_manager.py" in build_script["MICRO_FILES"]
assert "config_example.json" in build_script["MICRO_FILES"]
assert "app_version.json" in build_script["MICRO_FILES"]


def test_desktop_and_python_packages_include_all_runtime_files():
Expand All @@ -21,7 +23,7 @@ def test_desktop_and_python_packages_include_all_runtime_files():
runtime_files = {
path.name for path in (project_root / "micro").iterdir()
if path.suffix == ".py"
} | {"config_example.json"}
} | {"config_example.json", "app_version.json"}

assert set(build_script["MICRO_FILES"]) == runtime_files

Expand All @@ -38,3 +40,14 @@ def test_desktop_and_python_packages_include_all_runtime_files():
"include"
])
assert {"/micro/" + filename for filename in runtime_files} <= sdist_files


def test_usb_application_version_matches_release_candidate():
project_root = Path(__file__).parents[1]
app_version = json.loads(
(project_root / "micro" / "app_version.json").read_text()
)
candidate_version = json.loads(
(project_root / "docs" / "release-validation.json").read_text()
)["candidate_version"]
assert app_version["version"] == candidate_version
8 changes: 5 additions & 3 deletions tests/test_push_micro.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def test_push_micro_stages_runtime_before_installing_bootloader(tmp_path, monkey
micro_dir.mkdir()
for filename in ("app_main.py", "boot.py", "main.py", "ota_manager.py", "web_config.py"):
(micro_dir / filename).write_text(filename)
(micro_dir / "app_version.json").write_text('{"version":"1.0.0"}')
config = micro_dir / "config.json"
config.write_text("{}")
calls = []
Expand All @@ -21,17 +22,18 @@ def test_push_micro_stages_runtime_before_installing_bootloader(tmp_path, monkey

assert calls[0][0:2] == ["mpremote", "exec"]
assert calls[1][0:2] == ["mpremote", "exec"]
staged_paths = [call[-1] for call in calls[2:8]]
staged_paths = [call[-1] for call in calls[2:9]]
assert staged_paths == [
":.bambutton/bootstrap/app_main.py",
":.bambutton/bootstrap/boot.py",
":.bambutton/bootstrap/main.py",
":.bambutton/bootstrap/ota_manager.py",
":.bambutton/bootstrap/web_config.py",
":.bambutton/bootstrap/app_version.json",
":.bambutton/bootstrap/config.json",
]
assert calls[8][0:2] == ["mpremote", "exec"]
assert calls[9] == ["mpremote", "reset"]
assert calls[9][0:2] == ["mpremote", "exec"]
assert calls[10] == ["mpremote", "reset"]


def test_push_micro_no_main_keeps_development_path_without_boot(tmp_path, monkeypatch):
Expand Down
Loading