Overly complicated and minimized resilient NixOS webstream player
  • Nix 84.1%
  • Shell 15.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
defaultuser0 2ee0d7ac39 Drop secrets.nix and shorten the comments
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
2026-09-04 21:41:42 +02:00
modules Drop secrets.nix and shorten the comments 2026-09-04 21:41:42 +02:00
overlays first and last commit prob 2026-08-30 11:25:56 +02:00
pkgs first and last commit prob 2026-08-30 11:25:56 +02:00
.gitignore Drop secrets.nix and shorten the comments 2026-09-04 21:41:42 +02:00
flake.lock first and last commit prob 2026-08-30 11:25:56 +02:00
flake.nix first and last commit prob 2026-08-30 11:25:56 +02:00
README.md Cut USB port power from a URL 2026-09-04 21:41:34 +02:00
settings.nix Drop secrets.nix and shorten the comments 2026-09-04 21:41:42 +02:00

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, an EnvironmentFile for radio.service. Only STREAM_URL and VOLUME are ever emitted -- this file becomes the player's environment, so passing the card's contents through unfiltered would let it set PATH or LD_PRELOAD.
  • /run/nanomecha/wireless.conf, loaded by wpa_supplicant with -I.
  • /run/nanomecha/usb-power.env, an EnvironmentFile for nanomecha-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-update reports 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_GRACE failures 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.