Software-defined radio as a filesystem, over 9P2000.
Two servers. adsb9p presents an ADS-B receiver — aircraft, positions, a live
message stream. sdr9p presents RTL-SDR devices — enumeration, tuning,
spectrum sweeps. Both speak plain 9P2000, so a Linux client mounts them with no
extra software and Plan 9 or Inferno hosts reach them natively.
No API. No RPC. No JSON. Reading and writing files tunes a radio.
mount -t 9p -o trans=tcp,port=5641,version=9p2000 receiver /mnt/adsb
cat /mnt/adsb/aircraft/881055/callsign # NOK100
cat /mnt/adsb/events # blocks until the next message
echo 'range 88M:108M:100k' > /mnt/sdr/dev/0/ctl
echo 'gain 40.2' > /mnt/sdr/dev/0/ctl
cat /mnt/sdr/dev/0/spectrum # CSV sweep/
help usage
summary one line per aircraft
stats receiver statistics, passed through verbatim
events blocking read; one SBS record per read
aircraft/
<hex>/
callsign lat lon altitude track speed
squawk vertrate ground messages seen
State is accumulated from a readsb/dump1090 SBS/BaseStation stream (port
30003), not from JSON. Each message type carries only part of the picture —
callsign arrives on MSG,1, position on MSG,3, velocity on MSG,4, squawk
on MSG,6 — so the tracker folds them together and expires aircraft it stops
hearing from. Fields an aircraft has never transmitted read - rather than
zero.
events is the Plan 9 /dev/mouse idiom: a read returns the next record and
blocks until one arrives. Any language with read becomes a live aircraft
feed.
Units are feet, knots and degrees — what ADS-B actually transmits.
adsb9p -addr :5641 -sbs 127.0.0.1:30003 -stats /run/readsb/stats.json/
devices index, name, serial, tuner, accessibility
refresh write anything to re-enumerate
dev/
<index>/
info tuner and supported gain values
status "free" or "busy"
ctl write: range LOW:HIGH:STEP | gain N | integration N
settings current values
spectrum read: runs a sweep, returns rtl_power CSV
Drives the rtl-sdr command line tools rather than linking librtlsdr. That
keeps the binary free of cgo — so it cross-compiles to any target Go supports —
and keeps libusb out of a network-facing daemon's address space. An RTL dongle
can be opened by exactly one process, so while something else holds it,
status reads busy and spectrum says so rather than pretending.
Raw I/Q is deliberately not exposed. 9P does a round trip per read and a
2.4 MS/s dongle produces ~4.8 MB/s; use rtl_sdr directly for that. Decoded
state is small and hierarchical, which is what 9P is good at.
sdr9p -addr :5642Mounting needs root, and macOS has no 9P client at all. 9pget is a small
standalone client so the servers are testable anywhere, including CI.
9pget host:5641 /summary
9pget -ls host:5641 /aircraft
9pget -w 'gain 49.6' host:5642 /dev/0/ctlNotes from getting this working on real hardware. All cost time; none are documented anywhere obvious.
RTL-SDR Blog V4 needs the vendor driver, and fails silently without it.
The V4 uses an R828D tuner that stock librtlsdr cannot lock. Nothing errors —
you get a plausible-looking sweep that is entirely wrong. On a working
receiver an FM band sweep shows ~30 dB of dynamic range with discrete
stations; through stock librtlsdr the same antenna gave 5.2 dB of flat noise.
Build from rtlsdrblog/rtl-sdr-blog
with -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON. rtl_test prints
RTL-SDR Blog V4 Detected when the right driver is in place, and stops
printing [R82XX] PLL not locked!.
udev rules only apply on device add. Installing rtl-sdr while the dongle
is already plugged in leaves the node root:root, and every open fails with
usb_open error -3 despite the rules being correct and your user being in
plugdev. Replug it, or udevadm control --reload-rules && udevadm trigger.
When librtlsdr cannot open a device, rtl_test prints uninitialised
memory where the USB strings belong — and it still matches a reasonable
parser. This server rejects any name or serial that is not printable ASCII
rather than serving binary junk through a namespace other programs read.
dvb_usb_rtl28xxu claims the dongle on every boot unless blacklisted, and
the module cannot be unloaded while the device is bound, so the blacklist only
takes effect after a reboot.
Written to port to Limbo. Go and Limbo share ancestry, so idiomatic Go transliterates closely — provided you stay inside the overlap:
- Channels, not mutexes. All aircraft state is owned by one goroutine and
reached only through channels, which maps onto Limbo's
chanandspawn. - No reflection. No
encoding/json, no struct tags. The SBS feed is line-oriented text and parses identically in both languages.statsis passed through as opaque bytes rather than parsed. - No generics, and a hand-rolled sort rather than
sort.Slice. - The 9P layer is the port boundary. On Inferno it would be replaced by
styxlib; nothing above it should need to change.
Optional values carry explicit Have flags rather than using pointers, since
Limbo has no nil-able scalars.
Input arrives on an untrusted socket, so the decoders are the sharp edge. The
message framing rejects sizes below 7 and above MaxMessageSize, and reads
into a preallocated buffer so the payload slice cannot overrun. Twalk is
bounded to MAXWELEM (16) — without that, a 10-byte message claiming 65535
path elements makes the server allocate for all of them. Connections are
served from goroutines with recover(), so a panic costs one client rather
than the process.
Bind to localhost unless you mean otherwise: 9P2000 as implemented here has no
authentication, and Tattach accepts any client.
Three of the tests are regressions for bugs found while building this:
bufio.ScannerreturnsErrTooLongon an over-long line and cannot resume — one oversized record from the network permanently killed the feed- unbounded
nwnameallocation amplification - uninitialised memory reaching the namespace as a device name
go test ./...
go test ./internal/protocol -run=xxx -fuzz=FuzzServergo build ./...
# no cgo, so cross-compilation is trivial
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build ./cmd/adsb9p # Pi 2/3
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/sdr9p # Pi 4/5, JetsonTested on Raspberry Pi 2 (ARMv7, Raspbian Trixie) and NVIDIA Jetson (ARM64,
Ubuntu 22.04), against the Linux kernel's v9fs client and 9pget.
- llm9p — an LLM as a 9P filesystem;
the protocol implementation here started as a copy of its
internal/protocol
MIT. See LICENSE.