Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sdr9p

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

adsb9p

/
  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

sdr9p

/
  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 :5642

9pget

Mounting 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/ctl

Traps

Notes 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.


Design

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 chan and spawn.
  • No reflection. No encoding/json, no struct tags. The SBS feed is line-oriented text and parses identically in both languages. stats is 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.

Security

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.Scanner returns ErrTooLong on an over-long line and cannot resume — one oversized record from the network permanently killed the feed
  • unbounded nwname allocation amplification
  • uninitialised memory reaching the namespace as a device name
go test ./...
go test ./internal/protocol -run=xxx -fuzz=FuzzServer

Build

go 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, Jetson

Tested on Raspberry Pi 2 (ARMv7, Raspbian Trixie) and NVIDIA Jetson (ARM64, Ubuntu 22.04), against the Linux kernel's v9fs client and 9pget.

Related

  • llm9p — an LLM as a 9P filesystem; the protocol implementation here started as a copy of its internal/protocol

Licence

MIT. See LICENSE.

About

Software-defined radio and ADS-B as 9P filesystems

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages