- Nix 84.1%
- Shell 15.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
secrets.nix has been unused since the settings.txt migration; the wifi and the stream live on the card now. Its .gitignore entry goes with it. The rest is comments only, no behaviour change: the house style had grown into paragraphs that restate the code. Every fact that cost a flash cycle to learn is kept -- U-Boot's missing zstd, ARCH_BRCMSTB, the wpa_supplicant declarative path, the LogsDirectory deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUaL7FZyVWBqyyEMwtuJgu |
||
| modules | ||
| overlays | ||
| pkgs | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| settings.nix | ||
nanomecha
A Raspberry Pi 4 that does exactly one thing: play http://fm.mecha.hu/stream
out of its 3.5 mm jack, from power-on until power-off, and nothing else.
No users. No login, on console or over the network. No shell to reach. No package manager. It reads one text file off the card, brings up Ethernet or one of the Wi-Fi networks named there by itself, starts playing, and if anything goes wrong -- network drops, stream dies, connection stalls, kernel wedges -- it recovers on its own.
Build
nix build .#sd-image
That is the whole procedure. There is nothing to create first and no credentials in the build: the stream and the wifi are read off the card at boot, not compiled in, so the image is identical for every device and the repo has no secrets to keep out of it.
Cross-compiled from x86_64 to aarch64. Requires nothing but a flake-enabled Nix.
The result lands in result/sd-image/nanomecha-rpi4.img.zst.
Other outputs:
| Output | What it is |
|---|---|
.#sd-image |
the image (cross-compiled, trimmed kernel modules) |
.#sd-image-safe |
same, but keeping the full kernel module tree (~140 MiB larger). Use if you suspect a missing driver. |
.#sd-image-emulated |
built natively for aarch64 through binfmt/qemu instead of cross. Fallback if a package ever refuses to cross-compile; everything comes prebuilt from the cache. |
.#player |
just the ffmpeg build, for testing on its own |
.#toplevel |
the system closure, for auditing sizes |
Flash
lsblk # find the card, and be sure
zstdcat result/sd-image/nanomecha-rpi4.img.zst \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
Then, with the card still in the reader, open the small FAT partition -- the one
Windows and macOS mount on their own -- and edit settings.txt in its root:
STREAM_URL=http://fm.mecha.hu/stream
VOLUME=0.3
WIFI_COUNTRY=HU
WIFI_SSID=MyNetwork
WIFI_PSK=mypassword
Every key is optional; anything left out falls back to the value built into the image,
and anything invalid is ignored and reported in the log. A card whose settings.txt is
never touched plays the stream above over Ethernet.
There is no first-boot setup and no resize step. Put the card in, connect audio, apply power.
Reading the logs
By default the root is read-only and the journal is volatile: logs live in RAM and are gone at power-off. That is the point of a static image, but it means a card that misbehaves cannot tell you why after the fact.
To diagnose something, set readOnlyRoot = false in settings.nix, rebuild and
reflash. That gives a writable root, a persistent journal capped at 64 MiB, and the
reserved free space to hold it. Then, with the card in another machine:
# plain text: the player's own output, the ALSA card list, the network interfaces
# and the stream URL actually in use
cat /mnt/var/log/radio.log
# the full system journal: kernel, wifi association, DHCP, unit restarts
journalctl -D /mnt/var/log/journal -b
Every failure in this project was found this way. Note that the emergency shell is
not an alternative: root is locked, so a failed boot leaves a console that cannot be
typed into. If the system does not get far enough to write a log, the way forward is to
compare derivations against a build that is known to boot -- nix derivation show on
each side, then walk down through the ext4, the toplevel and the initrd -- which
narrows "something broke" to an exact list of changed inputs without needing the board
to say anything.
Changing everything else
Two files, and which one you want depends on whether the device has to be rebuilt.
settings.txt, in the root of the card's FAT partition, is read at every boot. Editing
it needs no Nix, no rebuild and no reflash -- just a PC and a card reader:
STREAM_URL=http://fm.mecha.hu/stream
VOLUME=0.3 # multiplier, or decibels: -10.5dB
WIFI_COUNTRY=HU # regulatory domain -- see below
WIFI_SSID=MyNetwork
WIFI_PSK=mypassword
WIFI_SSID_2=SomeCafe # up to eight networks
WIFI_PSK_2= # empty password = an open network
USB_POWER_URL=http://host/ok # optional, off unless set -- see below
USB_POWER_INTERVAL=30 # seconds between checks
USB_POWER_GRACE=3 # failures in a row before power is cut
USB_POWER_DEBUG=1 # dump a diagnostic to the card
One KEY=value per line, # starts a comment, no quotes. Spaces around the = are
ignored -- except on a WIFI_PSK line, where the password is taken exactly as it
appears after the =, because a trailing space is legal in a passphrase and silently
eating it would look identical to a wrong password. A password must be 8-63 characters;
anything else is skipped and said so in the log.
settings.nix holds what is compiled in, and needs a rebuild and a reflash:
{
volume = "0.3"; # fallback, if settings.txt does not say
wifiCountry = "HU"; # fallback, likewise
audioDevice = "default";
alsaBufferFrames = 16384;
hostName = "nanomecha";
timeZone = "Europe/Budapest";
player = "minimal"; # or "ffmpeg-headless"
slimModules = true; # ship only this board's kernel modules
slimKernel = true; # drop other boards' device trees
conservativeBoot = false; # perl-based /etc + users, as a fallback
}
WIFI_COUNTRY matters more than it looks. With no country set, the driver uses the
"world" regulatory domain, which cannot see 2.4 GHz channels 12-13 and will not
actively scan 5 GHz. An access point on those channels is then simply invisible,
with no error logged anywhere.
The wifi password is stored in the clear on the card's FAT partition. That is a
deliberate trade and not an accident: an appliance that brings up its own network with
no operator present has to carry the password somewhere, and FAT has neither
permissions nor encryption. What it buys is that the password is no longer inside the
image -- so one build serves every device, each card is keyed on its own, and the
.img.zst can be handed to anyone. Anyone holding a card, though, can read it.
How the card's settings reach the system
nanomecha-settings.service mounts the FAT partition read-only, parses settings.txt,
unmounts it again, and writes three files to tmpfs:
/run/nanomecha/player.env, anEnvironmentFileforradio.service. OnlySTREAM_URLandVOLUMEare ever emitted -- this file becomes the player's environment, so passing the card's contents through unfiltered would let it setPATHorLD_PRELOAD./run/nanomecha/wireless.conf, loaded by wpa_supplicant with-I./run/nanomecha/usb-power.env, anEnvironmentFilefornanomecha-usb-power.service. Empty unless the card asks for it, and an empty one is what makes that unit exit without touching anything.
Each consumer then parses its own native format, so there is no second parser to get
wrong. The validation is not decoration: a malformed -I file makes wpa_supplicant
refuse to start at all rather than skip the bad block, so a five-character password
typed into settings.txt would take the wifi down with nothing to say why. Every value
is checked before it is used, and whatever fails is dropped and logged.
The card is never written to, and nanomecha-settings.service cannot fail the boot:
if it never runs, a tmpfiles rule has already put a valid empty wireless.conf in
place, and the player falls back to the stream compiled into it.
What the settings unit made of the card is repeated at the top of radio.log on every
start, passwords reduced to a character count -- otherwise "the stream URL is wrong"
and "the wifi password is wrong" look identical from the outside.
Cutting power to the USB ports
Set USB_POWER_URL in settings.txt and the USB ports become a remote switch for
whatever is plugged into them. nanomecha-usb-power.service GETs that URL every
USB_POWER_INTERVAL seconds: 200 means powered, anything else means not. Stop
answering 200 and the adapter loses power; answer again and it comes back.
A real VBUS cut, not a logical disconnect -- the device sees what it would see if it
were unplugged. uhubctl does the switching. Off unless the card asks for it.
Four things decide whether it is safe to turn on:
- All four ports switch together. The Pi 4 wires them as one power group. Safe here only because nothing this system needs is on USB -- the card and the wifi are SDIO, the audio is the analog jack. A USB wifi dongle would make the first cut permanent: the check could never succeed again.
- VL805 firmware must be 00137ad or newer. Older firmware takes the request and
leaves VBUS up, silently.
rpi-eeprom-updatereports the version. - Failing to reach the URL counts as off, including having no network at all. That is the point, but it does mean the ports are dark for the first half-minute of every boot.
- Cutting is slower than restoring.
USB_POWER_GRACEfailures in a row (3 by default, so about 90 s) before power drops; one 200 brings it back. A dropped wifi association should not power-cycle the adapter.
Set USB_POWER_DEBUG=1 and it writes nanomecha-usb.txt to the card a minute after
boot: what the settings produced, whether PCIe and USB came up, what uhubctl sees, and
the service's own log. With no console and no persistent journal that is the only way
to ask, and it is the only thing that ever writes to the card.
A read-only root has to ship its own mount points
This cost several flash cycles and is worth stating plainly, because nothing in the error message points at it.
make-ext4-fs builds the root filesystem from the store closure plus whatever
populateRootCommands leaves in ./files. Nothing else. So by default the root
contains exactly /nix, /boot and nix-path-registration -- no /run, no /etc,
no /var, no /proc.
On a writable root that is invisible: systemd creates each mount point on demand. On a
read-only root it cannot, and stage 1 needs three of them immediately -- it rbinds the
initrd's /run onto /sysroot/run, mounts a tmpfs on /sysroot/var, and an overlay
on /sysroot/etc. The first one fails with
Failed to mount /sysroot/run
and because initrd-fs.target requires it, everything downstream fails too. The
resulting screen is a wall of Dependency failed for … naming /sysroot/etc and
/run/nixos-etc-metadata -- which is the same cascade you get from any stage 1
mount failure, so it identifies neither the failing mount nor the cause. Read the
first error, not the loudest one.
modules/image.nix now creates those directories, plus /bin/sh and /usr/bin/env
as symlinks. The last two matter because activation builds them by writing a temp file
and renaming it, which a read-only root cannot do -- and activate ends in
exit $_status, so either one failing fails the boot. Their activation snippets are
disabled in modules/readonly.nix to match.
To check an image without flashing it, no root needed:
zstdcat result/sd-image/nanomecha-rpi4.img.zst > /tmp/img
dd if=/tmp/img of=/tmp/root.img bs=1M skip=28 status=none # rootfs starts at 28 MiB
debugfs -R "ls -l /" /tmp/root.img
Measuring boot time
Two different numbers, easy to confuse:
- From the journal (
journalctl -b -o short-monotonic,systemd-analyze time) the clock starts when the kernel starts. It cannot see the EEPROM bootloader,start4.elf, U-Boot, U-Boot reading ~37 MB of kernel and initrd off the SD card, or the kernel's own decompression. - From power-on, with a stopwatch, is that plus everything above.
The gap between them is the pre-kernel time, and the only way to get it is to subtract: stopwatch total minus the journal timestamp at which audio starts. Quoting a journal figure as a power-on figure understates the boot by however long the firmware and U-Boot took -- which on this board is not small, since U-Boot reads the kernel and initrd off a slow card before the kernel exists to log anything.
debugBoot = true writes nanomecha-timing.txt to the card's FAT partition ~45 s into
a successful boot, with systemd-analyze blame, the critical chain, and the full
journal in monotonic time.
Size
nanomecha-rpi4.img.zst (what you flash) |
150 MiB |
| written to the card | 249 MiB |
| system closure | 398 MiB, as a 116 MiB squashfs |
a stock NixOS sd-image-aarch64 |
~1.5-2 GB |
With squashfsStore = false the same system is 171 MiB compressed and 560 MiB on the
card. The compressed image barely changes, because zstd was already squeezing the
plain store on the way out -- the squashfs win is space on the card and less to read
from it at every boot.
Where it went, measured rather than guessed:
| removed | saving |
|---|---|
linux-firmware (via hardware.enableAllHardware) |
1809 MiB closure |
| kernel device trees for every other arm64 board + System.map | 130 MiB |
perl (setup-etc.pl, update-users-groups.pl) |
119 MiB closure |
nix itself |
its whole chain |
stock environment.corePackages (openssh, bind, curl, ncurses, procps, netcat, tar/gzip/xz, less...) |
most of a stock system path |
gnupg, via the systemd-importd unit |
49 MiB closure |
7582 of 7640 kernel modules (autoModules = false) |
~140 MiB |
| 33 systemd subsystems (importd, machined, homed, TPM2, EFI…) | 19 MiB |
hwdb.bin |
13 MiB |
Why the rootfs has no free space
It does not need any: the root filesystem is mounted read-only and every writable
path is a tmpfs, so nothing is ever written to the card. expandOnBoot is off for
the same reason.
When readOnlyRoot = false (the debug mode), the root becomes writable and the
image reserves 256 MiB explicitly in preBuildCommands -- make-ext4-fs sizes the
filesystem with resize2fs -M plus 16 MiB, and how much slack that leaves depends
on file layout: one build came out with 153 MiB free and the next with 25 MiB, which
was not enough for journald and left the system hitting ENOSPC on first boot.
The biggest remaining items are irreducible: systemd (78 MiB), the kernel Image (61 MiB), glibc (45 MiB), the initrd (23 MiB).
Audio: level and latency
Two things are set in settings.nix and both are worth understanding before changing:
volume is applied by the player (-af volume=...), not by a mixer -- there is no
ALSA mixer utility in the image, and alsa-utils would cost an 831 MiB closure for
one amixer call. The Pi's 3.5 mm jack is a headphone output and runs well above
consumer line level (-10 dBV), so feeding a line input wants roughly 0.3 (-10.5 dB).
A +4 dBu professional input wants the opposite: 0.7-1.0.
alsaBufferFrames is the dominant term in output latency. ffmpeg asks ALSA for
the largest buffer the device will give, capped by ALSA_BUFFER_SIZE_MAX, which
upstream sets to 131072 frames -- 2.73 s at 48 kHz before a sample is heard. The
alsa output device exposes no options at all, so pkgs/ffmpeg-radio.nix patches the
constant at build time. 16384 frames is 0.34 s. Lower is tighter but leaves less
slack before an underrun clicks.
Latency worth ruling out first: a constant offset is a buffer, and a variable one is the network. Wi-Fi contributes milliseconds and shows up as dropouts, never as a steady lag.
How it works
power on
-> Pi firmware (start4.elf) reads config.txt, loads armstub + u-boot.bin
-> U-Boot reads /boot/extlinux/extlinux.conf, loads the mainline kernel + its own DTB
-> systemd
nanomecha-settings reads settings.txt off the FAT partition, read-only
systemd-networkd DHCP on en* (metric 100) and wl* (metric 600)
wpa_supplicant associates with whichever SSID from settings.txt is in range
systemd-timesyncd sets the clock (a Pi has no RTC; TLS fails until it does)
radio.service ffmpeg: http -> ogg -> opus -> alsa
nanomecha-usb-power optional: USB port power follows a URL's 200
radio.service never waits for the network. It is started immediately with
Restart=always, RestartSec=2 and no start-rate limiting, so "there is no network
yet", "DNS isn't up", "the clock is still 1970" and "the stream is down" are all just
transient states it retries through. Two distinct stall cases are handled separately:
ffmpeg's -reconnect* flags cover a stream that ends or errors, while -rw_timeout
covers a half-open TCP connection that would otherwise hang silently forever.
Underneath all of it, the BCM2835 hardware watchdog reboots the board if the kernel
itself ever stops responding.
Notes on some of the choices
Analog audio needs no device-tree overlay. Mainline bcm2711-rpi-4-b.dts has no
audio node at all -- bcm2835-audio is a vchiq bus driver registered as a child of
vchiq, not matched from the DT. Loading vchiq is all it takes. enable_headphones
already defaults to true and enable_hdmi to false, so "Headphones" is the only ALSA
card and default resolves to it. (dtparam=audio=on is in config.txt anyway, and
is inert here: U-Boot loads the kernel's own DTB from FDTDIR, not the firmware's.)
Why the image is small. The stock sd-image-aarch64 profile sets
hardware.enableAllHardware, which sets hardware.enableRedistributableFirmware,
which pulls in linux-firmware -- a 1809 MiB closure. Forcing that off is the single
largest saving here; the Pi's Wi-Fi needs only raspberrypiWirelessFirmware, at
3.5 MiB. After that: no Nix, no documentation, no locale archive, a trimmed
environment.corePackages, and only the kernel modules this board can use.
Why a custom ffmpeg. Nothing needs ffmpeg specifically -- something needs to speak
TLS, demux Ogg, decode Opus, write to ALSA, and recover from stalls. Stock
ffmpeg-headless costs a 304 MiB closure and mpv 1139 MiB, for hundreds of codecs
this device will never see. Built with --disable-everything --enable-small and only
the four components the stream actually needs, ffmpeg is a single 2.6 MiB static
binary referencing nothing but glibc, openssl and alsa-lib -- roughly 5.6 MiB marginal,
since wpa_supplicant already brings openssl. alsa-utils is deliberately absent: its
closure is 831 MiB, because alsa-plugins depends on pipewire, gstreamer and libcamera.