diff --git a/.gitignore b/.gitignore index d53e06f..e83dfce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .direnv/ result +result-* diff --git a/Makefile b/Makefile index 423b8d0..1c94542 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ MIXTAPE ?= coder ARCH ?= SSH_PORT ?= 2222 +SSH_KEY ?= define require-arch $(if $(ARCH),,$(error ARCH is required. Use ARCH=aarch64-linux or ARCH=x86_64-linux)) @@ -36,6 +37,30 @@ build-kernel: ## Build kernel artifacts for kernel boot $(call require-arch) nix build .#packages.$(ARCH).$(MIXTAPE)-kernel-artifacts --impure +# -- Raspberry Pi image builds ----------------------------------------------- +# +# RPi images are aarch64-only and ship as a single SD card image. +# The attr name adds "-rpi4-sd" / "-rpi5-sd" to the mixtape +# (e.g. coder → coder-rpi4-sd, coder → coder-rpi5-sd). + +.PHONY: build-rpi4 +build-rpi4: ## Build an SD card image for Raspberry Pi 4 (MIXTAPE=coder by default) + nix build .#packages.aarch64-linux.$(MIXTAPE)-rpi4-sd --impure -o result-rpi4 + +.PHONY: flash-rpi4 +flash-rpi4: ## Flash RPi4 SD image to an SD card (SSH_KEY=~/.ssh/id_ed25519.pub optional) + @./scripts/flash-rpi.sh --board rpi4 \ + $(if $(SSH_KEY),--ssh-key $(SSH_KEY)) + +.PHONY: build-rpi5 +build-rpi5: ## Build an SD card image for Raspberry Pi 5 (MIXTAPE=coder by default) + nix build .#packages.aarch64-linux.$(MIXTAPE)-rpi5-sd --impure -o result-rpi5 + +.PHONY: flash-rpi5 +flash-rpi5: ## Flash RPi5 SD image to an SD card (SSH_KEY=~/.ssh/id_ed25519.pub optional) + @./scripts/flash-rpi.sh --board rpi5 \ + $(if $(SSH_KEY),--ssh-key $(SSH_KEY)) + # -- VM development operations ------------------------------------------------ .PHONY: run @@ -78,6 +103,7 @@ help: ## Show this help message @echo " MIXTAPE=$(MIXTAPE)" @echo " ARCH=$(ARCH)" @echo " SSH_PORT=$(SSH_PORT)" + @echo " SSH_KEY=$(SSH_KEY)" define print-target @printf "Executing target: \033[36m$@\033[0m\n" diff --git a/flake.lock b/flake.lock index e5f5e22..381d723 100644 --- a/flake.lock +++ b/flake.lock @@ -116,6 +116,22 @@ "type": "github" } }, + "nixos-hardware": { + "locked": { + "lastModified": 1777914779, + "narHash": "sha256-otbZpzOc2OplHh09iETXenKoJYp2dhfGFAWoQ2Z+Tcg=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "f1b7ff92cdd107c6c34d3015fbb24bd65161426d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "f1b7ff92cdd1", + "repo": "nixos-hardware", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1772047000, @@ -168,6 +184,7 @@ "agentd": "agentd", "dagger": "dagger", "flake-parts": "flake-parts", + "nixos-hardware": "nixos-hardware", "nixpkgs": "nixpkgs", "nixpkgs-unstable": "nixpkgs-unstable", "stereosd": "stereosd" diff --git a/flake.nix b/flake.nix index 4c3ddf2..c310a3e 100644 --- a/flake.nix +++ b/flake.nix @@ -5,6 +5,19 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; + + # Raspberry Pi 4 / 5 configuration. The Pi 5 module sets + # boot.kernelPackages to the rpi-vendor 6.12.x kernel (built with + # bcm2712_defconfig and the RP1-southbridge initrd modules); without + # this input there is no Pi 5 kernel in nixpkgs at our pinned rev. + # + # Pinned to f1b7ff92cdd1 — the last commit before nixos-hardware#1841 + # replaced the kernel's postConfigure sed with `LOCALVERSION = freeform ""`, + # which expands through nixpkgs' kernel-config emitter as the literal + # two characters `""`, breaking modDirVersion at build time. Tracked + # in nixos-hardware#1859; fix in #1860 is open but unmerged. Revisit + # the pin once #1860 (or any later fix) lands on master. + nixos-hardware.url = "github:NixOS/nixos-hardware/f1b7ff92cdd1"; dagger.url = "github:dagger/nix"; dagger.inputs.nixpkgs.follows = "nixpkgs"; diff --git a/flake/images.nix b/flake/images.nix index 0f68446..f28494f 100644 --- a/flake/images.nix +++ b/flake/images.nix @@ -9,9 +9,15 @@ # packages..-kernel-artifacts (direct-kernel boot) # packages..-dist (all formats + mixtape.toml) # +# RPi mixtapes only emit an SD card image, suffixed by board: +# packages.aarch64-linux.-rpi4-sd (Pi 4 SD image) +# packages.aarch64-linux.-rpi5-sd (Pi 5 SD image) +# # Build with: # nix build .#packages.aarch64-linux.coder --impure # nix build .#packages.x86_64-linux.coder --impure +# nix build .#packages.aarch64-linux.coder-rpi4-sd --impure +# nix build .#packages.aarch64-linux.coder-rpi5-sd --impure { self, inputs, ... }: @@ -32,6 +38,37 @@ let { name = "coder-dev"; features = [ ../mixtapes/coder/package.nix ]; extraModules = [ ../profiles/dev.nix ]; } ]; + # RPi 4 mixtape definitions — aarch64-only, SD image output. + # stereos.rpi.series defaults to "rpi4" so no inline override is needed. + rpi4MixtapeSpecs = [ + { name = "base-rpi4"; features = [ ../mixtapes/base/package.nix ]; extraModules = [ ../profiles/rpi.nix ]; } + { name = "coder-rpi4"; features = [ ../mixtapes/coder/package.nix ]; extraModules = [ ../profiles/rpi.nix ]; } + { name = "base-rpi4-dev"; features = [ ../mixtapes/base/package.nix ]; extraModules = [ ../profiles/rpi.nix ../profiles/dev.nix ]; } + { name = "coder-rpi4-dev"; features = [ ../mixtapes/coder/package.nix ]; extraModules = [ ../profiles/rpi.nix ../profiles/dev.nix ]; } + ]; + + # RPi 5 mixtape definitions — aarch64-only, SD image output. + # Each spec sets stereos.rpi.series = "rpi5" inline so modules/rpi.nix + # writes the Pi 5 firmware partition + [pi5] config.txt block, and pulls + # in nixos-hardware.raspberry-pi-5 for the rpi-vendor 6.12.x kernel + # (nixpkgs has no Pi 5 kernel package at our pinned rev). + rpi5MixtapeSpecs = + let + rpi5Modules = [ + ../profiles/rpi.nix + { stereos.rpi.series = "rpi5"; } + inputs.nixos-hardware.nixosModules.raspberry-pi-5 + # Skip modules that all-hardware.nix lists but the rpi-vendor + # kernel builds as =y. See modules/rpi5-kernel-overlay.nix. + ../modules/rpi5-kernel-overlay.nix + ]; + in [ + { name = "base-rpi5"; features = [ ../mixtapes/base/package.nix ]; extraModules = rpi5Modules; } + { name = "coder-rpi5"; features = [ ../mixtapes/coder/package.nix ]; extraModules = rpi5Modules; } + { name = "base-rpi5-dev"; features = [ ../mixtapes/base/package.nix ]; extraModules = rpi5Modules ++ [ ../profiles/dev.nix ]; } + { name = "coder-rpi5-dev"; features = [ ../mixtapes/coder/package.nix ]; extraModules = rpi5Modules ++ [ ../profiles/dev.nix ]; } + ]; + # Helper to build packages for a given system buildSystemImages = system: let @@ -88,8 +125,23 @@ let }; }) mixtapeNames ); + # RPi SD images — aarch64-linux only. + mkSdImagePkgs = specs: + if system == "aarch64-linux" then + builtins.listToAttrs ( + builtins.map (spec: { + name = "${spec.name}-sd"; + value = (stereos-main.mkMixtape { + inherit system; + inherit (spec) name features extraModules; + }).config.system.build.sdImage; + }) specs + ) + else {}; + rpi4Pkgs = mkSdImagePkgs rpi4MixtapeSpecs; + rpi5Pkgs = mkSdImagePkgs rpi5MixtapeSpecs; in - rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs; + rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs // rpi4Pkgs // rpi5Pkgs; # Build packages for all target systems allPackages = builtins.listToAttrs ( diff --git a/formats/rpi-sd-image.nix b/formats/rpi-sd-image.nix new file mode 100644 index 0000000..239e8d1 --- /dev/null +++ b/formats/rpi-sd-image.nix @@ -0,0 +1,87 @@ +# formats/rpi-sd-image.nix +# +# SD card image format for Raspberry Pi. +# Produces system.build.sdImage — an MBR-partitioned raw image with a +# FAT32 /boot partition (U-Boot + kernel + DTB) and an ext4 root. +# +# Build with: +# nix build .#packages.aarch64-linux.-sd --impure +# +# Flash with: +# zstd -d stereos--rpi4.img.zst -o stereos.img +# dd if=stereos.img of=/dev/ bs=4M status=progress + +{ config, lib, pkgs, modulesPath, ... }: + +let + # User-facing template for the first-boot key drop-in. See + # modules/firstboot-keys.nix for the service that consumes it. + authorizedKeysTemplate = pkgs.writeText "ssh_authorized_keys.txt" '' + # stereOS authorized_keys (first-boot drop-in) + # + # Add one SSH public key per line, in the standard OpenSSH + # authorized_keys format, e.g. + # + # ssh-ed25519 AAAA... matt@laptop + # ssh-rsa AAAAB3... workstation + # + # On every boot, stereos-firstboot-keys.service reads this file and + # appends any new keys to /home/admin/.ssh/authorized_keys before + # sshd starts. Blank lines and "#" comments are ignored. Duplicate + # keys are skipped. Safe to leave empty. + # + # This file lives on the FAT32 "FIRMWARE" partition of the SD card + # and can be edited directly from macOS / Windows / Linux with the + # card plugged into any computer. + ''; +in +{ + imports = [ + # Provides system.build.sdImage, U-Boot extlinux bootloader setup, + # Raspberry Pi firmware in /boot, and fileSystems entries for + # NIXOS_SD (root) and FIRMWARE (boot). + "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" + ]; + + # `sdImage.imageBaseName` was renamed to `image.baseName` in nixpkgs 25.05. + image.baseName = "stereos-${config.networking.hostName}"; + + # Uncompressed — matches raw-efi.nix convention so downstream tools + # (dd, vm runners) can consume the artifact directly. + sdImage.compressImage = false; + + # Drop the template onto the FIRMWARE partition. populateFirmwareCommands + # is declared as types.lines in nixpkgs, so this concatenates with the + # commands sd-image-aarch64.nix already installs. + sdImage.populateFirmwareCommands = '' + cp ${authorizedKeysTemplate} firmware/ssh_authorized_keys.txt + ''; + + # nixpkgs sd-image.nix mounts /boot/firmware with `noauto` because the + # FAT partition only holds RPi bootloader blobs consumed by the GPU + # firmware — nothing on the running Linux system needs it. We use it as + # the drop-in surface for user-editable config (ssh_authorized_keys.txt, + # future knobs), so override the options to mount it at boot. `nofail` + # stays so a corrupt/missing FAT doesn't block boot. + fileSystems."/boot/firmware".options = lib.mkForce [ "nofail" ]; + + # Disable fsck on the FIRMWARE partition. systemd auto-generates a + # systemd-fsck@... unit for every fstab entry with a nonzero passno, + # and failures there cascade into "Dependency failed for /boot/firmware" + # on the mount unit. vfat's dirty-bit set by unclean shutdowns is a + # frequent source; the partition holds small config files we're willing + # to re-flash if it ever corrupts. + fileSystems."/boot/firmware".noCheck = true; + + # The sd-image build populates ./files/boot/... into the ext4 root, so + # /boot exists on the installed system but /boot/firmware does NOT — + # and systemd won't mount onto a missing path (nofail then swallows the + # error). systemd-tmpfiles runs after local-fs.target, so tmpfiles rules + # can't create the mount point in time. Instead bake the empty directory + # into the root filesystem at image build time. + # populateRootCommands is types.lines, so this concatenates with the + # sd-image-aarch64.nix block that writes extlinux.conf. + sdImage.populateRootCommands = '' + mkdir -p ./files/boot/firmware + ''; +} diff --git a/modules/boot.nix b/modules/boot.nix index 31bfb76..5efcfb5 100644 --- a/modules/boot.nix +++ b/modules/boot.nix @@ -18,13 +18,19 @@ let isAarch64 = pkgs.system == "aarch64-linux"; + + # VM-target gate. Real hardware images (the RPi4 SD image) enable the + # generic extlinux-compatible loader, so we use that as the signal that + # this build is NOT a QEMU / Apple-VF guest and should skip the virtio- + # only initrd, vsock wiring, and other VM-only optimizations below. + isVmTarget = !config.boot.loader.generic-extlinux-compatible.enable; in { # -- Boot ------------------------------------------------------------------ # efiInstallAsRemovable=true puts GRUB at /EFI/BOOT/BOOTAA64.EFI (aarch64) # or /EFI/BOOT/BOOTX64.EFI (x86_64), which is the fallback path # QEMU's UEFI firmware searches. The correct filename is determined by grub-install. - boot.loader.grub = { + boot.loader.grub = lib.mkIf isVmTarget { enable = true; efiSupport = true; efiInstallAsRemovable = true; @@ -46,13 +52,19 @@ in # tty0 — virtual terminal # # With this ordering, hvc0/ttyS0 is /dev/console. + # quiet + loglevel=0 silence the kernel for fast/clean VM boots — on real + # RPi4 hardware we want the messages on HDMI + serial so a hung or failing + # boot is diagnosable. boot.kernelParams = lib.mkMerge [ - (lib.mkBefore [ "quiet" "loglevel=0" ]) + (lib.mkIf isVmTarget (lib.mkBefore [ "quiet" "loglevel=0" ])) (if isAarch64 then [ "console=tty0" "console=ttyAMA0,115200" "console=hvc0" ] else [ "console=tty0" "console=ttyS0,115200" ]) ]; - boot.growPartition = true; + # VM disk images grow on first boot via cloud-utils growpart. The RPi4 + # sd-image build already handles partition expansion via sdImage.expandOnBoot, + # so growpart only runs (and only makes sense) on the VM target. + boot.growPartition = lib.mkIf isVmTarget true; # ============================================================ # Phase 1: High-Impact, Low-Effort @@ -65,11 +77,15 @@ in boot.initrd.systemd.enable = true; # Silence kernel output — no printk spam on the serial console during boot. - boot.consoleLogLevel = 0; + # Real hardware needs the printk stream visible (HDMI + UART) so failures + # can be diagnosed. + boot.consoleLogLevel = lib.mkIf isVmTarget 0; # Restrict initrd to only the kernel modules needed for virtio-backed VMs. # This keeps the initrd small and avoids probing irrelevant hardware. - boot.initrd.availableKernelModules = lib.mkForce [ + # Real-hardware builds (RPi4) fall through to nixpkgs' all-hardware.nix + # defaults (pulled in by sd-image.nix) and must not be narrowed here. + boot.initrd.availableKernelModules = lib.mkIf isVmTarget (lib.mkForce [ "virtio_blk" "virtio_pci" "virtio_net" @@ -84,16 +100,16 @@ in "ext4" "erofs" "overlay" - ]; + ]); # Nothing force-loaded at initrd time — let systemd-udevd handle it. - boot.initrd.kernelModules = lib.mkForce []; + boot.initrd.kernelModules = lib.mkIf isVmTarget (lib.mkForce []); # Force-load vsock transport in the real root so it is available before # stereosd starts. udev does not automatically load vsock modules because # the virtio-socket device doesn't trigger a modalias match for the # transport layer. Without this, stereosd's VsockTransportAvailable() # check fails and it falls back to TCP. - boot.kernelModules = [ "vmw_vsock_virtio_transport" ]; + boot.kernelModules = lib.mkIf isVmTarget [ "vmw_vsock_virtio_transport" ]; # Use systemd-networkd for networking instead of scripted ifup. # Pairs with disabling the wait-online stall below. @@ -107,11 +123,26 @@ in # QEMU's SLIRP stack provides a DHCP server at 10.0.2.2. systemd.network.networks."10-ethernet" = { matchConfig.Type = "ether"; + linkConfig = { + # RequiredForOnline=routable means wait-online only succeeds when + # the link has a routable DHCP/static address — NOT when IPv4LL + # has assigned 169.254.x.x. On VM targets this is moot (wait- + # online is disabled below); on rpi4 it's the difference between + # network-online.target firing at the link-local mark (seconds + # after boot) vs firing only when a real DHCP lease is in hand + # (30-50s later, thanks to BCM54213 PHY negotiation). Services + # that need actual internet — openclaw-bootstrap, for example — + # depend on this target being honest. + RequiredForOnline = "routable"; + }; networkConfig = { DHCP = "yes"; - # Don't wait for DHCP to finish before declaring the link "online". - # This avoids boot stalls if the DHCP server is slow or unavailable. - LinkLocalAddressing = "ipv4"; + # VM targets lean on IPv4LL as a "don't block boot if DHCP is + # unreachable" fallback. Real-hardware targets live on a LAN + # with a real DHCP server; turning off IPv4LL means the only + # address the interface ever gets is the routable one, and the + # console banner / `ip addr` never shows a misleading 169.254. + LinkLocalAddressing = if isVmTarget then "ipv4" else "no"; }; dhcpV4Config = { # Accept the default route from QEMU SLIRP (10.0.2.2) @@ -119,10 +150,12 @@ in }; }; - # Do not stall boot waiting for all interfaces to become online. - # The host's SLIRP/vmnet interface comes up asynchronously; we don't need - # to block multi-user.target on it. - systemd.services.systemd-networkd-wait-online.enable = lib.mkForce false; + # VM targets boot on SLIRP/vmnet which comes up asynchronously — we + # don't want to block multi-user.target on it. Real-hardware targets + # (rpi4) need the opposite: hold off services like openclaw-bootstrap + # until a real DHCP lease is in hand, otherwise npm install will run + # against link-local-only connectivity and fail intermittently. + systemd.services.systemd-networkd-wait-online.enable = lib.mkForce (!isVmTarget); # -- Disable unnecessary NixOS defaults ------------------------------------ @@ -169,7 +202,11 @@ in # Tighten start/stop/device timeouts for the ephemeral sandbox use-case. # Default NixOS values are 90 s (start) and 90 s (stop); these are far # too long for a VM that should boot and shut down in under 5 s total. - systemd.settings.Manager = { + # Real hardware (RPi4) needs the defaults back — the SD/MMC controller + # typically takes ~3s to enumerate the FIRMWARE partition's by-label + # symlink, so DefaultDeviceTimeoutSec=3s races and intermittently loses, + # cascading into "Dependency failed for /boot/firmware". + systemd.settings.Manager = lib.mkIf isVmTarget { DefaultTimeoutStartSec = "10s"; DefaultTimeoutStopSec = "3s"; DefaultDeviceTimeoutSec = "3s"; @@ -183,10 +220,12 @@ in # stereOS is headless; there is no interactive login via a TTY or serial # console. Disabling getty removes several units from the boot graph. - services.getty.autologinUser = lib.mkForce null; - systemd.services."getty@".enable = lib.mkForce false; - systemd.services."serial-getty@".enable = lib.mkForce false; - systemd.services."autovt@".enable = lib.mkForce false; + # On real hardware (RPi4) we keep getty so the user can log in via HDMI + + # keyboard or via UART. + services.getty.autologinUser = lib.mkIf isVmTarget (lib.mkForce null); + systemd.services."getty@".enable = lib.mkIf isVmTarget (lib.mkForce false); + systemd.services."serial-getty@".enable = lib.mkIf isVmTarget (lib.mkForce false); + systemd.services."autovt@".enable = lib.mkIf isVmTarget (lib.mkForce false); # Use a volatile (in-memory) journal. An ephemeral sandbox VM has no need # for persistent logs across reboots, and avoiding disk writes reduces I/O diff --git a/modules/default.nix b/modules/default.nix index b88b0b3..6b1a62e 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -9,6 +9,8 @@ ./boot.nix ./services/stereosd.nix ./services/agentd.nix + ./rpi-options.nix + ./rpi-radios.nix ./users/agent.nix ./users/admin.nix ]; diff --git a/modules/firstboot-keys.nix b/modules/firstboot-keys.nix new file mode 100644 index 0000000..29364e1 --- /dev/null +++ b/modules/firstboot-keys.nix @@ -0,0 +1,74 @@ +# modules/firstboot-keys.nix +# +# First-boot SSH key drop-in for Raspberry Pi images. +# +# The RPi has no vsock, so the masterblaster-over-vsock key injection used +# in the VM flow doesn't apply. Instead we read a plain text file from the +# FAT /boot/firmware partition (visible on macOS/Windows/Linux the moment +# you plug the SD card in) and append the keys into admin's +# authorized_keys before sshd starts. +# +# This service is intentionally forgiving: +# - missing file → skip, exit 0 +# - empty file → skip, exit 0 +# - no valid keys → skip, exit 0 +# - malformed line → ignored (valid keys still imported) +# +# It runs on every boot (not just the first) so users can add more keys +# later by editing the file and rebooting. + +{ config, lib, pkgs, ... }: + +{ + systemd.services.stereos-firstboot-keys = { + description = "Import SSH authorized_keys from FIRMWARE partition"; + wantedBy = [ "multi-user.target" ]; + before = [ "sshd.service" ]; + after = [ "local-fs.target" ]; + # RequiresMountsFor pulls in boot-firmware.mount on demand and orders + # our service after it. local-fs.target does NOT auto-pull every fstab + # entry on NixOS+systemd-initrd, so declaring the dependency here is + # what guarantees the FAT partition is mounted when the script runs. + unitConfig.RequiresMountsFor = [ "/boot/firmware" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -u + src=/boot/firmware/ssh_authorized_keys.txt + dest=/home/admin/.ssh/authorized_keys + + if [ ! -f "$src" ]; then + echo "firstboot-keys: $src not present, skipping" + exit 0 + fi + + # Extract plausible key lines: anything starting with a supported + # OpenSSH key-type prefix. Blank lines and comments drop out. + keys=$(${pkgs.gnugrep}/bin/grep -E \ + '^[[:space:]]*(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-|sk-ssh-|sk-ecdsa-)' \ + "$src" || true) + if [ -z "$keys" ]; then + echo "firstboot-keys: no valid keys in $src, skipping" + exit 0 + fi + + install -d -m 0700 -o admin -g users /home/admin/.ssh + touch "$dest" + chown admin:users "$dest" + chmod 0600 "$dest" + + added=0 + while IFS= read -r line; do + [ -z "$line" ] && continue + if ! ${pkgs.gnugrep}/bin/grep -qxF "$line" "$dest"; then + printf '%s\n' "$line" >> "$dest" + added=$((added + 1)) + fi + done <<< "$keys" + + echo "firstboot-keys: imported $added new key(s) from $src" + ''; + }; +} diff --git a/modules/rpi-options.nix b/modules/rpi-options.nix new file mode 100644 index 0000000..7880343 --- /dev/null +++ b/modules/rpi-options.nix @@ -0,0 +1,33 @@ +# modules/rpi-options.nix +# +# Always-declared Raspberry Pi option surface. +# +# `stereos.rpi.series` needs to be readable from any module — including +# modules/rpi-radios.nix, which is imported on every build (VM and Pi +# alike). modules/rpi.nix sets `sdImage.*` options that only exist when +# nixpkgs' sd-image-aarch64.nix is imported, so it can't be imported on +# VM builds. This file holds just the option *declarations* (no `config` +# block), so it's safe to import unconditionally from modules/default.nix. +# +# The `config` block that consumes these options lives in modules/rpi.nix, +# which is pulled in only via profiles/rpi.nix. + +{ lib, ... }: + +{ + options.stereos.rpi = { + series = lib.mkOption { + type = lib.types.enum [ "rpi4" "rpi5" ]; + default = "rpi4"; + description = '' + Which Raspberry Pi board this image targets. Drives the small + set of board-specific knobs that differ between Pi 4 (BCM2711) + and Pi 5 (BCM2712 + RP1 southbridge): the dtoverlay used to + free the UART, and the BCM43455 radio-blacklist heuristic. + + Default is "rpi4" so the legacy build target keeps working. + Pi 5 mixtape specs override this to "rpi5". + ''; + }; + }; +} diff --git a/modules/rpi-radios.nix b/modules/rpi-radios.nix new file mode 100644 index 0000000..6f0c87b --- /dev/null +++ b/modules/rpi-radios.nix @@ -0,0 +1,56 @@ +# modules/rpi-radios.nix +# +# Turn off the Pi 4's onboard Bluetooth and WiFi by blacklisting the +# Broadcom kernel modules. The BCM43455 combo chip's firmware load is +# slow (~10s) and occasionally times out outright on our hardware — +# dmesg fills with: +# +# Bluetooth: hci0: BCM: failed to write update baudrate (-110) +# Bluetooth: hci0: Failed to set baudrate +# Bluetooth: hci0: command tx timeout +# Bluetooth: hci0: BCM: Reset failed (-110) +# +# None of those matter for the openclaw appliance (which uses the +# gigabit ethernet only), but they run in parallel with the network +# stack coming up and dirty the boot logs. Defaulting to `false` +# keeps the appliance boot clean. Flip the option to `true` if/when +# we want BT or WiFi working. +# +# This blacklist is Pi-4-specific. The Pi 5 uses a different combo +# chip (Cypress/Infineon CYW43455 variant on RP1) and the BCM firmware +# timeouts that motivated the blacklist may not occur — the gate +# (stereos.rpi.series == "rpi4") keeps the blacklist off Pi 5 builds +# until/unless we observe the same dmesg pattern there. + +{ config, lib, ... }: + +let + isPiHardware = config.boot.loader.generic-extlinux-compatible.enable; + isRpi4 = isPiHardware && config.stereos.rpi.series == "rpi4"; + cfg = config.stereos.rpi.radios; +in { + options.stereos.rpi.radios.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether the Pi's onboard Bluetooth and WiFi kernel modules + should be loaded. Default `false` — the openclaw appliance + uses gigabit ethernet only and the BCM firmware load adds + ~10s of boot time plus noisy timeouts in dmesg. Set to `true` + to re-enable the radios. + + Today the blacklist that this option drives is Pi-4-specific + (BCM43455 module names). Pi 5 builds do not blacklist anything + regardless of this option; the radios just come up. + ''; + }; + + config = lib.mkIf (isRpi4 && !cfg.enable) { + boot.blacklistedKernelModules = [ + "brcmfmac" # Broadcom FullMAC WiFi driver + "brcmutil" # shared utilities for brcm80211 family + "hci_uart" # UART transport for Bluetooth HCI + "btbcm" # Broadcom Bluetooth firmware / init glue + ]; + }; +} diff --git a/modules/rpi.nix b/modules/rpi.nix new file mode 100644 index 0000000..9bcd0b0 --- /dev/null +++ b/modules/rpi.nix @@ -0,0 +1,258 @@ +# modules/rpi.nix +# +# Raspberry Pi hardware overrides (Pi 4 + Pi 5). +# +# Board-aware via `stereos.rpi.series`. The Nix-level differences between +# Pi 4 and Pi 5 are small enough to live in one module: a few config.txt +# lines (UART/BT routing differs because Pi 5 puts UART behind RP1) and, +# in companion modules, a kernel-module blacklist that's BCM43455-specific. +# Everything else (root-fs label, redistributable firmware, kernel-console +# fixup) is identical across both boards. +# +# modules/boot.nix gates its VM-only tweaks (virtio-restricted initrd, +# GRUB EFI bootloader, vsock wiring) behind +# `!boot.loader.generic-extlinux-compatible.enable`, so enabling extlinux +# (done by formats/rpi-sd-image.nix via nixpkgs' sd-image-aarch64) is +# enough to skip them. nixpkgs' sd-image imports profiles/all-hardware.nix +# which brings the full initrd module set we need on real SBCs. +# +# Pulled in by profiles/rpi.nix alongside formats/rpi-sd-image.nix. Only +# imported on Pi builds because the `config` block sets sdImage.* options +# that nixpkgs only declares when sd-image-aarch64.nix is loaded. +# The board-series option lives in modules/rpi-options.nix so it is +# readable from VM builds too (modules/rpi-radios.nix consults it). + +{ config, lib, pkgs, ... }: + +let + cfg = config.stereos.rpi; +in +{ + options.stereos.rpi.serialConsole.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Route the PL011 UART to GPIO 14/15 (physical pins 8/10) and use + it as the kernel console + login getty, so a USB-serial adapter + plugged into the GPIO header gives a diagnostic shell. + + On Pi 4 enabling this also disables on-board Bluetooth: the Pi + 4B wires PL011 to the BT radio by default, and + dtoverlay=disable-bt moves it to the GPIO header. The mini-UART + alternative preserves BT but its baud rate tracks the core clock + and drifts without pinning — we don't offer it here. + + On Pi 5 UART routing goes through the RP1 southbridge and the + Pi-4-shaped disable-bt overlay does not apply; only enable_uart + is written to config.txt. + + Set to false to keep BT (Pi 4) or to skip the config.txt write + entirely (Pi 5). You keep the HDMI console either way; you only + lose the UART login. + ''; + }; + + config = { + # -- FAT firmware partition size ---------------------------------------- + # nixpkgs sd-image defaults to 30 MiB. Pi 4 fits (bootcode.bin + fixup*.dat + # + start*.elf + bcm2711-* dtbs ≈ 25 MiB). Pi 5 adds kernel_2712.img on + # top of that — sd-image-aarch64.nix copies the *entire* raspberrypifw + # boot/ tree, so the Pi 4 blobs are still present too — and that pushes + # the FAT past 30 MiB and the build fails with "Disk full" during the + # firmware-population step. 128 MiB gives comfortable headroom for + # future overlays without being wasteful. + sdImage.firmwareSize = lib.mkIf (cfg.series == "rpi5") 128; + + # -- Root filesystem ----------------------------------------------------- + # modules/base.nix sets the root to label "nixos"; the sd-image build + # labels the root partition "NIXOS_SD". Override to match — mkForce beats + # the normal-priority definition in base.nix and the one sd-image.nix + # contributes from its own fileSystems block. + fileSystems."/" = lib.mkForce { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + autoResize = true; + }; + + # -- Hardware ------------------------------------------------------------ + # Redistributable firmware covers Broadcom wireless + GPU blobs. The + # sd-image module already copies the RPi bootloader firmware to the + # FIRMWARE partition. + hardware.enableRedistributableFirmware = true; + + # -- Debug: allow emergency shell without password --------------------- + # The Pi 5 firmware-direct boot path is still being shaken out, so when + # the initrd drops into emergency mode (e.g. /sysroot/run mount fails) + # we need to be able to log in and run `systemctl status` to see what + # actually went wrong. Default sulogin policy is "root account is + # locked" with no console access, which is useless for debugging. + # Scoped to Pi 5 only — Pi 4 production images should not expose a + # passwordless emergency root shell on UART/HDMI. + # TODO: remove once Pi 5 boot is stable. + boot.initrd.systemd.emergencyAccess = lib.mkIf (cfg.series == "rpi5") true; + + # -- Kernel console ------------------------------------------------------ + # modules/boot.nix's aarch64 branch ends the console list with hvc0 — + # correct for Apple Virtualization.framework guests, but a phantom on + # real Pi hardware. The kernel makes the *last* console= into + # /dev/console, so without an override anything init writes directly + # (emergency shell, early panics) goes nowhere. mkForce rebuilds the + # list with real terminals only; serialConsole.enable adds the right + # device as the last entry so it becomes /dev/console. + # + # On both Pi 4 and Pi 5 the GPIO 14/15 UART enumerates as ttyAMA0 + # — Pi 4 directly (PL011), Pi 5 via the RP1 southbridge once the + # uart0-pi5 dtoverlay routes RP1 UART0 to those GPIOs. ttyAMA10 on + # Pi 5 is a *different* UART that goes to the dedicated 3-pin debug + # header on the board, not the GPIO header. + boot.kernelParams = lib.mkForce ( + [ "console=tty0" ] + ++ lib.optional cfg.serialConsole.enable "console=ttyAMA0,115200" + ); + + # -- RPi firmware partition --------------------------------------------- + # nixpkgs' installer/sd-card/sd-image-aarch64.nix populates the FAT + # firmware partition with Pi 0/3/4 boot blobs (start4.elf, fixup4.dat, + # bcm2711-rpi-4-b.dtb, ...) plus U-Boot extlinux. That is everything + # the Pi 4 needs. + # + # The Pi 5 boots from its on-board EEPROM (no GPU-side bootcode.bin, + # no fixup*.dat) and the firmware partition needs different content: + # + # • bcm2712-rpi-5-b.dtb — Pi 5 device tree + # • kernel_2712.img — rpi-vendor kernel image + # • a [pi5] section in config.txt — tells the EEPROM to boot it + # + # pkgs.raspberrypifw at our pinned nixpkgs revision (1.20250430) ships + # all of the above. We append them here; the [all] terminator after + # the [pi5] block stops the filter bleeding into subsequent lines. + # + # populateFirmwareCommands is types.lines, so this concatenates with + # the block in formats/rpi-sd-image.nix that drops in the SSH-keys + # template. + # + # On Pi 4 the PL011 is wired to the on-board BT radio by default; + # dtoverlay=disable-bt frees it onto GPIO 14/15 (pins 8/10). On Pi 5 + # UART routing is through the RP1 southbridge and the disable-bt + # overlay does not apply — only enable_uart is written. + sdImage.populateFirmwareCommands = '' + # sd-image-aarch64.nix copies config.txt from a /nix/store path + # (mode 0444), so any cat >> append below would hit Permission + # denied. Make it writable before we touch it. install -m 644 the + # Pi 5 blobs we drop in, for the same reason — keeps the FAT + # partition contents at sane modes. + chmod u+w firmware/config.txt + + ${lib.optionalString (cfg.series == "rpi5") '' + # Pi 5: ship the full bcm2712 dtb set + entire overlays tree from + # raspberrypifw, plus the *NixOS-built* kernel and initrd as + # kernel_2712.img / initrd. The EEPROM reads them directly. + # + # Why the full dtb + overlays set: + # + # The Pi 5 ships in two SoC steppings — C1 (4/8/16 GB models) and + # D0 (newer 2 GB boards + rev-1.1 reworks). D0 needs + # bcm2712d0-rpi-5-b.dtb plus the overlays/bcm2712d0.dtbo "C0->D0 + # differences" overlay to describe pinctrl correctly. The EEPROM + # auto-selects the right dtb + overlay when both are present. + # Ship only one and D0 panics during pinctrl-bcm2712 probe with + # an Asynchronous SError. bcm2712*.dtb covers Pi 5, Pi 500, + # CM5, CM5L variants; shipping the full overlays/ tree is the + # nvmd/nixos-raspberrypi pattern. + install -m 0644 ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2712*.dtb firmware/ + mkdir -p firmware/overlays + install -m 0644 ${pkgs.raspberrypifw}/share/raspberrypi/boot/overlays/*.dtbo firmware/overlays/ + + # NixOS-built kernel + initrd on the FAT partition. + # + # Why we do NOT use raspberrypifw's prebuilt kernel_2712.img: + # it's a different version (e.g. 6.12.25-v8-16k+ in fw 1.20250430) + # than the kernel nixos-hardware builds (linux-rpi 6.12.75-1+rpt1). + # Loading the firmware kernel makes /lib/modules/6.12.75/ unusable — + # modprobe fails, kernel-module-dependent userspace breaks, and the + # kernel boots with the firmware's Pi-OS-shaped cmdline.txt + # (root=/dev/mmcblk0p2 etc.) which has nothing to do with NixOS. + # + # nixpkgs at our pinned rev has no ubootRaspberryPi5_64bit, so the + # standard sd-image-aarch64 U-Boot+extlinux flow is unavailable on + # Pi 5 — we install our kernel + initrd directly under the names + # the EEPROM looks for. cmdline.txt below points at the NixOS + # init via the profile symlink and mounts root by-label so the + # same image works whether flashed to SD or USB. + # + # kernel_2712.img must be gzip-compressed — the Pi 5 EEPROM + # detects the gzip magic and decompresses on load. nixos-hardware's + # linuxPackages_rpi5 only installs the raw Image, so we gzip it + # ourselves. (raspberrypifw's prebuilt kernel_2712.img is gzipped + # too — verified with `file` against 1.20250430.) + gzip -n9 -c ${config.boot.kernelPackages.kernel}/Image > firmware/kernel_2712.img + chmod 0644 firmware/kernel_2712.img + install -m 0644 ${config.system.build.initialRamdisk}/initrd firmware/initrd + + # Don't set root= / rootfstype= / rootwait on the kernel cmdline: + # systemd-initrd reads the new root from /etc/fstab in the initrd, + # generated from fileSystems."/". Setting root= as well makes + # systemd-fstab-generator create sysroot.mount twice, fails the + # second creation, and the boot drops to emergency mode with + # "Failed to create unit file '/run/systemd/generator/sysroot.mount', + # as it already exists. Duplicate entry in '...-initrd-fstab'?". + # NixOS extlinux APPEND deliberately omits root= for the same reason. + # + # init= must be the *explicit* /nix/store toplevel path, not the + # /nix/var/nix/profiles/system symlink — that symlink is only + # created by activation, which runs post-switch_root, so on the + # very first boot the profile path doesn't exist yet and + # initrd-find-nixos-closure fails with + # "Failed to canonicalize /nix/var/nix/profiles/system/init: + # No such file or directory". NixOS extlinux bakes the explicit + # toplevel into APPEND for exactly this reason; we mirror that + # here so first boot works. Trade-off: nixos-rebuild switch + # doesn't update cmdline.txt automatically — kernel/initrd/init + # path are baked at image-build time. + cat > firmware/cmdline.txt <> firmware/config.txt <<'EOF' + + # stereos: serial console on GPIO 14/15 (PL011, 115200 8N1). + # On Pi 4 this disables on-board Bluetooth. To restore BT and + # give up the serial console, set + # stereos.rpi.serialConsole.enable = false. + enable_uart=1 + ${lib.optionalString (cfg.series == "rpi4") "dtoverlay=disable-bt"} + EOF + ''} + + ${lib.optionalString (cfg.series == "rpi5") '' + cat >> firmware/config.txt <<'EOF' + + # stereos: tell the Pi 5 EEPROM to boot the rpi-vendor kernel + # we just dropped into the FAT partition, and route the GPIO + # 14/15 UART (RP1 UART0) onto pins 8/10 so the serial console + # comes out where the user is plugging the USB-serial adapter. + # + # enable_uart=1 (above, in the [all] section) starts the + # firmware-side UART. dtparam=uart0=on alone is NOT sufficient + # on Pi 5 — it doesn't pinmux RP1 UART0 to GPIO 14/15. The + # uart0-pi5 dtoverlay does that pinmux. Ship config.txt without + # it and tio sees nothing on pins 8/10 even with enable_uart. + # + # initramfs initrd followkernel tells the EEPROM to load our + # NixOS-built initrd from FAT after the kernel, since we're + # bypassing extlinux/U-Boot entirely. + # [all] closes the [pi5] filter so subsequent appends apply + # unconditionally again. + [pi5] + kernel=kernel_2712.img + initramfs initrd followkernel + arm_64bit=1 + dtoverlay=uart0-pi5 + [all] + EOF + ''} + ''; + }; +} diff --git a/modules/rpi5-kernel-overlay.nix b/modules/rpi5-kernel-overlay.nix new file mode 100644 index 0000000..18299dc --- /dev/null +++ b/modules/rpi5-kernel-overlay.nix @@ -0,0 +1,42 @@ +# modules/rpi5-kernel-overlay.nix +# +# Pi-5-only overlay: tell makeModulesClosure to skip modules listed in +# boot.initrd.availableKernelModules that are absent from the rpi-vendor +# kernel's /lib/modules tree. +# +# Why this is needed: nixpkgs' installer/sd-card/sd-image-aarch64.nix +# transitively imports profiles/all-hardware.nix, which sets a giant +# boot.initrd.availableKernelModules list (dw_hdmi, drm_kms_helper, +# pwm-bcm2835, …). The rpi-vendor 6.12.x kernel built with +# bcm2712_defconfig compiles many of those as =y — they live in vmlinuz +# rather than as separate .ko files, so modprobe errors out with +# +# modprobe: FATAL: Module dw-hdmi not found in directory ... +# +# during the `modules-shrunk` (makeModulesClosure) derivation, killing +# the SD-image build before it assembles the initrd. +# +# Skipping them in the closure is safe because they're already linked +# into the kernel image and auto-loaded by the kernel; there is no +# corresponding action for the initrd to take. +# +# This is the canonical pattern: nvmd/nixos-raspberrypi applies the same +# overlay unconditionally for every Pi variant. We scope it to Pi 5 +# builds (Pi 4 still uses the standard sd-image-aarch64 kernel and works +# without the override). +# +# Refs: +# https://github.com/NixOS/nixpkgs/issues/154163 +# https://discourse.nixos.org/t/cannot-build-raspberry-pi-sdimage-module-dw-hdmi-not-found/71804 +# https://github.com/nvmd/nixos-raspberrypi/blob/master/modules/raspberrypi.nix + +{ ... }: + +{ + nixpkgs.overlays = [ + (final: super: { + makeModulesClosure = args: + super.makeModulesClosure (args // { allowMissing = true; }); + }) + ]; +} diff --git a/profiles/rpi.nix b/profiles/rpi.nix new file mode 100644 index 0000000..7874648 --- /dev/null +++ b/profiles/rpi.nix @@ -0,0 +1,25 @@ +# profiles/rpi.nix +# +# Profile for Raspberry Pi mixtapes. Swaps the VM-oriented image format +# (raw EFI, qcow2, kernel artifacts) for an SD card image and applies +# the Pi hardware + bootloader overrides. +# +# Board-agnostic — modules/rpi.nix dispatches on `stereos.rpi.series`. +# The mixtape spec sets the series; this profile defaults to the option +# default ("rpi4") so legacy callers don't need to change. +# +# modules/rpi.nix is imported unconditionally from modules/default.nix +# so the option is always declared; this profile only adds the SD image +# format + first-boot key drop-in. +# +# Include via mkMixtape extraModules alongside a mixtape's feature list. + +{ config, lib, pkgs, ... }: + +{ + imports = [ + ../formats/rpi-sd-image.nix + ../modules/rpi.nix + ../modules/firstboot-keys.nix + ]; +} diff --git a/scripts/flash-rpi.sh b/scripts/flash-rpi.sh new file mode 100755 index 0000000..ff14503 --- /dev/null +++ b/scripts/flash-rpi.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# +# Flash a stereOS Raspberry Pi SD image to an SD card. +# +# Supports macOS and Linux. Auto-discovers removable disks, prompts for +# confirmation, and optionally injects an SSH public key onto the +# FIRMWARE FAT partition so the Pi accepts SSH on first boot. +# +# Usage: +# ./scripts/flash-rpi.sh [--board rpi4|rpi5] [--image ] [--ssh-key ] +# +# Options: +# --board Scope auto-detection to one board's result dir. +# Without it, both result-rpi4/ and result-rpi5/ are +# searched. The Makefile passes this so flash-rpi4 +# and flash-rpi5 never confuse one for the other. +# --image Path to the .img file (default: auto-detected +# under result-rpi4/sd-image/ and/or result-rpi5/sd-image/) +# --ssh-key Path to an SSH .pub key to inject into the +# FIRMWARE partition's ssh_authorized_keys.txt +# -h, --help Show this help message +# +# Environment variable fallbacks: +# STEREOS_IMAGE Same as --image +# STEREOS_SSH_KEY Same as --ssh-key +# +# Examples: +# ./scripts/flash-rpi.sh +# ./scripts/flash-rpi.sh --ssh-key ~/.ssh/id_ed25519.pub +# make flash-rpi4 SSH_KEY=~/.ssh/id_ed25519.pub +# +set -euo pipefail + +# -- Helpers ----------------------------------------------------------------- + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: + ./scripts/flash-rpi.sh [--board rpi4|rpi5] [--image ] [--ssh-key ] + +Options: + --board Scope auto-detection to that board's result dir. + Without it, both result-rpi4/ and result-rpi5/ are + searched. + --image Path to the .img file (default: auto-detected + under result-rpi4/sd-image/ and/or result-rpi5/sd-image/) + --ssh-key Path to an SSH .pub key to inject into the + FIRMWARE partition's ssh_authorized_keys.txt + -h, --help Show this help message + +Environment variable fallbacks: + STEREOS_IMAGE Same as --image + STEREOS_SSH_KEY Same as --ssh-key + +Examples: + ./scripts/flash-rpi.sh + ./scripts/flash-rpi.sh --board rpi5 --ssh-key ~/.ssh/id_ed25519.pub + make flash-rpi5 SSH_KEY=~/.ssh/id_ed25519.pub +EOF +} + +# -- Argument parsing -------------------------------------------------------- + +IMAGE="" +SSH_KEY="" +BOARD="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + [[ -n "${2:-}" ]] || die "--image requires a path" + IMAGE="$2"; shift 2 ;; + --ssh-key) + [[ -n "${2:-}" ]] || die "--ssh-key requires a path" + SSH_KEY="$2"; shift 2 ;; + --board) + [[ "${2:-}" =~ ^rpi[45]$ ]] || die "--board requires rpi4 or rpi5" + BOARD="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + die "Unknown argument: $1" ;; + esac +done + +# Env-var fallbacks +IMAGE="${IMAGE:-${STEREOS_IMAGE:-}}" +SSH_KEY="${SSH_KEY:-${STEREOS_SSH_KEY:-}}" + +# -- Find image -------------------------------------------------------------- + +find_image() { + if [[ -n "$IMAGE" ]]; then + [[ -f "$IMAGE" ]] || die "Image not found: $IMAGE" + return + fi + # Auto-detect. With --board, scope to that board's result dir only; without + # it, look in both. nullglob so an empty result dir expands to nothing + # rather than the literal pattern. + shopt -s nullglob + local candidates + case "$BOARD" in + rpi4) candidates=( result-rpi4/sd-image/stereos-*.img ) ;; + rpi5) candidates=( result-rpi5/sd-image/stereos-*.img ) ;; + "") candidates=( result-rpi4/sd-image/stereos-*.img result-rpi5/sd-image/stereos-*.img ) ;; + esac + shopt -u nullglob + if [[ ${#candidates[@]} -eq 0 ]]; then + if [[ -n "$BOARD" ]]; then + die "No Raspberry Pi image found under result-${BOARD}/sd-image/ +Build one first: + make build-${BOARD} MIXTAPE=" + else + die "No Raspberry Pi image found under result-rpi4/sd-image/ or result-rpi5/sd-image/ +Build one first: + make build-rpi4 MIXTAPE= + make build-rpi5 MIXTAPE=" + fi + fi + if [[ ${#candidates[@]} -gt 1 ]]; then + echo "Multiple images found:" >&2 + printf " %s\n" "${candidates[@]}" >&2 + die "Specify one with --image (or --board rpi4|rpi5)" + fi + IMAGE="${candidates[0]}" +} + +# -- OS detection ------------------------------------------------------------ + +OS="" + +detect_os() { + case "$(uname -s)" in + Darwin) OS=macos ;; + Linux) OS=linux ;; + *) die "Unsupported OS: $(uname -s). macOS and Linux supported." ;; + esac +} + +# -- Disk discovery ---------------------------------------------------------- + +DISK_IDS=() + +list_removable_disks() { + if [[ "$OS" == "macos" ]]; then + list_removable_disks_macos + else + list_removable_disks_linux + fi + if [[ ${#DISK_IDS[@]} -eq 0 ]]; then + die "No removable disks found. Insert an SD card and try again." + fi +} + +list_removable_disks_macos() { + local line disk + while IFS= read -r line; do + disk=$(echo "$line" | awk '{print $1}') + [[ -n "$disk" ]] && DISK_IDS+=("$disk") + done < <(diskutil list external physical 2>/dev/null | grep "^/dev/disk" || true) +} + +list_removable_disks_linux() { + local name + while IFS= read -r name; do + [[ -n "$name" ]] && DISK_IDS+=("/dev/$name") + done < <(lsblk -d -n -o NAME,RM,TYPE | awk '$2 == 1 && $3 == "disk" {print $1}' || true) +} + +# -- Device selection -------------------------------------------------------- + +TARGET_DEVICE="" + +prompt_device() { + echo "" + echo "Available removable disks:" + echo "" + + local i disk + for i in "${!DISK_IDS[@]}"; do + disk="${DISK_IDS[$i]}" + if [[ "$OS" == "macos" ]]; then + local size model + size=$(diskutil info "$disk" | grep "Disk Size" | awk -F: '{print $2}' | xargs) + model=$(diskutil info "$disk" | grep "Media Name" | awk -F: '{print $2}' | xargs) + printf " [%d] %s\n" "$((i+1))" "$disk" + printf " %s — %s\n" "${model:-unknown}" "${size:-unknown}" + else + local info + info=$(lsblk -d -n -o SIZE,MODEL "$disk" 2>/dev/null | xargs) + printf " [%d] %s %s\n" "$((i+1))" "$disk" "$info" + fi + done + + echo "" + local choice + if [[ ${#DISK_IDS[@]} -eq 1 ]]; then + read -rp "Use ${DISK_IDS[0]}? [y/N] " choice + [[ "$choice" =~ ^[Yy]$ ]] || die "Aborted." + TARGET_DEVICE="${DISK_IDS[0]}" + else + read -rp "Enter disk number [1-${#DISK_IDS[@]}]: " choice + [[ "$choice" =~ ^[0-9]+$ ]] || die "Invalid selection." + local idx=$((choice - 1)) + (( idx >= 0 && idx < ${#DISK_IDS[@]} )) || die "Selection out of range." + TARGET_DEVICE="${DISK_IDS[$idx]}" + fi +} + +# -- Confirmation gate ------------------------------------------------------- + +confirm_flash() { + local dev_size="" + if [[ "$OS" == "macos" ]]; then + dev_size=$(diskutil info "$TARGET_DEVICE" | grep "Disk Size" | awk -F: '{print $2}' | xargs) + else + dev_size=$(lsblk -d -n -o SIZE "$TARGET_DEVICE" 2>/dev/null | xargs) + fi + local img_size + img_size=$(du -h "$IMAGE" | awk '{print $1}') + + echo "" + echo "══════════════════════════════════════════════════════════" + echo " WARNING: All data on the target device will be erased!" + echo "" + echo " Image: $IMAGE ($img_size)" + echo " Device: $TARGET_DEVICE" + echo " Device size: ${dev_size:-unknown}" + if [[ -n "$SSH_KEY" ]]; then + echo " SSH key: $SSH_KEY" + fi + echo "══════════════════════════════════════════════════════════" + echo "" + local confirm + read -rp "Type 'yes' to flash: " confirm + [[ "$confirm" == "yes" ]] || die "Aborted." +} + +# -- Flash ------------------------------------------------------------------- + +flash_image() { + if [[ "$OS" == "macos" ]]; then + flash_image_macos + else + flash_image_linux + fi +} + +flash_image_macos() { + local raw_dev="${TARGET_DEVICE/disk/rdisk}" + echo "" + echo "Unmounting $TARGET_DEVICE..." + diskutil unmountDisk "$TARGET_DEVICE" + echo "Flashing $IMAGE -> $raw_dev (this may take a few minutes)..." + sudo dd if="$IMAGE" of="$raw_dev" bs=4M status=progress + sync + echo "Flash complete." +} + +flash_image_linux() { + echo "" + echo "Unmounting partitions on $TARGET_DEVICE..." + local part + for part in "${TARGET_DEVICE}"*; do + if mount | grep -q "^$part "; then + sudo umount "$part" 2>/dev/null || true + fi + done + echo "Flashing $IMAGE -> $TARGET_DEVICE (this may take a few minutes)..." + sudo dd if="$IMAGE" of="$TARGET_DEVICE" bs=4M status=progress conv=fsync + sync + echo "Flash complete." +} + +# -- SSH key injection ------------------------------------------------------- + +inject_ssh_key() { + [[ -n "$SSH_KEY" ]] || return 0 + + echo "" + echo "Injecting SSH key..." + + if [[ "$OS" == "macos" ]]; then + inject_ssh_key_macos + else + inject_ssh_key_linux + fi +} + +inject_ssh_key_macos() { + echo "Mounting $TARGET_DEVICE..." + diskutil mountDisk "$TARGET_DEVICE" >/dev/null 2>&1 || true + # Give macOS a moment to mount volumes + sleep 2 + + local fw_path="/Volumes/FIRMWARE" + if [[ ! -d "$fw_path" ]]; then + echo "WARNING: FIRMWARE partition not mounted at $fw_path" >&2 + echo "You can add the key manually later by editing" >&2 + echo "ssh_authorized_keys.txt on the FIRMWARE partition." >&2 + return 0 + fi + + local keys_file="$fw_path/ssh_authorized_keys.txt" + if [[ ! -f "$keys_file" ]]; then + # Template not present — create it + touch "$keys_file" + fi + + cat "$SSH_KEY" >> "$keys_file" + echo "Key appended to $keys_file" + diskutil eject "$TARGET_DEVICE" >/dev/null 2>&1 || true +} + +inject_ssh_key_linux() { + sudo partprobe "$TARGET_DEVICE" 2>/dev/null || true + sleep 1 + + local mount_point="/tmp/stereos-firmware" + sudo mkdir -p "$mount_point" + + # Partition naming: /dev/sdX1 vs /dev/mmcblk0p1 + local part1 + if [[ "$TARGET_DEVICE" == *mmcblk* || "$TARGET_DEVICE" == *nvme* ]]; then + part1="${TARGET_DEVICE}p1" + else + part1="${TARGET_DEVICE}1" + fi + + if ! sudo mount "$part1" "$mount_point" 2>/dev/null; then + echo "WARNING: Failed to mount FIRMWARE partition ($part1)" >&2 + echo "You can add the key manually later by editing" >&2 + echo "ssh_authorized_keys.txt on the FIRMWARE partition." >&2 + return 0 + fi + + local keys_file="$mount_point/ssh_authorized_keys.txt" + if [[ ! -f "$keys_file" ]]; then + sudo touch "$keys_file" + fi + + sudo tee -a "$keys_file" < "$SSH_KEY" > /dev/null + echo "Key appended to $keys_file" + sudo umount "$mount_point" + sudo eject "$TARGET_DEVICE" 2>/dev/null || true +} + +# -- Summary ----------------------------------------------------------------- + +print_summary() { + echo "" + echo "══════════════════════════════════════════════════════════" + echo " stereOS Raspberry Pi image flashed successfully" + echo "" + echo " Image: $IMAGE" + echo " Device: $TARGET_DEVICE" + if [[ -n "$SSH_KEY" ]]; then + echo " SSH key: $(basename "$SSH_KEY") (injected)" + fi + echo "" + echo " Insert the SD card into your Raspberry Pi and power on." + if [[ -n "$SSH_KEY" ]]; then + echo " SSH: ssh admin@" + else + echo " To add SSH keys, mount the FIRMWARE partition and" + echo " edit ssh_authorized_keys.txt, or reflash with:" + echo " make flash-rpi4 SSH_KEY=~/.ssh/id_ed25519.pub" + fi + echo "══════════════════════════════════════════════════════════" +} + +# -- Main -------------------------------------------------------------------- + +main() { + find_image + detect_os + + # Validate SSH key early + if [[ -n "$SSH_KEY" ]]; then + [[ -f "$SSH_KEY" ]] || die "SSH key file not found: $SSH_KEY" + if ! grep -qE '^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-|sk-ssh-|sk-ecdsa-)' "$SSH_KEY"; then + die "File does not look like an SSH public key: $SSH_KEY" + fi + fi + + echo "══════════════════════════════════════════════════════════" + echo " stereOS Raspberry Pi SD card flasher" + echo "" + echo " Image: $IMAGE" + echo " OS: $OS" + if [[ -n "$SSH_KEY" ]]; then + echo " SSH key: $SSH_KEY" + fi + echo "══════════════════════════════════════════════════════════" + + list_removable_disks + prompt_device + confirm_flash + flash_image + inject_ssh_key + print_summary +} + +main "$@" diff --git a/scripts/run-vm.sh b/scripts/run-vm.sh index 137a510..5abced1 100755 --- a/scripts/run-vm.sh +++ b/scripts/run-vm.sh @@ -139,6 +139,20 @@ if [ ! -f "$IMAGE" ]; then exit 1 fi +# -- RPi SD images are for real hardware, not QEMU --------------------------- +# The sd-image artifact uses U-Boot + extlinux (Pi 4) or the EEPROM-direct +# kernel_2712.img path (Pi 5); neither boots on qemu-system-aarch64 -M virt. +# Redirect the user to flashing. +case "$IMAGE" in + *rpi4*.img|*rpi5*.img|*/sd-image/*.img) + echo "This looks like a Raspberry Pi SD card image: $IMAGE" + echo "It is not bootable under QEMU's 'virt' machine. Flash it to an SD card:" + echo " make flash-rpi4 MIXTAPE= (for Pi 4)" + echo " make flash-rpi5 MIXTAPE= (for Pi 5)" + exit 1 + ;; +esac + # -- Prepare writable working copy ------------------------------------------ WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT