diff --git a/migration_gate.json b/migration_gate.json new file mode 100644 index 00000000..703d93f2 --- /dev/null +++ b/migration_gate.json @@ -0,0 +1,4 @@ +{ + "nixos_for_everyone": false, + "nixos_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst" +} diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index 21b44189..48411dff 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,10 +1,12 @@ import glob +import json import re from typing import Dict, Any +import pam +import requests import sh from sh import wpa_cli, unzip, passwd -import pam import socket from PiFinder import utils @@ -445,3 +447,101 @@ def update_gpsd_config(baud_rate: int) -> None: except Exception as e: logger.error(f"SYS: Error updating GPSD config: {e}") raise + + +# --------------------------------------------------------------------------- +# NixOS migration +# --------------------------------------------------------------------------- + +MIGRATION_PROGRESS_FILE = "/tmp/nixos_migration_progress" +MIGRATION_SCRIPT = "/home/pifinder/PiFinder/python/scripts/nixos_migration.sh" + + +def _fetch_migration_sha256(version_info: dict) -> str: + """Fetch SHA256 from sidecar URL, falling back to hardcoded value.""" + sha256_url = version_info.get("migration_sha256_url", "") + if sha256_url: + try: + resp = requests.get(sha256_url, timeout=15) + if resp.status_code == 200: + sha256 = resp.text.strip().split()[0] + logger.info(f"SYS: Fetched migration SHA256: {sha256[:16]}...") + return sha256 + logger.warning(f"SYS: SHA256 fetch returned {resp.status_code}") + except requests.exceptions.RequestException as e: + logger.warning(f"SYS: Failed to fetch SHA256: {e}") + + sha256 = version_info.get("migration_sha256", "") + if sha256: + logger.info("SYS: Using hardcoded migration SHA256") + return sha256 + + +def start_nixos_migration(version_info: dict) -> None: + """ + Start the NixOS migration process in the background. + + Raises ValueError if migration_url or a migration SHA256 cannot be + obtained — an in-place OS replacement must not run without checksum + verification. + """ + url = version_info.get("migration_url", "") + if not url: + raise ValueError("Missing migration_url") + sha256 = _fetch_migration_sha256(version_info) + if not sha256: + raise ValueError( + "No migration SHA256 available (neither migration_sha256_url nor " + "migration_sha256 produced a value); refusing to migrate without " + "checksum verification" + ) + display_class = str(version_info.get("display_class", "")) + display_resolution_value = version_info.get("display_resolution", "") + if isinstance(display_resolution_value, (list, tuple)): + display_resolution = "x".join(str(part) for part in display_resolution_value) + else: + display_resolution = str(display_resolution_value) + + logger.info(f"SYS: Starting NixOS migration to {version_info.get('version', '?')}") + + with open(MIGRATION_PROGRESS_FILE, "w") as f: + json.dump({"percent": 0, "status": "Starting..."}, f) + + def _log_output(line): + logger.info(f"SYS: migration: {line.strip()}") + + def _log_error(line): + logger.error(f"SYS: migration: {line.strip()}") + + def _on_done(cmd, success, exit_code): + if not success: + logger.error(f"SYS: Migration script failed with exit code {exit_code}") + + try: + sh.bash( + MIGRATION_SCRIPT, + url, + sha256, + MIGRATION_PROGRESS_FILE, + display_class, + display_resolution, + _bg=True, + _bg_exc=False, + _out=_log_output, + _err=_log_error, + _done=_on_done, + ) + except Exception as e: + logger.error(f"SYS: Migration failed to start: {e}") + raise + + +def get_migration_progress() -> Dict[str, Any]: + """ + Read current migration progress from the progress file. + """ + try: + with open(MIGRATION_PROGRESS_FILE, "r") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index c9be54aa..5df9b65d 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,16 +1,68 @@ #!/usr/bin/python # -*- coding:utf-8 -*- """ -This module contains all the UI Module classes - +This module contains the UI Module classes for +software updates and NixOS migration. """ +import logging +import time +from typing import Any, Optional, TYPE_CHECKING + import requests from PiFinder import utils from PiFinder.ui.base import UIModule +from PiFinder.ui.ui_utils import TextLayouter + +if TYPE_CHECKING: + + def _(a) -> Any: + return a + sys_utils = utils.get_sys_utils() +logger = logging.getLogger("UISoftware") + +REQUEST_TIMEOUT = 10 +MIGRATION_GATE_URL = ( + "https://raw.githubusercontent.com/brickbots/PiFinder/release/migration_gate.json" +) + +# Secret unlock: 7x square button +_UNLOCK_SEQUENCE = ["square"] * 7 + +_MIGRATION_VERSION_INFO = { + "version": "3.0.0", + "type": "upgrade", + "migration_size_mb": 292, + "migration_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst", + "migration_sha256_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst.sha256", +} + + +def _fetch_migration_config() -> Optional[dict]: + """Fetch and parse the remote migration config JSON. + + Returns the parsed dict if it contains a usable `nixos_url`; None on + network error, non-200 response, malformed JSON, or missing url. + The `nixos_for_everyone` gate is enforced by the caller. + """ + try: + res = requests.get(MIGRATION_GATE_URL, timeout=REQUEST_TIMEOUT) + except requests.exceptions.RequestException: + return None + if res.status_code != 200: + return None + try: + data = res.json() + except ValueError: + return None + if not isinstance(data, dict): + return None + if not data.get("nixos_url"): + return None + return data def update_needed(current_version: str, repo_version: str) -> bool: @@ -45,7 +97,8 @@ def update_needed(current_version: str, repo_version: str) -> bool: class UISoftware(UIModule): """ - UI for updating software versions + UI for updating software versions. + Includes secret 7x square unlock to trigger NixOS migration. """ __title__ = "SOFTWARE" @@ -64,17 +117,55 @@ def __init__(self, *args, **kwargs) -> None: self._go_for_update = False self._option_select = "Update" + # Unlock sequence tracking (7x square triggers migration) + self._key_buffer: list = [] + + def _record_key(self, key_name: str): + """Record a key press for unlock sequence detection.""" + self._key_buffer.append(key_name) + if len(self._key_buffer) > len(_UNLOCK_SEQUENCE): + self._key_buffer = self._key_buffer[-len(_UNLOCK_SEQUENCE) :] + if self._key_buffer == _UNLOCK_SEQUENCE: + self._key_buffer = [] + # Unlock: self-contained — uses the hardcoded URLs and does not + # require the remote migration_gate.json to exist. + self._trigger_migration(dict(_MIGRATION_VERSION_INFO)) + + def _trigger_migration(self, version_info: dict): + """Push UIMigrationConfirm onto the UI stack with the supplied + version_info (must already contain migration_url and + migration_sha256_url).""" + self.message("System Upgrade", 1) + self.add_to_stack( + { + "class": UIMigrationConfirm, + "version_info": version_info, + "current_version": self._software_version.strip(), + } + ) + def get_release_version(self): """ Fetches current release version from - github, sets class variable if found + github, sets class variable if found. + Also checks the remote migration config. """ + config = _fetch_migration_config() + if config and config.get("nixos_for_everyone"): + version_info = dict(_MIGRATION_VERSION_INFO) + nixos_url = config["nixos_url"] + version_info["migration_url"] = nixos_url + version_info["migration_sha256_url"] = f"{nixos_url}.sha256" + self._trigger_migration(version_info) + return + try: res = requests.get( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/version.txt" + "https://raw.githubusercontent.com/brickbots/PiFinder/release/version.txt", + timeout=REQUEST_TIMEOUT, ) - except requests.exceptions.ConnectionError: - print("Could not connect to github") + except requests.exceptions.RequestException: + logger.warning("Could not fetch release version from github") self._release_version = "Unknown" return @@ -230,6 +321,9 @@ def toggle_option(self): else: self._option_select = "Update" + def key_square(self): + self._record_key("square") + def key_up(self): self.toggle_option() @@ -241,3 +335,372 @@ def key_right(self): self.remove_from_stack() else: self.update_software() + + +class UIMigrationConfirm(UIModule): + """ + Warning screen before initiating NixOS migration. + Shows version info, warns about irreversibility, requires confirmation. + """ + + __title__ = "UPGRADE" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._version_info = self.item_definition.get("version_info", {}) + self._current_version = self.item_definition.get("current_version", "?") + self._target_version = self._version_info.get("version", "?") + self._option_index = 0 + self._options = [_("Confirm"), _("Cancel")] + + def update(self, force=False): + time.sleep(1 / 30) + self.clear_screen() + y = self.display_class.titlebar_height + 2 + + self.draw.text( + (0, y), + _("Major Upgrade"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 14 + + self.draw.text( + (5, y), + f"{self._current_version} -> {self._target_version}", + font=self.fonts.bold.font, + fill=self.colors.get(192), + ) + y += 16 + + # Separator + self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) + y += 4 + + self.draw.text( + (0, y), + _("IRREVERSIBLE"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 12 + + size_mb = self._version_info.get("migration_size_mb", "?") + self.draw.text( + (0, y), + _("Download: {}MB").format(size_mb), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += 11 + + self.draw.text( + (0, y), + _("Power + WiFi req"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += 11 + + if not self._version_info.get( + "migration_sha256_url" + ) and not self._version_info.get("migration_sha256"): + self.draw.text( + (0, y), + _("No checksum avail."), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += 11 + + y += 5 + + # Options + for i, label in enumerate(self._options): + oy = y + i * 12 + self.draw.text( + (10, oy), + label, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + if i == self._option_index: + self.draw.text( + (0, oy), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + + return self.screen_update() + + def key_up(self): + self._option_index = (self._option_index - 1) % len(self._options) + + def key_down(self): + self._option_index = (self._option_index + 1) % len(self._options) + + def key_left(self): + return True + + def key_right(self): + if self._options[self._option_index] == _("Cancel"): + self.remove_from_stack() + elif self._options[self._option_index] == _("Confirm"): + self.add_to_stack( + { + "class": UIMigrationProgress, + "version_info": self._version_info, + } + ) + + +class UIMigrationProgress(UIModule): + """ + Migration download and preparation progress screen. + Triggers the actual migration via sys_utils. + """ + + __title__ = "UPGRADE" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._version_info = self.item_definition.get("version_info", {}) + self._started = False + self._status = _("Starting...") + self._progress = 0 + self._terminal_failure = False + self._status_layout = TextLayouter( + self._status, + draw=self.draw, + color=self.colors.get(255), + colors=self.colors, + font=self.fonts.base, + available_lines=4, + ) + + def active(self): + super().active() + if not self._started: + self._started = True + self._start_migration() + + def _start_migration(self): + """Kick off the migration process in the background.""" + self._status = _("Downloading...") + try: + version_info = dict(self._version_info) + version_info["display_class"] = self.display_class.__class__.__name__ + version_info["display_resolution"] = list(self.display_class.resolution) + supported_displays = { + "DisplaySSD1351": (128, 128), + "DisplaySSD1333": (176, 176), + } + display_class = version_info["display_class"] + display_resolution = tuple(version_info["display_resolution"]) + display_supported = ( + supported_displays.get(display_class) == display_resolution + ) + display_supported = display_supported or ( + "SSD1333" in display_class and display_resolution == (176, 176) + ) + if not display_supported: + logger.error( + "Unsupported migration progress renderer display: " + f"{display_class} {version_info['display_resolution']}" + ) + self._status = _("Not supported") + return + sys_utils.start_nixos_migration(version_info) + except AttributeError: + logger.error("sys_utils.start_nixos_migration not available") + self._status = _("Not supported") + self._status_layout.set_text(self._status) + self._terminal_failure = True + except Exception as e: + logger.error(f"Migration failed to start: {e}") + self._status = _("Failed: ") + str(e) + self._status_layout.set_text(self._status) + self._terminal_failure = True + + def update(self, force=False): + time.sleep(1 / 30) + self.clear_screen() + y = self.display_class.titlebar_height + 2 + + # Try to read progress from sys_utils. AttributeError happens when + # running against sys_utils_fake (no migration support); the helper + # itself swallows OS/JSON errors and returns {}. + try: + progress = sys_utils.get_migration_progress() + except AttributeError: + progress = None + if progress: + try: + self._progress = int(progress.get("percent", self._progress)) + except (TypeError, ValueError): + pass # bad/missing percent — keep prior value + new_status = progress.get("status", self._status) + if isinstance(new_status, str) and new_status != self._status: + self._status = new_status + self._status_layout.set_text(self._status) + + self.draw.text( + (0, y), + _("System Upgrade"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 20 + + # Progress bar + bar_x, bar_w, bar_h = 4, 120, 12 + self.draw.rectangle( + [bar_x, y, bar_x + bar_w, y + bar_h], + outline=self.colors.get(64), + ) + fill_w = int(bar_w * self._progress / 100) + if fill_w > 0: + self.draw.rectangle( + [bar_x + 1, y + 1, bar_x + fill_w, y + bar_h - 1], + fill=self.colors.get(255), + ) + pct_text = f"{self._progress}%" + pct_bbox = self.fonts.base.font.getbbox(pct_text) + pct_w = pct_bbox[2] - pct_bbox[0] + pct_h = pct_bbox[3] - pct_bbox[1] + pct_x = bar_x + (bar_w - pct_w) // 2 + pct_y = y + (bar_h - pct_h) // 2 - pct_bbox[1] + self.draw.text( + (pct_x, pct_y), + pct_text, + font=self.fonts.base.font, + fill=self.colors.get(0) if self._progress > 45 else self.colors.get(192), + ) + y += bar_h + 4 + + # Use TextLayouter for scrollable status text + self._status_layout.draw((0, y)) + + return self.screen_update() + + def key_up(self): + self._status_layout.previous() + + def key_down(self): + self._status_layout.next() + + def key_left(self): + # Allow exit only if the migration never actually started (e.g., + # pre-flight refused due to missing checksum or unsupported display). + # Once the bash script is running, going back is unsafe. + if self._terminal_failure: + self.remove_from_stack() + return True + return False + + +class UIReleaseNotes(UIModule): + """ + Scrollable release notes viewer. + Fetches markdown from a URL and displays as plain text. + """ + + __title__ = "NOTES" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._notes_url = self.item_definition.get("notes_url", "") + self._loaded = False + self._error = False + self._text_layout = TextLayouter( + "", + draw=self.draw, + color=self.colors.get(255), + colors=self.colors, + font=self.fonts.base, + available_lines=9, + ) + + def active(self): + super().active() + if not self._loaded: + self._fetch_notes() + + def _fetch_notes(self): + """Fetch release notes from the configured URL.""" + try: + res = requests.get(self._notes_url, timeout=REQUEST_TIMEOUT) + if res.status_code == 200: + text = _strip_markdown(res.text) + self._text_layout.set_text(text) + self._loaded = True + else: + self._error = True + logger.warning(f"Failed to fetch release notes: HTTP {res.status_code}") + except requests.exceptions.RequestException as e: + self._error = True + logger.warning(f"Failed to fetch release notes: {e}") + + def update(self, force=False): + time.sleep(1 / 30) + self.clear_screen() + draw_pos = self.display_class.titlebar_height + 2 + + if self._error: + self.draw.text( + (10, draw_pos + 20), + _("Could not load"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, draw_pos + 35), + _("release notes"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + return self.screen_update() + + if not self._loaded: + self.draw.text( + (10, draw_pos + 20), + _("Loading..."), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + return self.screen_update() + + self._text_layout.draw((0, draw_pos)) + return self.screen_update() + + def key_down(self): + self._text_layout.next() + + def key_up(self): + self._text_layout.previous() + + def key_left(self): + return True + + +def _strip_markdown(text: str) -> str: + """ + Minimal markdown stripping for plain-text display on OLED. + Removes common markdown syntax while keeping readable text. + """ + lines = [] + for line in text.splitlines(): + stripped = line.lstrip("#").strip() + stripped = stripped.replace("**", "").replace("__", "") + stripped = stripped.replace("*", "").replace("_", "") + while "[" in stripped and "](" in stripped: + start = stripped.index("[") + mid = stripped.index("](", start) + end = stripped.index(")", mid) + link_text = stripped[start + 1 : mid] + stripped = stripped[:start] + link_text + stripped[end + 1 :] + stripped = stripped.replace("`", "") + lines.append(stripped) + return "\n".join(lines) diff --git a/python/noxfile.py b/python/noxfile.py index d5d17eed..df27d360 100644 --- a/python/noxfile.py +++ b/python/noxfile.py @@ -46,7 +46,12 @@ def type_hints(session: nox.Session) -> None: """ session.install("-r", "requirements.txt") session.install("-r", "requirements_dev.txt") - session.run("mypy", "--install-types", "--non-interactive", ".") + # First run populates the cache so --install-types knows what stubs are needed. + # success_codes=[0, 1] here is expected: missing-stub errors before stubs are + # installed. The second run (with stubs) must exit 0; real type errors fail CI. + # Targets PiFinder/ explicitly to avoid broken tetra3 symlink in the tree. + session.run("mypy", "PiFinder", success_codes=[0, 1]) + session.run("mypy", "--install-types", "--non-interactive", "PiFinder") @nox.session(reuse_venv=True, python="3.9") diff --git a/python/scripts/migration_progress b/python/scripts/migration_progress new file mode 100755 index 00000000..4d87f175 Binary files /dev/null and b/python/scripts/migration_progress differ diff --git a/python/scripts/migration_progress.c b/python/scripts/migration_progress.c new file mode 100644 index 00000000..3b0a3731 --- /dev/null +++ b/python/scripts/migration_progress.c @@ -0,0 +1,579 @@ +/* + * migration_progress - OLED progress display for PiFinder initramfs + * + * Drives supported SPI OLED displays to show migration progress. + * Designed to be statically compiled and included in the initramfs. + * + * Usage: migration_progress + * percent: 0-100 + * message: status text (max ~20 chars fits on screen) + * + * Examples: + * migration_progress 0 1 22 "Starting..." + * migration_progress 45 10 22 "Moving data" + * migration_progress 100 22 22 "Complete!" + * + * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25, BGR565 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_WIDTH 176 +#define MAX_HEIGHT 176 +#define SPI_DEVICE "/dev/spidev0.0" +#define SPI_SPEED 40000000 +#define GPIO_DC 24 +#define GPIO_RST 25 + +/* BGR565 colors */ +#define COL_BLACK 0x0000 +#define COL_WHITE 0xFFFF +#define COL_RED 0x001F /* BGR565: blue=0, green=0, red=31 */ +#define COL_GREEN 0x07E0 +#define COL_DKGRAY 0x4208 +#define COL_DKRED 0x0010 + +static int spi_fd = -1; +static int gpio_fd = -1; +static struct gpio_v2_line_request dc_req; +static struct gpio_v2_line_request rst_req; +static uint16_t framebuf[MAX_WIDTH * MAX_HEIGHT]; + +enum oled_controller { + CTRL_SSD1351, + CTRL_SSD1333, +}; + +static enum oled_controller controller = CTRL_SSD1351; +static int display_width = 128; +static int display_height = 128; + +/* 5x7 bitmap font - ASCII 32-126 */ +static const uint8_t font5x7[][5] = { + {0x00,0x00,0x00,0x00,0x00}, /* space */ + {0x00,0x00,0x5F,0x00,0x00}, /* ! */ + {0x00,0x07,0x00,0x07,0x00}, /* " */ + {0x14,0x7F,0x14,0x7F,0x14}, /* # */ + {0x24,0x2A,0x7F,0x2A,0x12}, /* $ */ + {0x23,0x13,0x08,0x64,0x62}, /* % */ + {0x36,0x49,0x55,0x22,0x50}, /* & */ + {0x00,0x05,0x03,0x00,0x00}, /* ' */ + {0x00,0x1C,0x22,0x41,0x00}, /* ( */ + {0x00,0x41,0x22,0x1C,0x00}, /* ) */ + {0x08,0x2A,0x1C,0x2A,0x08}, /* * */ + {0x08,0x08,0x3E,0x08,0x08}, /* + */ + {0x00,0x50,0x30,0x00,0x00}, /* , */ + {0x08,0x08,0x08,0x08,0x08}, /* - */ + {0x00,0x60,0x60,0x00,0x00}, /* . */ + {0x20,0x10,0x08,0x04,0x02}, /* / */ + {0x3E,0x51,0x49,0x45,0x3E}, /* 0 */ + {0x00,0x42,0x7F,0x40,0x00}, /* 1 */ + {0x42,0x61,0x51,0x49,0x46}, /* 2 */ + {0x21,0x41,0x45,0x4B,0x31}, /* 3 */ + {0x18,0x14,0x12,0x7F,0x10}, /* 4 */ + {0x27,0x45,0x45,0x45,0x39}, /* 5 */ + {0x3C,0x4A,0x49,0x49,0x30}, /* 6 */ + {0x01,0x71,0x09,0x05,0x03}, /* 7 */ + {0x36,0x49,0x49,0x49,0x36}, /* 8 */ + {0x06,0x49,0x49,0x29,0x1E}, /* 9 */ + {0x00,0x36,0x36,0x00,0x00}, /* : */ + {0x00,0x56,0x36,0x00,0x00}, /* ; */ + {0x00,0x08,0x14,0x22,0x41}, /* < */ + {0x14,0x14,0x14,0x14,0x14}, /* = */ + {0x41,0x22,0x14,0x08,0x00}, /* > */ + {0x02,0x01,0x51,0x09,0x06}, /* ? */ + {0x32,0x49,0x79,0x41,0x3E}, /* @ */ + {0x7E,0x11,0x11,0x11,0x7E}, /* A */ + {0x7F,0x49,0x49,0x49,0x36}, /* B */ + {0x3E,0x41,0x41,0x41,0x22}, /* C */ + {0x7F,0x41,0x41,0x22,0x1C}, /* D */ + {0x7F,0x49,0x49,0x49,0x41}, /* E */ + {0x7F,0x09,0x09,0x01,0x01}, /* F */ + {0x3E,0x41,0x41,0x51,0x32}, /* G */ + {0x7F,0x08,0x08,0x08,0x7F}, /* H */ + {0x00,0x41,0x7F,0x41,0x00}, /* I */ + {0x20,0x40,0x41,0x3F,0x01}, /* J */ + {0x7F,0x08,0x14,0x22,0x41}, /* K */ + {0x7F,0x40,0x40,0x40,0x40}, /* L */ + {0x7F,0x02,0x04,0x02,0x7F}, /* M */ + {0x7F,0x04,0x08,0x10,0x7F}, /* N */ + {0x3E,0x41,0x41,0x41,0x3E}, /* O */ + {0x7F,0x09,0x09,0x09,0x06}, /* P */ + {0x3E,0x41,0x51,0x21,0x5E}, /* Q */ + {0x7F,0x09,0x19,0x29,0x46}, /* R */ + {0x46,0x49,0x49,0x49,0x31}, /* S */ + {0x01,0x01,0x7F,0x01,0x01}, /* T */ + {0x3F,0x40,0x40,0x40,0x3F}, /* U */ + {0x1F,0x20,0x40,0x20,0x1F}, /* V */ + {0x7F,0x20,0x18,0x20,0x7F}, /* W */ + {0x63,0x14,0x08,0x14,0x63}, /* X */ + {0x03,0x04,0x78,0x04,0x03}, /* Y */ + {0x61,0x51,0x49,0x45,0x43}, /* Z */ + {0x00,0x00,0x7F,0x41,0x41}, /* [ */ + {0x02,0x04,0x08,0x10,0x20}, /* \ */ + {0x41,0x41,0x7F,0x00,0x00}, /* ] */ + {0x04,0x02,0x01,0x02,0x04}, /* ^ */ + {0x40,0x40,0x40,0x40,0x40}, /* _ */ + {0x00,0x01,0x02,0x04,0x00}, /* ` */ + {0x20,0x54,0x54,0x54,0x78}, /* a */ + {0x7F,0x48,0x44,0x44,0x38}, /* b */ + {0x38,0x44,0x44,0x44,0x20}, /* c */ + {0x38,0x44,0x44,0x48,0x7F}, /* d */ + {0x38,0x54,0x54,0x54,0x18}, /* e */ + {0x08,0x7E,0x09,0x01,0x02}, /* f */ + {0x08,0x14,0x54,0x54,0x3C}, /* g */ + {0x7F,0x08,0x04,0x04,0x78}, /* h */ + {0x00,0x44,0x7D,0x40,0x00}, /* i */ + {0x20,0x40,0x44,0x3D,0x00}, /* j */ + {0x00,0x7F,0x10,0x28,0x44}, /* k */ + {0x00,0x41,0x7F,0x40,0x00}, /* l */ + {0x7C,0x04,0x18,0x04,0x78}, /* m */ + {0x7C,0x08,0x04,0x04,0x78}, /* n */ + {0x38,0x44,0x44,0x44,0x38}, /* o */ + {0x7C,0x14,0x14,0x14,0x08}, /* p */ + {0x08,0x14,0x14,0x18,0x7C}, /* q */ + {0x7C,0x08,0x04,0x04,0x08}, /* r */ + {0x48,0x54,0x54,0x54,0x20}, /* s */ + {0x04,0x3F,0x44,0x40,0x20}, /* t */ + {0x3C,0x40,0x40,0x20,0x7C}, /* u */ + {0x1C,0x20,0x40,0x20,0x1C}, /* v */ + {0x3C,0x40,0x30,0x40,0x3C}, /* w */ + {0x44,0x28,0x10,0x28,0x44}, /* x */ + {0x0C,0x50,0x50,0x50,0x3C}, /* y */ + {0x44,0x64,0x54,0x4C,0x44}, /* z */ + {0x00,0x08,0x36,0x41,0x00}, /* { */ + {0x00,0x00,0x7F,0x00,0x00}, /* | */ + {0x00,0x41,0x36,0x08,0x00}, /* } */ + {0x08,0x08,0x2A,0x1C,0x08}, /* ~ */ +}; + +static void msleep(int ms) +{ + struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) +{ + struct gpio_v2_line_request r = {0}; + r.offsets[0] = pin; + r.num_lines = 1; + r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; + snprintf(r.consumer, sizeof(r.consumer), "migration"); + + if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { + perror("GPIO_V2_GET_LINE_IOCTL"); + return -1; + } + *req = r; + return 0; +} + +static void gpio_set(struct gpio_v2_line_request *req, int value) +{ + struct gpio_v2_line_values vals = {0}; + vals.bits = value ? 1 : 0; + vals.mask = 1; + ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); +} + +static void spi_write(const uint8_t *data, size_t len) +{ + /* Chunk large transfers - SPI driver may limit to 4KB */ + const size_t chunk_size = 4096; + while (len > 0) { + size_t this_len = len > chunk_size ? chunk_size : len; + struct spi_ioc_transfer tr = {0}; + tr.tx_buf = (unsigned long)data; + tr.len = this_len; + tr.speed_hz = SPI_SPEED; + tr.bits_per_word = 8; + ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); + data += this_len; + len -= this_len; + } +} + +static void oled_cmd(uint8_t cmd) +{ + gpio_set(&dc_req, 0); + spi_write(&cmd, 1); +} + +static void oled_data(const uint8_t *data, size_t len) +{ + gpio_set(&dc_req, 1); + spi_write(data, len); +} + +static void oled_cmd_data(uint8_t cmd, uint8_t data) +{ + oled_cmd(cmd); + oled_data(&data, 1); +} + +static int display_on = 0; + +static void detect_display(void) +{ + const char *display_class = getenv("MIGRATION_DISPLAY_CLASS"); + const char *display_resolution = getenv("MIGRATION_DISPLAY_RESOLUTION"); + + controller = CTRL_SSD1351; + display_width = 128; + display_height = 128; + + if (display_class && strstr(display_class, "SSD1333")) { + controller = CTRL_SSD1333; + display_width = 176; + display_height = 176; + return; + } + + if (display_resolution && strcmp(display_resolution, "176x176") == 0) { + controller = CTRL_SSD1333; + display_width = 176; + display_height = 176; + } +} + +static void oled_reset(void) +{ + gpio_set(&rst_req, 1); + msleep(10); + gpio_set(&rst_req, 0); + msleep(10); + gpio_set(&rst_req, 1); + msleep(10); +} + +static void oled_common_init(int mux_ratio, uint8_t remap) +{ + oled_reset(); + + /* SSD13xx 65k-color OLED setup. */ + oled_cmd_data(0xFD, 0x12); /* Unlock */ + oled_cmd_data(0xFD, 0xB1); /* Unlock commands */ + + oled_cmd(0xAE); /* Display off */ + oled_cmd_data(0xB3, 0xF1); /* Clock divider */ + oled_cmd_data(0xCA, (uint8_t)mux_ratio); /* Mux ratio */ + + oled_cmd(0x15); /* Column address */ + uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; + oled_data(col, 2); + + oled_cmd(0x75); /* Row address */ + uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; + oled_data(row, 2); + + oled_cmd_data(0xA0, remap); /* Remap/color depth */ + oled_cmd_data(0xA1, 0x00); /* Start line */ + oled_cmd_data(0xA2, 0x00); /* Display offset */ + oled_cmd_data(0xB5, 0x00); /* GPIO */ + oled_cmd_data(0xAB, 0x01); /* Function select */ + oled_cmd_data(0xB1, 0x32); /* Precharge */ + + oled_cmd(0xB4); /* VSL */ + uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; + oled_data(vsl, 3); + + oled_cmd_data(0xBE, 0x05); /* VCOMH */ + oled_cmd_data(0xC7, 0x0F); /* Master contrast */ + oled_cmd_data(0xB6, 0x01); /* Precharge2 */ + oled_cmd(0xA6); /* Normal display */ + + /* Display ON (0xAF) happens after first framebuffer flush. */ +} + +static void oled_init(void) +{ + if (controller == CTRL_SSD1333) + oled_common_init(0xAF, 0x74); + else + oled_common_init(0x7F, 0x74); +} + +static void oled_flush(void) +{ + /* Set contrast before first frame (matching luma) */ + if (!display_on) { + oled_cmd(0xC1); /* Contrast */ + uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; + oled_data(contrast, 3); + } + + oled_cmd(0x15); + uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; + oled_data(col, 2); + + oled_cmd(0x75); + uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; + oled_data(row, 2); + + oled_cmd(0x5C); /* Write RAM */ + + /* Send framebuffer as big-endian 16-bit pixels */ + uint8_t buf[MAX_WIDTH * MAX_HEIGHT * 2]; + int pixels = display_width * display_height; + for (int i = 0; i < pixels; i++) { + buf[i * 2] = framebuf[i] >> 8; + buf[i * 2 + 1] = framebuf[i] & 0xFF; + } + oled_data(buf, (size_t)pixels * 2); + + /* Turn display on after first frame */ + if (!display_on) { + oled_cmd(0xAF); /* Display on */ + display_on = 1; + } +} + +static void fb_clear(uint16_t color) +{ + for (int i = 0; i < display_width * display_height; i++) + framebuf[i] = color; +} + +static void fb_pixel(int x, int y, uint16_t color) +{ + if (x >= 0 && x < display_width && y >= 0 && y < display_height) + framebuf[y * display_width + x] = color; +} + +static void fb_rect(int x, int y, int w, int h, uint16_t color) +{ + for (int j = y; j < y + h && j < display_height; j++) + for (int i = x; i < x + w && i < display_width; i++) + fb_pixel(i, j, color); +} + +static void fb_char(int x, int y, char c, uint16_t color, int scale) +{ + if (c < 32 || c > 126) + c = '?'; + const uint8_t *glyph = font5x7[c - 32]; + for (int col = 0; col < 5; col++) { + uint8_t bits = glyph[col]; + for (int row = 0; row < 7; row++) { + if (bits & (1 << row)) { + for (int sy = 0; sy < scale; sy++) + for (int sx = 0; sx < scale; sx++) + fb_pixel(x + col * scale + sx, + y + row * scale + sy, color); + } + } + } +} + +static void fb_string(int x, int y, const char *s, uint16_t color, int scale) +{ + int cx = x; + while (*s) { + fb_char(cx, y, *s, color, scale); + cx += 6 * scale; /* 5px char + 1px gap */ + s++; + } +} + +/* Center a string horizontally */ +static void fb_string_centered(int y, const char *s, uint16_t color, int scale) +{ + int len = strlen(s); + int px_width = len * 6 * scale - scale; /* subtract trailing gap */ + int x = (display_width - px_width) / 2; + if (x < 0) x = 0; + fb_string(x, y, s, color, scale); +} + +/* Center a string, shrinking the scale (and finally truncating) so it always + * fits the display width. Used for the variable-length stage name. */ +static void fb_string_centered_fit(int y, const char *s, uint16_t color, int max_scale) +{ + int len = strlen(s); + for (int scale = max_scale; scale >= 1; scale--) { + if (len * 6 * scale - scale <= display_width) { + fb_string_centered(y, s, color, scale); + return; + } + } + /* Too long even at scale 1: render a head that fits. */ + int max_chars = (display_width + 1) / 6; + char buf[64]; + if (max_chars > (int)sizeof(buf) - 1) + max_chars = (int)sizeof(buf) - 1; + int n = len < max_chars ? len : max_chars; + memcpy(buf, s, (size_t)n); + buf[n] = '\0'; + fb_string_centered(y, buf, color, 1); +} + +static void draw_progress(int percent, const char *stage, int stage_num, int stage_total) +{ + if (percent < 0) percent = 0; + if (percent > 100) percent = 100; + + fb_clear(COL_BLACK); + + int scale = display_width >= 160 ? 2 : 1; + int margin = display_width >= 160 ? 14 : 10; + int banner_h = display_width >= 160 ? 18 : 12; + int title_y = display_width >= 160 ? 28 : 18; + int subtitle_y = display_width >= 160 ? 55 : 38; + int stage_y = display_width >= 160 ? 76 : 52; + int bar_y = display_width >= 160 ? 94 : 65; + int pct_y = display_width >= 160 ? 116 : 82; + int stage_name_y = display_width >= 160 ? 145 : 105; + int wait_y = display_height - (8 * scale); + + /* Warning banner at top: solid bright bar with black text for contrast */ + fb_rect(0, 0, display_width, banner_h, COL_RED); + fb_string_centered(3, "DO NOT POWER OFF", COL_BLACK, scale); + + /* Title */ + fb_string_centered(title_y, "NixOS", COL_RED, 2); + fb_string_centered(subtitle_y, "Migration", COL_RED, scale); + + /* Stage indicator (e.g., "3/7") */ + if (stage_total > 0) { + char stage_str[32]; + snprintf(stage_str, sizeof(stage_str), "Stage %d/%d", stage_num, stage_total); + fb_string_centered(stage_y, stage_str, COL_DKGRAY, scale); + } + + /* Progress bar */ + int bar_x = margin; + int bar_w = display_width - (margin * 2); + int bar_h = display_width >= 160 ? 16 : 12; + + /* Border */ + fb_rect(bar_x, bar_y, bar_w, 1, COL_DKGRAY); + fb_rect(bar_x, bar_y + bar_h - 1, bar_w, 1, COL_DKGRAY); + fb_rect(bar_x, bar_y, 1, bar_h, COL_DKGRAY); + fb_rect(bar_x + bar_w - 1, bar_y, 1, bar_h, COL_DKGRAY); + + /* Fill */ + int fill_w = (bar_w - 4) * percent / 100; + if (fill_w > 0) + fb_rect(bar_x + 2, bar_y + 2, fill_w, bar_h - 4, COL_RED); + + /* Dark red background for unfilled */ + int unfill_x = bar_x + 2 + fill_w; + int unfill_w = (bar_w - 4) - fill_w; + if (unfill_w > 0) + fb_rect(unfill_x, bar_y + 2, unfill_w, bar_h - 4, COL_DKRED); + + /* Percentage */ + char pct_str[8]; + snprintf(pct_str, sizeof(pct_str), "%d%%", percent); + fb_string_centered(pct_y, pct_str, COL_RED, 2); + + /* Current stage name */ + if (stage && *stage) + fb_string_centered_fit(stage_name_y, stage, COL_RED, scale); + + /* Bottom warning */ + fb_string_centered(wait_y, "Please wait...", COL_DKGRAY, scale); + + oled_flush(); +} + +static int hw_init(void) +{ + /* Open SPI */ + spi_fd = open(SPI_DEVICE, O_RDWR); + if (spi_fd < 0) { + perror("open spi"); + return -1; + } + + uint8_t mode = SPI_MODE_0; + uint8_t bits = 8; + uint32_t speed = SPI_SPEED; + ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); + ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); + ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); + + /* Open GPIO chip */ + gpio_fd = open("/dev/gpiochip0", O_RDWR); + if (gpio_fd < 0) { + perror("open gpiochip0"); + return -1; + } + + if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) + return -1; + if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) + return -1; + + detect_display(); + oled_init(); + return 0; +} + +static void hw_cleanup(void) +{ + if (dc_req.fd > 0) close(dc_req.fd); + if (rst_req.fd > 0) close(rst_req.fd); + if (gpio_fd >= 0) close(gpio_fd); + if (spi_fd >= 0) close(spi_fd); +} + +/* Persistent mode: initialise the panel once, then redraw in place for each + * " " line read from stdin. + * Because the panel is never reset between frames, the display updates without + * blanking to black. Returns on EOF (writer closed). */ +static void serve_loop(void) +{ + char line[256]; + while (fgets(line, sizeof(line), stdin)) { + int percent = 0, stage_num = 0, stage_total = 0, name_off = 0; + if (sscanf(line, "%d %d %d %n", &percent, &stage_num, &stage_total, + &name_off) < 3) + continue; + char *name = line + name_off; + size_t len = strlen(name); + while (len > 0 && (name[len - 1] == '\n' || name[len - 1] == '\r')) + name[--len] = '\0'; + draw_progress(percent, name, stage_num, stage_total); + } +} + +int main(int argc, char *argv[]) +{ + int serve = (argc >= 2 && strcmp(argv[1], "--serve") == 0); + + if (!serve && argc < 5) { + fprintf(stderr, "Usage: %s --serve\n", argv[0]); + fprintf(stderr, " read ' '\n"); + fprintf(stderr, " lines from stdin and redraw in place (no flicker)\n"); + fprintf(stderr, " or: %s \n", argv[0]); + fprintf(stderr, " draw a single frame and exit\n"); + fprintf(stderr, "\nExample: %s 50 3 7 'Extracting system'\n", argv[0]); + return 1; + } + + if (hw_init() < 0) { + fprintf(stderr, "Hardware init failed\n"); + hw_cleanup(); + return 1; + } + + if (serve) { + serve_loop(); + } else { + draw_progress(atoi(argv[1]), argv[4], atoi(argv[2]), atoi(argv[3])); + } + + hw_cleanup(); + return 0; +} diff --git a/python/scripts/nixos_migration.sh b/python/scripts/nixos_migration.sh new file mode 100755 index 00000000..74092831 --- /dev/null +++ b/python/scripts/nixos_migration.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# nixos_migration.sh - Pre-migration: validate, download, stage initramfs +# +# Called by PiFinder app (sys_utils.start_nixos_migration). +# Runs on RPi OS before rebooting into initramfs for the actual migration. +# +# The initramfs will: +# 1. Save WiFi + user backup to RAM +# 2. DD the .img.zst to the SD card +# 3. Expand partition, restore WiFi + user data +# 4. Reboot into NixOS +# +# Usage: nixos_migration.sh [sha256] [progress_file] [display_class] [display_resolution] +# +# Exit codes: +# 0 - Success (initramfs staged, ready to reboot) +# 1 - Pre-flight check failure +# 2 - Download failure +# 3 - Checksum mismatch +# 5 - Initramfs staging failure + +set -euo pipefail + +export PATH="/usr/sbin:/sbin:${PATH}" + +MIGRATION_URL="${1:?Usage: nixos_migration.sh [sha256] [progress_file]}" +MIGRATION_SHA256="${2:-}" +PROGRESS_FILE="${3:-/tmp/nixos_migration_progress}" +DISPLAY_CLASS="${4:-}" +DISPLAY_RESOLUTION="${5:-}" + +trap '_trap_err $LINENO "$BASH_COMMAND"' ERR +_trap_err() { + echo "{\"percent\": 0, \"status\": \"FAILED at line $1: $2\"}" > "${PROGRESS_FILE}" + echo "ERROR at line $1: $2" >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PIFINDER_HOME="/home/pifinder" +TARBALL="${PIFINDER_HOME}/pifinder-nixos-migration.tar.zst" +BOOT_PARTITION="/boot" +INITRAMFS_DIR="/tmp/nixos_initramfs" +PROGRESS_BIN="${SCRIPT_DIR}/migration_progress" +INIT_SCRIPT="${SCRIPT_DIR}/nixos_migration_init.sh" + +progress() { + local pct="$1" + local msg="$2" + echo "{\"percent\": ${pct}, \"status\": \"${msg}\"}" > "${PROGRESS_FILE}" + echo "[${pct}%] ${msg}" +} + +fail() { + local code="$1" + local msg="$2" + progress 0 "FAILED: ${msg}" + echo "ERROR: ${msg}" >&2 + exit "${code}" +} + +# Copy a binary and all its shared library dependencies into the initramfs. +copy_with_libs() { + local bin_path="$1" + local dest="$2" + + cp "${bin_path}" "${dest}/bin/" + + ldd "${bin_path}" 2>/dev/null | grep -oP '/\S+' | while read -r lib; do + local dir + dir=$(dirname "${lib}") + mkdir -p "${dest}${dir}" + cp -n "${lib}" "${dest}${dir}/" 2>/dev/null || true + done +} + +# --- Phase 0: Install required packages --- +progress 0 "Installing dependencies" +for pkg in busybox cpio curl dosfstools e2fsprogs fdisk gzip xz-utils zstd; do + if ! dpkg -s "${pkg}" >/dev/null 2>&1; then + sudo apt-get install -y "${pkg}" || fail 1 "Failed to install ${pkg}" + fi +done + +# --- Phase 1: Pre-flight checks --- +# nixos_migration_calc.py is the single source of truth for whether this +# system is ready to migrate (model, RAM, SD size + layout, free space, +# WiFi mode, supported display). A non-zero exit means all_ok is false. +progress 3 "Running pre-flight checks" + +if ! python3 "${SCRIPT_DIR}/nixos_migration_calc.py" --json \ + --display-class "${DISPLAY_CLASS}" \ + --display-resolution "${DISPLAY_RESOLUTION}" \ + > /tmp/migration_checks.json 2>&1; then + fail 1 "Pre-flight checks failed" +fi + +progress 5 "Pre-flight OK" + +# --- Phase 2: Download image --- +SKIP_DOWNLOAD=false +if [ -f "${TARBALL}" ]; then + if [ -z "${MIGRATION_SHA256}" ]; then + progress 60 "Using cached download (no checksum)" + SKIP_DOWNLOAD=true + else + progress 10 "Verifying existing download" + EXISTING_SHA256=$(sha256sum "${TARBALL}" | awk '{print $1}') + if [ "${EXISTING_SHA256}" = "${MIGRATION_SHA256}" ]; then + progress 60 "Using cached download" + SKIP_DOWNLOAD=true + fi + fi +fi + +if [ "${SKIP_DOWNLOAD}" = false ]; then + progress 10 "Downloading..." + rm -f "${TARBALL}" + + if ! curl -L -f -o "${TARBALL}" \ + --progress-bar \ + "${MIGRATION_URL}" 2>&1 | tr '\r' '\n' | while IFS= read -r line; do + if [[ "$line" =~ ([0-9]+)\.[0-9]% ]]; then + dl_pct="${BASH_REMATCH[1]}" + mapped_pct=$(( 10 + dl_pct * 50 / 100 )) + progress "${mapped_pct}" "Downloading..." + fi + done; then + fail 2 "Download failed" + fi + + # --- Phase 3: Verify checksum --- + if [ -z "${MIGRATION_SHA256}" ]; then + progress 60 "SHA256 not provided, skipping verification" + else + progress 60 "Verifying checksum" + ACTUAL_SHA256=$(sha256sum "${TARBALL}" | awk '{print $1}') + if [ "${ACTUAL_SHA256}" != "${MIGRATION_SHA256}" ]; then + rm -f "${TARBALL}" + fail 3 "Checksum mismatch" + fi + fi +fi + +progress 65 "Download OK" + +# --- Phase 4: Get image size --- +progress 68 "Preparing" + +TARBALL_SIZE=$(stat -c%s "${TARBALL}") + +progress 75 "Tarball: $((TARBALL_SIZE / 1048576))MB" + +# --- Phase 5: Build initramfs --- +progress 78 "Building initramfs" + +rm -rf "${INITRAMFS_DIR}" +mkdir -p "${INITRAMFS_DIR}"/{bin,lib,dev,proc,sys,mnt,tmp} + +# Busybox (provides sh, mount, umount, dd, tar, cp, etc.) +if command -v busybox >/dev/null 2>&1; then + copy_with_libs "$(command -v busybox)" "${INITRAMFS_DIR}" +else + fail 5 "busybox not found" +fi + +# Filesystem tools +for tool in e2fsck resize2fs mke2fs mkfs.vfat sfdisk zstd; do + tool_path=$(command -v "${tool}" 2>/dev/null || true) + if [ -z "${tool_path}" ]; then + fail 5 "${tool} not found — install e2fsprogs dosfstools util-linux zstd" + fi + copy_with_libs "${tool_path}" "${INITRAMFS_DIR}" +done + +# mkfs.ext4 is typically a symlink to mke2fs +ln -sf mke2fs "${INITRAMFS_DIR}/bin/mkfs.ext4" 2>/dev/null || true + +# OLED progress display (static binary, no libs needed) +cp "${PROGRESS_BIN}" "${INITRAMFS_DIR}/bin/" 2>/dev/null || true + +# SPI kernel modules — needed for OLED progress display +# Modules may be compressed (.ko.xz); decompress for insmod in initramfs +KVER=$(uname -r) +KMOD_DIR="/lib/modules/${KVER}/kernel/drivers/spi" +if [ -d "${KMOD_DIR}" ]; then + INITRAMFS_SPI="${INITRAMFS_DIR}/lib/modules" + mkdir -p "${INITRAMFS_SPI}" + for mod in spi-bcm2835 spidev; do + if [ -f "${KMOD_DIR}/${mod}.ko.xz" ]; then + xz -dc "${KMOD_DIR}/${mod}.ko.xz" > "${INITRAMFS_SPI}/${mod}.ko" + elif [ -f "${KMOD_DIR}/${mod}.ko.gz" ]; then + gzip -dc "${KMOD_DIR}/${mod}.ko.gz" > "${INITRAMFS_SPI}/${mod}.ko" + elif [ -f "${KMOD_DIR}/${mod}.ko.zst" ]; then + zstd -dc "${KMOD_DIR}/${mod}.ko.zst" > "${INITRAMFS_SPI}/${mod}.ko" + elif [ -f "${KMOD_DIR}/${mod}.ko" ]; then + cp "${KMOD_DIR}/${mod}.ko" "${INITRAMFS_SPI}/${mod}.ko" + fi + done +fi + +# Dynamic linker — needed for non-busybox tools +LD_PATH=$(find /lib /lib64 /usr/lib -name "ld-linux-*" -type f 2>/dev/null | head -1 || true) +if [ -n "${LD_PATH}" ]; then + mkdir -p "${INITRAMFS_DIR}$(dirname "${LD_PATH}")" + cp "${LD_PATH}" "${INITRAMFS_DIR}${LD_PATH}" +fi + +# Init script +cp "${INIT_SCRIPT}" "${INITRAMFS_DIR}/init" +chmod +x "${INITRAMFS_DIR}/init" + +# Metadata: paths + sizes so init script knows where to find things +cat > "${INITRAMFS_DIR}/migration_meta" </dev/null | gzip > /tmp/nixos_migration_initramfs.gz + +sudo cp /tmp/nixos_migration_initramfs.gz "${BOOT_PARTITION}/initramfs-migration.gz" + +# Migration flag on boot partition (survives root format) +sudo touch "${BOOT_PARTITION}/nixos_migration" + +progress 92 "Configuring boot" + +# --- Phase 7: Configure boot to use migration initramfs --- +if [ -f "${BOOT_PARTITION}/config.txt" ]; then + sudo cp "${BOOT_PARTITION}/config.txt" "${BOOT_PARTITION}/config.txt.premigration" + + echo "initramfs initramfs-migration.gz followkernel" | \ + sudo tee -a "${BOOT_PARTITION}/config.txt" > /dev/null +fi + +progress 100 "Rebooting in 5s..." + +echo "Migration staged. Tarball: ${TARBALL_SIZE} bytes" +echo "Rebooting in 5 seconds..." +sleep 5 +sudo reboot diff --git a/python/scripts/nixos_migration_calc.py b/python/scripts/nixos_migration_calc.py new file mode 100755 index 00000000..d3500bdc --- /dev/null +++ b/python/scripts/nixos_migration_calc.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Pre-flight checks for NixOS migration. + +Validates hardware requirements before migration can proceed. +Run on the Pi to verify it meets minimum specs. + +Usage: python3 nixos_migration_calc.py [--json] --display-class CLASS --display-resolution WxH +""" + +import argparse +import json +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +MIN_RAM_MB = 1800 # 2GB Pi reports ~1849MB due to GPU memory reservation +MIN_SD_GB = 16 +REQUIRED_MODEL = "Raspberry Pi 4" +# Must match the initramfs progress renderer, not just the main PiFinder UI. +SUPPORTED_DISPLAYS = { + "DisplaySSD1351": "128x128", + "DisplaySSD1333": "176x176", +} +# The initramfs script hardcodes these paths and unconditionally extends +# partition 2 to fill the disk. Migration only supports the stock layout. +SD_DISK = "/dev/mmcblk0" +EXPECTED_BOOT = "/dev/mmcblk0p1" +EXPECTED_ROOT = "/dev/mmcblk0p2" +EXPECTED_PARTITION_COUNT = 2 + + +def get_model() -> str: + """Read Pi model from device-tree.""" + try: + return Path("/proc/device-tree/model").read_text().rstrip("\x00").strip() + except OSError: + return "Unknown" + + +def get_ram_mb() -> int: + """Get total RAM in MB from /proc/meminfo.""" + try: + text = Path("/proc/meminfo").read_text() + match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) + if match: + return int(match.group(1)) // 1024 + except OSError: + pass + return 0 + + +def get_sd_size_gb() -> float: + """Get SD card size in GB (root device).""" + try: + result = subprocess.run( + ["lsblk", "-b", "-d", "-n", "-o", "SIZE", "/dev/mmcblk0"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return int(result.stdout.strip()) / (1024**3) + except (OSError, ValueError): + pass + return 0.0 + + +def get_free_space_gb(path: str = "/home/pifinder") -> float: + """Get free space in GB at the given path.""" + try: + usage = shutil.disk_usage(path) + return usage.free / (1024**3) + except OSError: + return 0.0 + + +def get_root_source() -> str: + """Device backing the / mount, e.g. /dev/mmcblk0p2.""" + try: + result = subprocess.run( + ["findmnt", "-no", "SOURCE", "/"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout.strip() + except OSError: + pass + return "" + + +def get_partition_count(disk: str = SD_DISK) -> int: + """Number of partitions on the SD disk node (excludes the disk itself).""" + try: + result = subprocess.run( + ["lsblk", "-no", "NAME", "-l", disk], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return 0 + lines = [line for line in result.stdout.splitlines() if line.strip()] + return max(len(lines) - 1, 0) + except OSError: + return 0 + + +def get_wifi_mode() -> str: + """Detect WiFi mode.""" + wifi_status = Path("/home/pifinder/PiFinder/wifi_status.txt") + try: + return wifi_status.read_text().strip() + except OSError: + return "Unknown" + + +def normalize_resolution(value: str) -> str: + """Normalize a live UI resolution string to WIDTHxHEIGHT.""" + match = re.fullmatch(r"\s*(\d+)\s*[x,]\s*(\d+)\s*", value) + if not match: + return value.strip() + return f"{int(match.group(1))}x{int(match.group(2))}" + + +def is_pi4() -> bool: + """Check if running on a Raspberry Pi 4.""" + model = get_model() + return REQUIRED_MODEL in model + + +def check_all(display_class: str = "", display_resolution: str = "") -> dict: + """Run all pre-flight checks. Returns dict with results.""" + model = get_model() + ram_mb = get_ram_mb() + sd_gb = get_sd_size_gb() + free_gb = get_free_space_gb() + wifi = get_wifi_mode() + display_class = display_class.strip() or "Unknown" + display_resolution = normalize_resolution(display_resolution) or "Unknown" + display_ok = SUPPORTED_DISPLAYS.get(display_class) == display_resolution + display_ok = display_ok or ( + "SSD1333" in display_class and display_resolution == "176x176" + ) + root_source = get_root_source() + partition_count = get_partition_count() + boot_present = Path(EXPECTED_BOOT).is_block_device() + layout_ok = ( + root_source == EXPECTED_ROOT + and boot_present + and partition_count == EXPECTED_PARTITION_COUNT + ) + + checks = { + "model": model, + "is_pi4": REQUIRED_MODEL in model, + "ram_mb": ram_mb, + "ram_ok": ram_mb >= MIN_RAM_MB, + "sd_gb": round(sd_gb, 1), + "sd_ok": sd_gb >= MIN_SD_GB, + "free_gb": round(free_gb, 1), + "free_ok": free_gb >= 1.5, + "wifi_mode": wifi, + "wifi_ok": wifi == "Client", + "display_class": display_class, + "display_resolution": display_resolution, + "display_ok": display_ok, + "root_source": root_source, + "partition_count": partition_count, + "boot_present": boot_present, + "layout_ok": layout_ok, + "arch": platform.machine(), + } + checks["all_ok"] = all( + [ + checks["is_pi4"], + checks["ram_ok"], + checks["sd_ok"], + checks["free_ok"], + checks["wifi_ok"], + checks["display_ok"], + checks["layout_ok"], + ] + ) + return checks + + +def main(): + parser = argparse.ArgumentParser(description="NixOS migration pre-flight checks") + parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument( + "--display-class", + default="", + help="Live PiFinder display class name from the running UI", + ) + parser.add_argument( + "--display-resolution", + default="", + help="Live PiFinder logical display resolution as WIDTHxHEIGHT", + ) + args = parser.parse_args() + + checks = check_all(args.display_class, args.display_resolution) + + if args.json: + print(json.dumps(checks, indent=2)) + sys.exit(0 if checks["all_ok"] else 1) + + print(f"Model: {checks['model']}") + print(f" Pi 4: {'OK' if checks['is_pi4'] else 'FAIL'}") + print(f"RAM: {checks['ram_mb']} MB") + print(f" >= {MIN_RAM_MB}MB: {'OK' if checks['ram_ok'] else 'FAIL'}") + print(f"SD Card: {checks['sd_gb']} GB") + print(f" >= {MIN_SD_GB}GB: {'OK' if checks['sd_ok'] else 'FAIL'}") + print(f"Free Space: {checks['free_gb']} GB") + print(f" >= 1.5GB: {'OK' if checks['free_ok'] else 'FAIL'}") + print(f"WiFi Mode: {checks['wifi_mode']}") + print(f" Client: {'OK' if checks['wifi_ok'] else 'FAIL'}") + print(f"Display: {checks['display_class']} {checks['display_resolution']}") + print( + f" initramfs renderer supported: " + f"{'OK' if checks['display_ok'] else 'FAIL'}" + ) + print(f"Root: {checks['root_source'] or 'Unknown'}") + print(f"Partitions: {checks['partition_count']} on {SD_DISK}") + print( + f" stock SD layout ({EXPECTED_BOOT} + {EXPECTED_ROOT}, 2 partitions): " + f"{'OK' if checks['layout_ok'] else 'FAIL'}" + ) + print(f"Arch: {checks['arch']}") + print() + if checks["all_ok"]: + print("All checks PASSED - migration can proceed") + else: + print("Some checks FAILED - migration cannot proceed") + + sys.exit(0 if checks["all_ok"] else 1) + + +if __name__ == "__main__": + main() diff --git a/python/scripts/nixos_migration_init.sh b/python/scripts/nixos_migration_init.sh new file mode 100755 index 00000000..385f972d --- /dev/null +++ b/python/scripts/nixos_migration_init.sh @@ -0,0 +1,468 @@ +#!/bin/busybox sh +# nixos_migration_init.sh - Initramfs init for NixOS migration +# +# Runs entirely from RAM. Strategy: +# 1. Save WiFi credentials and user backup to RAM +# 2. Copy tarball to RAM, unmount old root +# 3. Format both partitions +# 4. Extract tarball (boot → p1, rootfs → p2) +# 5. Restore WiFi + user data, expand partition +# 6. Reboot into NixOS + +set -e + +# The OLED progress display is driven through a pipe (fd 3). If that process +# ever dies, a write must not take a fatal SIGPIPE and abort the migration — +# the display is best-effort, the migration is not. +trap '' PIPE + +/bin/busybox --install -s /bin 2>/dev/null || true + +mount -t proc proc /proc 2>/dev/null || true +mount -t sysfs sysfs /sys 2>/dev/null || true +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true +mount -t tmpfs tmpfs /tmp 2>/dev/null || true + +# Load SPI modules for OLED progress display +if [ -f /lib/modules/spi-bcm2835.ko ]; then + insmod /lib/modules/spi-bcm2835.ko 2>/dev/null || true + insmod /lib/modules/spidev.ko 2>/dev/null || true + sleep 0.5 +fi + +# Shared lib path for dynamically linked tools (e2fsck, mkfs, etc.) +export LD_LIBRARY_PATH=/lib:/usr/lib:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu + +BOOT_DEV="/dev/mmcblk0p1" +ROOT_DEV="/dev/mmcblk0p2" +SD_DEV="/dev/mmcblk0" +MOUNT_ROOT="/mnt/root" +MOUNT_NEW="/mnt/new" +MOUNT_BOOT="/mnt/boot" +PROGRESS="/bin/migration_progress" + +STAGE_NUM=0 +STAGE_TOTAL=22 +PROGRESS_FIFO="/tmp/migration_progress.fifo" +PROGRESS_READY=0 + +# Drive the OLED with a single long-lived process. It initialises the panel +# once and redraws in place from stdin, so the display never resets to black +# between stages (a fresh process per stage would re-assert the panel's RST +# line and blank it). fd 3 is the persistent writer; keeping it open stops the +# server seeing EOF until the migration is done. +if [ -x "${PROGRESS}" ]; then + rm -f "${PROGRESS_FIFO}" + mkfifo "${PROGRESS_FIFO}" 2>/dev/null || true + "${PROGRESS}" --serve < "${PROGRESS_FIFO}" >/dev/null 2>&1 & + exec 3>"${PROGRESS_FIFO}" + PROGRESS_READY=1 +fi + +show() { + local pct="$1" + local msg="$2" + STAGE_NUM=$((STAGE_NUM + 1)) + echo "[${pct}%] ${msg}" > /dev/console 2>/dev/null || true + echo "[${pct}%] ${msg}" + if [ "${PROGRESS_READY}" -eq 1 ]; then + echo "${pct} ${STAGE_NUM} ${STAGE_TOTAL} ${msg}" >&3 2>/dev/null || true + fi +} + +fail() { + if [ "${PROGRESS_READY}" -eq 1 ]; then + echo "0 0 0 FAILED: $1" >&3 2>/dev/null || true + fi + echo "[FAILED] $1" + echo "MIGRATION FAILED: $1" > /dev/console 2>/dev/null || true + echo "Dropping to shell for debugging..." + exec /bin/sh +} + +if [ -f /migration_meta ]; then + . /migration_meta + export MIGRATION_DISPLAY_CLASS="${DISPLAY_CLASS:-}" + export MIGRATION_DISPLAY_RESOLUTION="${DISPLAY_RESOLUTION:-}" +fi + +show 28 "Migrating..." + +# Wait for SD card device to appear +n=0 +while [ ! -b "${BOOT_DEV}" ] && [ "${n}" -lt 30 ]; do + sleep 1 + n=$((n + 1)) +done +[ ! -b "${BOOT_DEV}" ] && fail "SD card not found after 30s: ${BOOT_DEV}" + +show 30 "Initramfs started" + +# ------------------------------------------------------------------- +# Phase 1: Validate +# ------------------------------------------------------------------- + +# Check migration flag on boot partition +mkdir -p /mnt/bootchk +mount -t vfat -o ro "${BOOT_DEV}" /mnt/bootchk || fail "Cannot mount boot" +if [ ! -f /mnt/bootchk/nixos_migration ]; then + umount /mnt/bootchk + fail "No migration flag — aborting" +fi +umount /mnt/bootchk + +# Read metadata written by pre-migration script +if [ ! -f /migration_meta ]; then + fail "migration_meta not found in initramfs" +fi +. /migration_meta +# Now we have: TARBALL_PATH, TARBALL_SIZE, PIFINDER_DATA_PATH + +# Initial RAM check: tarball + fixed overhead must fit. The exact user-data +# backup size is checked after the old root is mounted, before formatting. +MEM_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo) +MEM_MB=$((MEM_KB / 1024)) +TARBALL_SIZE_MB=$((TARBALL_SIZE / 1048576)) +NEEDED_MB=$((TARBALL_SIZE_MB + 150)) +[ "${MEM_MB}" -lt "${NEEDED_MB}" ] && fail "Insufficient RAM: ${MEM_MB}MB available, need ${NEEDED_MB}MB" + +show 31 "Validated: ${MEM_MB}MB" + +# ------------------------------------------------------------------- +# Phase 2: Save WiFi credentials to RAM +# ------------------------------------------------------------------- + +show 33 "Saving WiFi to RAM" + +mkdir -p "${MOUNT_ROOT}" +mount -t ext4 -o ro "${ROOT_DEV}" "${MOUNT_ROOT}" || fail "Cannot mount root" + +mkdir -p /tmp/wifi +WPA_FILE="${MOUNT_ROOT}/etc/wpa_supplicant/wpa_supplicant.conf" +if [ -f "${WPA_FILE}" ]; then + cp "${WPA_FILE}" /tmp/wifi/wpa_supplicant.conf +fi + +NM_SRC="${MOUNT_ROOT}/etc/NetworkManager/system-connections" +if [ -d "${NM_SRC}" ]; then + mkdir -p /tmp/wifi/nm-connections + cp -a "${NM_SRC}/." /tmp/wifi/nm-connections/ 2>/dev/null || true +fi + +# ------------------------------------------------------------------- +# Phase 3: Create user backup in RAM +# ------------------------------------------------------------------- + +show 35 "Creating backup" + +PIFINDER_DATA_ON_ROOT="${MOUNT_ROOT}${PIFINDER_DATA_PATH}" +BACKUP_STAGE="/tmp/backup_stage/PiFinder_data" +rm -rf /tmp/backup_stage +mkdir -p "${BACKUP_STAGE}" + +if [ -d "${PIFINDER_DATA_ON_ROOT}" ]; then + BACKUP_NEED_KB=0 + + # Root-level files are preserved, except pifinder.log which is truncated + # while copying so a large log cannot exhaust initramfs RAM. + for f in "${PIFINDER_DATA_ON_ROOT}"/*; do + if [ -f "$f" ]; then + case "$(basename "$f")" in + pifinder.log) + BACKUP_NEED_KB=$((BACKUP_NEED_KB + 256)) + ;; + *) + FILE_KB=$(du -sk "$f" 2>/dev/null | awk '{print $1}') + BACKUP_NEED_KB=$((BACKUP_NEED_KB + ${FILE_KB:-0})) + ;; + esac + fi + done + if [ -d "${PIFINDER_DATA_ON_ROOT}/obslists" ]; then + OBSLISTS_KB=$(du -sk "${PIFINDER_DATA_ON_ROOT}/obslists" 2>/dev/null | awk '{print $1}') + BACKUP_NEED_KB=$((BACKUP_NEED_KB + ${OBSLISTS_KB:-0})) + fi + + MEM_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo) + TARBALL_KB=$((TARBALL_SIZE / 1024)) + # Keep a conservative 150 MiB for tools, page cache, and shell overhead. + NEEDED_KB=$((TARBALL_KB + BACKUP_NEED_KB + 153600)) + [ "${MEM_KB}" -lt "${NEEDED_KB}" ] && fail "Insufficient RAM for backup: $((MEM_KB / 1024))MB available, need $((NEEDED_KB / 1024))MB" + + # Copy root-level files (observations.db, configs, etc.) + for f in "${PIFINDER_DATA_ON_ROOT}"/*; do + if [ -f "$f" ]; then + if [ "$(basename "$f")" = "pifinder.log" ]; then + tail -n 1000 "$f" > "${BACKUP_STAGE}/pifinder.log" 2>/dev/null || true + else + cp "$f" "${BACKUP_STAGE}/" 2>/dev/null || true + fi + fi + done + + # Copy obslists directory + if [ -d "${PIFINDER_DATA_ON_ROOT}/obslists" ]; then + cp -a "${PIFINDER_DATA_ON_ROOT}/obslists" "${BACKUP_STAGE}/obslists" + fi +fi + +# Preserve the pre-migration hostname: Pi OS stores it in /etc/hostname, the NixOS +# image reads it from PiFinder_data/hostname. Bridge them; don't clobber an existing one. +if [ ! -f "${BACKUP_STAGE}/hostname" ] && [ -s "${MOUNT_ROOT}/etc/hostname" ]; then + head -n1 "${MOUNT_ROOT}/etc/hostname" | tr -d '[:space:]' > "${BACKUP_STAGE}/hostname" +fi + +show 38 "Backup created" + +# ------------------------------------------------------------------- +# Phase 4: Copy tarball to RAM, unmount old root +# ------------------------------------------------------------------- + +show 40 "Loading tarball" + +TARBALL_ON_ROOT="${MOUNT_ROOT}${TARBALL_PATH}" +[ ! -f "${TARBALL_ON_ROOT}" ] && { umount "${MOUNT_ROOT}"; fail "Tarball not found: ${TARBALL_PATH}"; } + +cp "${TARBALL_ON_ROOT}" /tmp/migration.tar.zst || fail "Failed to copy tarball to RAM" +umount "${MOUNT_ROOT}" + +show 48 "Tarball loaded to RAM" + +# ------------------------------------------------------------------- +# Phase 5: Expand + format partitions +# ------------------------------------------------------------------- + +show 49 "Expanding partition" + +# Expand partition 2 BEFORE formatting — sfdisk rewrites the MBR and +# blockdev --rereadpt can corrupt a written FAT partition if done after. +echo ", +" | sfdisk -N 2 "${SD_DEV}" --no-reread 2>/dev/null || true +blockdev --rereadpt "${SD_DEV}" 2>/dev/null || true +sleep 1 + +show 50 "Formatting boot" + +mkfs.vfat -F 32 -n FIRMWARE "${BOOT_DEV}" || fail "mkfs.vfat failed" + +show 52 "Formatting root" + +mkfs.ext4 -F -L NIXOS_SD "${ROOT_DEV}" || fail "mkfs.ext4 failed" + +# ------------------------------------------------------------------- +# Phase 6: Extract tarball +# ------------------------------------------------------------------- + +show 55 "Extracting NixOS" + +mkdir -p "${MOUNT_NEW}" +mount -t ext4 "${ROOT_DEV}" "${MOUNT_NEW}" || fail "Cannot mount new root" + +# Extract tarball directly to SD card (ext4 has plenty of space, tmpfs does not) +zstd -d < /tmp/migration.tar.zst | tar xf - -C "${MOUNT_NEW}" || fail "Tarball extraction failed" +rm -f /tmp/migration.tar.zst + +show 60 "Moving rootfs" + +# Move rootfs/ contents up to partition root (same-fs rename, fast) +cd "${MOUNT_NEW}/rootfs" +for item in * .[!.]* ..?*; do + [ -e "$item" ] || continue + mv "$item" "${MOUNT_NEW}/" +done +cd / +rmdir "${MOUNT_NEW}/rootfs" + +# NetworkManager (like other security-sensitive plugin loaders) refuses to load +# any plugin file not owned by root, so a /nix/store with non-root paths baked +# into the tarball silently kills wifi (wlan0 ends up "unmanaged"). Normalise +# store ownership to root now, while the new root is still writable — once +# NixOS boots /nix/store is mounted read-only. (The boot-time +# fix-nix-store-ownership service is the runtime backstop for this.) +chown -R 0:0 "${MOUNT_NEW}/nix/store" "${MOUNT_NEW}/nix/var/nix/db" 2>/dev/null || true + +show 66 "Copying boot" + +mkdir -p "${MOUNT_BOOT}" +mount -t vfat "${BOOT_DEV}" "${MOUNT_BOOT}" || fail "Cannot mount boot" + +# Copy boot files to FAT partition +cd "${MOUNT_NEW}/boot" +for item in *; do + [ -e "$item" ] || continue + if [ -d "$item" ]; then + cp -r "$item" "${MOUNT_BOOT}/$item" + else + cp "$item" "${MOUNT_BOOT}/$item" + fi +done +cd / +sync + +# Verify critical boot files landed +if [ ! -f "${MOUNT_BOOT}/extlinux/extlinux.conf" ]; then + echo "Boot partition contents:" >&2 + ls -lR "${MOUNT_BOOT}" >&2 + fail "extlinux.conf missing from boot partition after copy" +fi + +# Keep boot/ on ext4 — U-Boot reads extlinux.conf from mmc 0:2 (ext4 root) +# FAT partition only needs RPi firmware files (config.txt, u-boot, DTBs) + +# ------------------------------------------------------------------- +# Phase 7: Migrate WiFi +# ------------------------------------------------------------------- + +show 70 "Migrating WiFi" + +NM_DIR="${MOUNT_NEW}/etc/NetworkManager/system-connections" +mkdir -p "${NM_DIR}" + +# NM keyfile helpers — SSID becomes hex bytes (safe for any content), +# PSK gets minimal keyfile escaping (backslash + semicolon), filename +# is sanitized so a hostile or unusual SSID can't escape NM_DIR. +ssid_to_hex() { + printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | sed 's/\(..\)/\1;/g' +} +escape_kf() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' +} +sanitize_fn() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' +} + +if [ -f /tmp/wifi/wpa_supplicant.conf ]; then + SSID="" + PSK="" + IN_NET=0 + + while IFS= read -r line; do + line=$(printf '%s' "${line}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + + case "${line}" in + network=*) + IN_NET=1 + SSID="" + PSK="" + ;; + "}") + if [ "${IN_NET}" = "1" ] && [ -n "${SSID}" ]; then + SSID_HEX=$(ssid_to_hex "${SSID}") + ID_ESC=$(escape_kf "${SSID}") + FN=$(sanitize_fn "${SSID}") + # Guard against empty/dotfile filenames after sanitization + case "${FN}" in + ""|.|..) FN="wifi" ;; + esac + NM_FILE="${NM_DIR}/${FN}.nmconnection" + + if [ -n "${PSK}" ]; then + PSK_ESC=$(escape_kf "${PSK}") + cat > "${NM_FILE}" < "${NM_FILE}" </dev/null || true +fi + +sync + +show 74 "WiFi migrated" + +# ------------------------------------------------------------------- +# Phase 8: Restore user data +# ------------------------------------------------------------------- + +show 76 "Restoring user data" + +mkdir -p "${MOUNT_NEW}/home/pifinder" + +if [ -d /tmp/backup_stage/PiFinder_data ]; then + cp -a /tmp/backup_stage/PiFinder_data "${MOUNT_NEW}/home/pifinder/" +fi + +# pifinder user: UID 1000, GID 100 (users) on NixOS +chown -R 1000:100 "${MOUNT_NEW}/home/pifinder" 2>/dev/null || true + +show 80 "User data restored" + +# ------------------------------------------------------------------- +# Phase 9: Expand partition and finalize +# ------------------------------------------------------------------- + +umount "${MOUNT_BOOT}" 2>/dev/null || true +umount "${MOUNT_NEW}" 2>/dev/null || true + +show 82 "Resizing filesystem" + +e2fsck -f -y "${ROOT_DEV}" 2>/dev/null || true +resize2fs "${ROOT_DEV}" 2>/dev/null || true + +show 92 "Syncing" +sync + +# Final verification: remount boot partition and confirm extlinux.conf survived +show 95 "Verifying boot" +mkdir -p /mnt/bootchk +mount -t vfat -o ro "${BOOT_DEV}" /mnt/bootchk || fail "Cannot remount boot for verification" +if [ ! -f /mnt/bootchk/extlinux/extlinux.conf ]; then + ls -lR /mnt/bootchk > /dev/console 2>&1 || true + umount /mnt/bootchk 2>/dev/null || true + fail "extlinux.conf missing from boot partition before reboot" +fi +umount /mnt/bootchk + +show 100 "Complete" +sleep 3 + +echo "Rebooting into NixOS..." > /dev/console 2>/dev/null || true +reboot -f diff --git a/python/tests/test_software.py b/python/tests/test_software.py new file mode 100644 index 00000000..ba73e33d --- /dev/null +++ b/python/tests/test_software.py @@ -0,0 +1,151 @@ +from unittest.mock import patch, MagicMock + +import pytest +import requests + +from PiFinder.ui.software import ( + update_needed, + _strip_markdown, + _fetch_migration_config, + _UNLOCK_SEQUENCE, +) + + +_NIXOS_URL = "https://example.invalid/pifinder-nixos.tar.zst" + + +@pytest.mark.unit +class TestUpdateNeeded: + def test_newer_version_available(self): + assert update_needed("2.3.0", "2.4.0") is True + + def test_same_version(self): + assert update_needed("2.4.0", "2.4.0") is False + + def test_older_version(self): + assert update_needed("2.5.0", "2.4.0") is False + + def test_major_version_bump(self): + assert update_needed("1.9.9", "2.0.0") is True + + def test_patch_bump(self): + assert update_needed("2.4.0", "2.4.1") is True + + def test_garbage_input_returns_true(self): + assert update_needed("garbage", "2.4.0") is True + + def test_empty_string_returns_true(self): + assert update_needed("", "") is True + + def test_partial_version_returns_true(self): + assert update_needed("2.4", "2.5.0") is True + + def test_unknown_returns_true(self): + assert update_needed("2.4.0", "Unknown") is True + + +@pytest.mark.unit +class TestUnlockSequence: + def test_sequence_length(self): + assert len(_UNLOCK_SEQUENCE) == 7 + + def test_sequence_content(self): + assert _UNLOCK_SEQUENCE == ["square"] * 7 + + +@pytest.mark.unit +class TestStripMarkdown: + def test_removes_headings(self): + assert _strip_markdown("# Hello") == "Hello" + assert _strip_markdown("## Sub") == "Sub" + + def test_removes_bold(self): + assert _strip_markdown("**bold**") == "bold" + + def test_removes_italic(self): + assert _strip_markdown("*italic*") == "italic" + + def test_removes_links(self): + assert _strip_markdown("[text](http://example.com)") == "text" + + def test_removes_backticks(self): + assert _strip_markdown("`code`") == "code" + + def test_preserves_plain_text(self): + assert _strip_markdown("Hello world") == "Hello world" + + def test_multiline(self): + md = "# Title\n\nSome **bold** text.\n- item" + result = _strip_markdown(md) + assert "Title" in result + assert "bold" in result + assert "**" not in result + + +def _mock_json_response(payload, status_code=200): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = payload + return resp + + +def _mock_invalid_json_response(status_code=200): + resp = MagicMock() + resp.status_code = status_code + resp.json.side_effect = ValueError("not json") + return resp + + +@pytest.mark.unit +class TestFetchMigrationConfig: + @patch("PiFinder.ui.software.requests.get") + def test_returns_dict_when_gate_open_and_url_set(self, mock_get): + payload = {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL} + mock_get.return_value = _mock_json_response(payload) + assert _fetch_migration_config() == payload + + @patch("PiFinder.ui.software.requests.get") + def test_returns_dict_when_gate_closed_but_url_set(self, mock_get): + # Gate check is the caller's job; fetch only requires nixos_url. + payload = {"nixos_for_everyone": False, "nixos_url": _NIXOS_URL} + mock_get.return_value = _mock_json_response(payload) + assert _fetch_migration_config() == payload + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_when_url_missing(self, mock_get): + mock_get.return_value = _mock_json_response({"nixos_for_everyone": True}) + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_when_url_empty(self, mock_get): + mock_get.return_value = _mock_json_response( + {"nixos_for_everyone": True, "nixos_url": ""} + ) + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_http_error(self, mock_get): + mock_get.return_value = _mock_json_response( + {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL}, status_code=404 + ) + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_connection_error(self, mock_get): + mock_get.side_effect = requests.exceptions.ConnectionError + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_timeout(self, mock_get): + mock_get.side_effect = requests.exceptions.Timeout + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_malformed_json(self, mock_get): + mock_get.return_value = _mock_invalid_json_response() + assert _fetch_migration_config() is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_when_payload_is_not_object(self, mock_get): + mock_get.return_value = _mock_json_response(["nixos_for_everyone"]) + assert _fetch_migration_config() is None diff --git a/python/tests/test_ui_modules.py b/python/tests/test_ui_modules.py index f6bb175a..e94a5400 100644 --- a/python/tests/test_ui_modules.py +++ b/python/tests/test_ui_modules.py @@ -88,6 +88,7 @@ from PiFinder.ui.sqm_calibration import UISQMCalibration from PiFinder.ui.sqm_sweep import UISQMSweep from PiFinder.ui.sqm_correction import UISQMCorrection +from PiFinder.ui.software import UIMigrationConfirm, UIMigrationProgress # --------------------------------------------------------------------------- # @@ -121,7 +122,8 @@ # UIModule subclasses that are intentionally *not* exercised, with the reason. # Keeps the completeness guard (test_all_ui_modules_covered) honest. _COVERAGE_SKIP: dict[str, str] = { - # (none currently -- UISQMCorrection is covered via the dynamic fixtures) + # (UISQMCorrection is covered via the dynamic fixtures) + "UIReleaseNotes": "fetches markdown via HTTP in active(); needs a network mock", } # Bound on the auto-sweep so a handler that keeps pushing modules @@ -183,6 +185,8 @@ def _node_id(node) -> str: "UISQMCalibration", "UISQMSweep", "UISQMCorrection", + "UIMigrationConfirm", + "UIMigrationProgress", ] @@ -226,6 +230,23 @@ def _build_dynamic_item_definition(spec_id: str, sample_object) -> dict: "class": UISQMCorrection, "label": "sqm_correction", } + if spec_id == "UIMigrationConfirm": + # Pushed by UISoftware.key_square() after a 7x-square unlock. + return { + "name": "Confirm Migration", + "class": UIMigrationConfirm, + "version_info": {"version": "2.5.0"}, + "current_version": "2.4.0", + "label": "migration_confirm", + } + if spec_id == "UIMigrationProgress": + # Pushed by UIMigrationConfirm after the user confirms. + return { + "name": "Migration Progress", + "class": UIMigrationProgress, + "version_info": {"version": "2.5.0"}, + "label": "migration_progress", + } raise KeyError(spec_id) # pragma: no cover