From 8125af56343458c61cec3506900a7367782e5ee8 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Wed, 6 May 2026 16:04:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(boot):=20gate=20VM-only=20?= =?UTF-8?q?optimizations=20behind=20extlinux=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot module is heavily VM-tuned: virtio-only initrd modules, GRUB EFI, vsock, aggressive systemd timeouts, suppressed kernel console output, no getty, growpart on first boot, IPv4LL fallback networking. These are all wrong for real hardware (Raspberry Pi). Introduce `isVmTarget = !boot.loader.generic-extlinux-compatible.enable` and gate every VM-shaped tweak behind it. The extlinux flag is the existing NixOS sd-image signal that "this build targets a real SBC and uses U-Boot/extlinux"; flipping it inverts boot.nix's posture without adding a new option. Two parallel changes that ride along: • aarch64 kernel-console list ends with hvc0 (Apple-VF console) only on VM targets; on real hardware modules/rpi.nix overrides the list with real terminals so /dev/console maps to a usable device. • systemd-networkd configured with `RequiredForOnline=routable` on real-hardware builds and `LinkLocalAddressing=no`. The BCM54213 PHY on Pi 4 takes 30-50s to negotiate gigabit, so network-online.target firing on a 169.254.x.x address would let services like first-boot installers run with no internet. On VM builds we keep the IPv4LL fallback so SLIRP/vmnet bring-up isn't blocking. No behavioral change for VM builds (everything still gated on, just under an explicit flag now). Real-hardware images now boot with the right kernel console wiring, getty enabled, real-DHCP wait-online, and no aggressive systemd timeouts. --- modules/boot.nix | 79 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 20 deletions(-) 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 From 1fc993396eccf24aeead50b823363585b786f994 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Thu, 7 May 2026 08:03:32 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20feat(rpi):=20Raspberry=20Pi=20a?= =?UTF-8?q?s=20a=20first-class=20stereOS=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SD-image build path and supporting modules so stereOS can boot on a Raspberry Pi alongside the existing VM mixtapes. The architecture is board-aware from the start via `stereos.rpi.series` (enum, default "rpi4"); only the Pi 4 build target is wired up in this commit, and Pi-4-only quirks (BCM43455 radio blacklist, BT-on-PL011 disable-bt overlay) gate on `series == "rpi4"`. A follow-up commit adds the Pi 5 build target without further refactoring. What's added: • formats/rpi-sd-image.nix imports nixpkgs' sd-image-aarch64 and adds a first-boot SSH-keys drop-in surface on the FAT firmware partition. • modules/rpi.nix board-aware Pi hardware module: root-fs label, kernel-console fixup, serial-console option (GPIO 14/15 PL011 → ttyAMA0), firmware-partition population, emergency-shell access for physical-console debugging. • modules/rpi-options.nix declares stereos.rpi.series so companion modules (rpi-radios, etc.) can read it on every build. • modules/rpi-radios.nix opt-out blacklist of the Pi 4's BCM43455 BT/WiFi modules — the combo-chip firmware load adds ~10s of boot time and noisy timeouts. Default off; enable the radios with stereos.rpi.radios.enable = true. • modules/firstboot-keys.nix forgiving SSH-key import from a plain-text file on the FAT partition, editable on macOS/Windows/Linux without mounting ext4. • profiles/rpi.nix bundles formats + firstboot-keys for the rpi mixtape spec. • scripts/flash-rpi.sh macOS/Linux SD-card flasher with --board scoping, SSH-key injection, and removable-disk detection. • flake/images.nix rpi4MixtapeSpecs (aarch64-only) for base/coder/base-dev/coder-dev, mkSdImagePkgs helper. *-rpi4-sd outputs join the existing per-system package set. • Makefile build-rpi4 / flash-rpi4 targets. • scripts/run-vm.sh guard that catches Pi SD images accidentally fed to QEMU and redirects to flash-rpi. Pi-4-only path requires no new flake inputs — uses nixpkgs' sd-image-aarch64 + raspberrypifw + pkgs.linuxKernel.packages.linux_rpi3 (the upstream default kernel for sd-image-aarch64). --- .gitignore | 1 + Makefile | 16 ++ flake/images.nix | 29 ++- formats/rpi-sd-image.nix | 87 ++++++++ modules/default.nix | 2 + modules/firstboot-keys.nix | 74 +++++++ modules/rpi-options.nix | 33 +++ modules/rpi-radios.nix | 56 +++++ modules/rpi.nix | 258 +++++++++++++++++++++++ profiles/rpi.nix | 25 +++ scripts/flash-rpi.sh | 409 +++++++++++++++++++++++++++++++++++++ scripts/run-vm.sh | 14 ++ 12 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 formats/rpi-sd-image.nix create mode 100644 modules/firstboot-keys.nix create mode 100644 modules/rpi-options.nix create mode 100644 modules/rpi-radios.nix create mode 100644 modules/rpi.nix create mode 100644 profiles/rpi.nix create mode 100755 scripts/flash-rpi.sh 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..d4f9603 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,20 @@ 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" to the mixtape (e.g. coder → coder-rpi4-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)) + # -- VM development operations ------------------------------------------------ .PHONY: run @@ -78,6 +93,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/images.nix b/flake/images.nix index 0f68446..b1f0407 100644 --- a/flake/images.nix +++ b/flake/images.nix @@ -9,9 +9,13 @@ # packages..-kernel-artifacts (direct-kernel boot) # packages..-dist (all formats + mixtape.toml) # +# RPi mixtapes only emit an SD card image: +# packages.aarch64-linux.-rpi4-sd (Pi 4 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 { self, inputs, ... }: @@ -32,6 +36,15 @@ 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 ]; } + ]; + # Helper to build packages for a given system buildSystemImages = system: let @@ -88,8 +101,22 @@ 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; in - rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs; + rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs // rpi4Pkgs; # 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/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/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 From 570d6ea075d1ce98ba0d5a9173fdb3b8cd983169 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Thu, 7 May 2026 08:05:35 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20feat(rpi):=20extend=20Pi=20supp?= =?UTF-8?q?ort=20to=20Raspberry=20Pi=205=20(BCM2712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up the rpi5 build target on top of the board-aware Pi infrastructure. Pi-5-only logic in modules/rpi.nix (firmware- partition contents, [pi5] config.txt block, NixOS-built kernel as gzipped kernel_2712.img, NixOS-built initrd, explicit toplevel cmdline) is reachable via stereos.rpi.series = "rpi5", which the rpi5MixtapeSpecs set inline. Why a NixOS-built kernel as kernel_2712.img instead of raspberrypifw's prebuilt: • The firmware-shipped kernel_2712.img is a different version (e.g. 6.12.25-v8-16k+ in fw 1.20250430) than the kernel nixos-hardware configures (linux-rpi 6.12.75-1+rpt1). Booting the firmware kernel makes /lib/modules// unusable — modprobe fails for everything kernel-module-dependent. • 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 replace the prebuilt kernel directly and write our own cmdline.txt. • Pi 5 EEPROM detects the gzip magic on kernel_2712.img and decompresses on load; linuxPackages_rpi5 only installs the raw Image, so we gzip it during firmware-partition population. D0 vs C1 silicon: the EEPROM auto-selects bcm2712d0-rpi-5-b.dtb on D0 boards when both DTBs *and* overlays/bcm2712d0.dtbo are present. Ship only the C1 dtb and a 2 GB / rev-1.1 board panics during pinctrl-bcm2712 probe with an Asynchronous SError. We ship the full bcm2712 dtb glob and the entire overlays/ tree. GPIO 14/15 UART: enable_uart=1 by itself does not pinmux the RP1's UART0 to the GPIO header on Pi 5. The dtoverlay=uart0-pi5 overlay does that routing; the device enumerates as ttyAMA0, matching Pi 4's name (ttyAMA10 on Pi 5 is the dedicated debug-header UART, not GPIO 14/15). nixos-hardware pin: pinned to f1b7ff92cdd1 — the last commit before nixos-hardware#1841 replaced the rpi-kernel postConfigure sed with `LOCALVERSION = freeform ""`, which round-trips through nixpkgs' kernel-config emitter as the literal two characters `""` and breaks the modDirVersion sanity check. Tracked in nixos-hardware#1859, fix in #1860 unmerged as of 2026-05. Pi-5-only artifacts: • flake.nix nixos-hardware flake input • modules/rpi5-kernel-overlay.nix overlay defaulting makeModulesClosure to allowMissing=true; needed because all- hardware.nix lists modules that the rpi-vendor 6.12.x kernel builds as =y (dw_hdmi, …) so they're not present as .ko files in /lib/modules and modprobe errors during the modules-shrunk step. Pattern from nvmd/nixos-raspberrypi. • flake/images.nix rpi5MixtapeSpecs (sets series + imports nixos-hardware module + the kernel- overlay), rpi5Pkgs. • Makefile build-rpi5 / flash-rpi5 targets. --- Makefile | 12 +++++++++- flake.lock | 17 +++++++++++++ flake.nix | 13 ++++++++++ flake/images.nix | 29 +++++++++++++++++++++-- modules/rpi5-kernel-overlay.nix | 42 +++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 modules/rpi5-kernel-overlay.nix diff --git a/Makefile b/Makefile index d4f9603..1c94542 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,8 @@ build-kernel: ## Build kernel artifacts for kernel boot # -- Raspberry Pi image builds ----------------------------------------------- # # RPi images are aarch64-only and ship as a single SD card image. -# The attr name adds "-rpi4-sd" to the mixtape (e.g. coder → coder-rpi4-sd). +# 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) @@ -51,6 +52,15 @@ flash-rpi4: ## Flash RPi4 SD image to an SD card (SSH_KEY=~/.ssh/id_ed25519.pub @./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 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 b1f0407..f28494f 100644 --- a/flake/images.nix +++ b/flake/images.nix @@ -9,13 +9,15 @@ # packages..-kernel-artifacts (direct-kernel boot) # packages..-dist (all formats + mixtape.toml) # -# RPi mixtapes only emit an SD card image: +# 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, ... }: @@ -45,6 +47,28 @@ let { 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 @@ -115,8 +139,9 @@ let ) else {}; rpi4Pkgs = mkSdImagePkgs rpi4MixtapeSpecs; + rpi5Pkgs = mkSdImagePkgs rpi5MixtapeSpecs; in - rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs // rpi4Pkgs; + rawPkgs // qcow2Named // kernelArtifactsNamed // distPkgs // rpi4Pkgs // rpi5Pkgs; # Build packages for all target systems allPackages = builtins.listToAttrs ( 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; }); + }) + ]; +}