Skip to content

Commit e318a72

Browse files
committed
v0.11.0: Airspy ADS-B support
The agent's ADS-B path is decoder-agnostic (it only polls readsb's aircraft.json), so RTL vs Airspy needed no agent logic change. The gaps were the installer (assumed RTL-SDR, producing a crash-looping readsb on Airspy-only boxes) and hardware inventory reporting. - internal/sdr: add DetectAirspy() (USB 1d50:60a1) and DeviceType field. Airspy is report-only inventory; kept out of the RTL "omni" pool since it can't serve UAT/ACARS/GOES and must never be hit with rtl_eeprom. - cmd/agent: report detected Airspy radios in health/UI; bump version. - scripts/install.sh: detect_airspy() + configure_airspy() install airspy_adsb and switch readsb to --net-only. Do the net-only rewrite ourselves rather than relying on airspy-conf's is-enabled guard, which silently skips when the readsb unit isn't enabled.
1 parent 1264d10 commit e318a72

5 files changed

Lines changed: 192 additions & 4 deletions

File tree

cmd/agent/main.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import (
3939
"github.com/skytracker/skytracker-device/internal/wifi"
4040
)
4141

42-
const version = "0.10.0"
42+
const version = "0.11.0"
4343

4444
func main() {
4545
var (
@@ -484,7 +484,14 @@ func main() {
484484
} else {
485485
// Real hardware detection.
486486
allSDRs := sdr.Detect()
487-
allDetectedSDRs = allSDRs
487+
// Airspy radios are reported for inventory/UI but kept out of the
488+
// RTL-SDR omni pool (they can't serve UAT/ACARS/GOES and must never
489+
// be touched by rtl_eeprom). They drive ADS-B via airspy_adsb.
490+
airspySDRs := sdr.DetectAirspy()
491+
allDetectedSDRs = append(append([]sdr.SDRDevice{}, allSDRs...), airspySDRs...)
492+
if len(airspySDRs) > 0 {
493+
log.Printf("[omni] detected %d Airspy radio(s) (ADS-B via airspy_adsb, not in omni pool)", len(airspySDRs))
494+
}
488495
readsbSerial, readsbActive := sdr.DetectReadsbSerial()
489496

490497
if readsbActive {

internal/sdr/detect.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ const (
1717
rtlProduct2838 = "2838"
1818
)
1919

20+
// Airspy R2/Mini USB vendor/product ID (shared across R0/R2/Mini).
21+
const (
22+
airspyVendorID = "1d50"
23+
airspyProductID = "60a1"
24+
)
25+
2026
// Detect enumerates RTL-SDR devices by walking /sys/bus/usb/devices/.
2127
// It first unloads conflicting DVB kernel modules that claim RTL-SDR hardware.
2228
func Detect() []SDRDevice {
@@ -78,8 +84,50 @@ func detectFromSysfs(sysfsBase string) []SDRDevice {
7884
ProductID: product,
7985
SerialNumber: readSysfsFile(filepath.Join(devPath, "serial")),
8086
TunerType: deriveTunerType(readSysfsFile(filepath.Join(devPath, "product"))),
87+
DeviceType: "rtlsdr",
88+
}
89+
90+
dev.USBBusNum, _ = strconv.Atoi(readSysfsFile(filepath.Join(devPath, "busnum")))
91+
dev.USBDevNum, _ = strconv.Atoi(readSysfsFile(filepath.Join(devPath, "devnum")))
92+
93+
devices = append(devices, dev)
94+
}
95+
96+
return devices
97+
}
98+
99+
// DetectAirspy enumerates Airspy R2/Mini devices by walking
100+
// /sys/bus/usb/devices/. Airspy radios are reported for inventory/UI only —
101+
// they are not part of the RTL-SDR "omni" pool (UAT/ACARS/GOES require RTL
102+
// tuners), and must never be touched by rtl_eeprom serial programming.
103+
func DetectAirspy() []SDRDevice {
104+
return detectAirspyFromSysfs("/sys/bus/usb/devices")
105+
}
106+
107+
func detectAirspyFromSysfs(sysfsBase string) []SDRDevice {
108+
entries, err := os.ReadDir(sysfsBase)
109+
if err != nil {
110+
return nil
111+
}
112+
113+
var devices []SDRDevice
114+
for _, entry := range entries {
115+
devPath := filepath.Join(sysfsBase, entry.Name())
116+
if readSysfsFile(filepath.Join(devPath, "idVendor")) != airspyVendorID {
117+
continue
118+
}
119+
if readSysfsFile(filepath.Join(devPath, "idProduct")) != airspyProductID {
120+
continue
81121
}
82122

123+
dev := SDRDevice{
124+
SysfsPath: devPath,
125+
VendorID: airspyVendorID,
126+
ProductID: airspyProductID,
127+
SerialNumber: readSysfsFile(filepath.Join(devPath, "serial")),
128+
TunerType: deriveAirspyModel(readSysfsFile(filepath.Join(devPath, "product"))),
129+
DeviceType: "airspy",
130+
}
83131
dev.USBBusNum, _ = strconv.Atoi(readSysfsFile(filepath.Join(devPath, "busnum")))
84132
dev.USBDevNum, _ = strconv.Atoi(readSysfsFile(filepath.Join(devPath, "devnum")))
85133

@@ -89,6 +137,14 @@ func detectFromSysfs(sysfsBase string) []SDRDevice {
89137
return devices
90138
}
91139

140+
// deriveAirspyModel derives a human-readable model from the USB product string.
141+
func deriveAirspyModel(product string) string {
142+
if strings.Contains(strings.ToUpper(product), "MINI") {
143+
return "Airspy Mini"
144+
}
145+
return "Airspy"
146+
}
147+
92148
// deriveTunerType guesses the tuner type from the USB product string.
93149
func deriveTunerType(product string) string {
94150
p := strings.ToUpper(product)

internal/sdr/detect_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,59 @@ func TestDetectFromSysfs(t *testing.T) {
5353
}
5454
}
5555

56+
func TestDetectAirspyFromSysfs(t *testing.T) {
57+
tmpDir := t.TempDir()
58+
59+
// Airspy R2 (1d50:60a1).
60+
airspyDir := filepath.Join(tmpDir, "1-1")
61+
os.MkdirAll(airspyDir, 0755)
62+
os.WriteFile(filepath.Join(airspyDir, "idVendor"), []byte("1d50\n"), 0644)
63+
os.WriteFile(filepath.Join(airspyDir, "idProduct"), []byte("60a1\n"), 0644)
64+
os.WriteFile(filepath.Join(airspyDir, "serial"), []byte("637862DC2E70ABD7\n"), 0644)
65+
os.WriteFile(filepath.Join(airspyDir, "product"), []byte("AIRSPY\n"), 0644)
66+
os.WriteFile(filepath.Join(airspyDir, "busnum"), []byte("1\n"), 0644)
67+
os.WriteFile(filepath.Join(airspyDir, "devnum"), []byte("3\n"), 0644)
68+
69+
// An RTL-SDR should NOT be picked up by the Airspy detector.
70+
rtlDir := filepath.Join(tmpDir, "1-2")
71+
os.MkdirAll(rtlDir, 0755)
72+
os.WriteFile(filepath.Join(rtlDir, "idVendor"), []byte("0bda\n"), 0644)
73+
os.WriteFile(filepath.Join(rtlDir, "idProduct"), []byte("2838\n"), 0644)
74+
75+
devices := detectAirspyFromSysfs(tmpDir)
76+
77+
if len(devices) != 1 {
78+
t.Fatalf("expected 1 Airspy device, got %d", len(devices))
79+
}
80+
dev := devices[0]
81+
if dev.DeviceType != "airspy" {
82+
t.Errorf("device type = %q, want airspy", dev.DeviceType)
83+
}
84+
if dev.SerialNumber != "637862DC2E70ABD7" {
85+
t.Errorf("serial = %q, want 637862DC2E70ABD7", dev.SerialNumber)
86+
}
87+
if dev.TunerType != "Airspy" {
88+
t.Errorf("model = %q, want Airspy", dev.TunerType)
89+
}
90+
}
91+
92+
func TestDeriveAirspyModel(t *testing.T) {
93+
tests := []struct {
94+
product string
95+
want string
96+
}{
97+
{"AIRSPY", "Airspy"},
98+
{"Airspy Mini", "Airspy Mini"},
99+
{"airspy mini", "Airspy Mini"},
100+
{"", "Airspy"},
101+
}
102+
for _, tt := range tests {
103+
if got := deriveAirspyModel(tt.product); got != tt.want {
104+
t.Errorf("deriveAirspyModel(%q) = %q, want %q", tt.product, got, tt.want)
105+
}
106+
}
107+
}
108+
56109
func TestDeriveTunerType(t *testing.T) {
57110
tests := []struct {
58111
product string

internal/sdr/types.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package sdr
22

3-
// SDRDevice describes an RTL-SDR dongle discovered via sysfs.
3+
// SDRDevice describes an SDR discovered via sysfs (RTL-SDR or Airspy).
44
type SDRDevice struct {
55
SysfsPath string
66
USBBusNum int
77
USBDevNum int
88
VendorID string
99
ProductID string
1010
SerialNumber string
11-
TunerType string // "R820T", "R820T2", "R828D", "unknown"
11+
TunerType string // RTL: "R820T"/"R820T2"/"R828D"/"unknown"; Airspy: "Airspy"/"Airspy Mini"
12+
DeviceType string // "rtlsdr" or "airspy"
1213
}
1314

1415
// SDRHandle is the interface used by the scheduler to reference an SDR device.

scripts/install.sh

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,63 @@ detect_existing_feeder() {
139139
return 1
140140
}
141141

142+
# Returns 0 if an Airspy R2/Mini (USB 1d50:60a1) is connected.
143+
detect_airspy() {
144+
if command -v lsusb &>/dev/null; then
145+
lsusb 2>/dev/null | grep -qiE '1d50:60a1|airspy' && return 0
146+
fi
147+
# sysfs fallback (lsusb may be absent on minimal images)
148+
local v
149+
for v in /sys/bus/usb/devices/*/idVendor; do
150+
[[ -f "$v" ]] || continue
151+
if [[ "$(cat "$v" 2>/dev/null)" == "1d50" ]] \
152+
&& [[ "$(cat "${v%idVendor}idProduct" 2>/dev/null)" == "60a1" ]]; then
153+
return 0
154+
fi
155+
done
156+
return 1
157+
}
158+
159+
# Sets up the Airspy ADS-B pipeline: airspy_adsb drives the Airspy and emits a
160+
# Beast stream that readsb ingests in --net-only mode. readsb (installed by
161+
# install_adsb_decoder) acts as the aggregator and serves aircraft.json via
162+
# tar1090, exactly as it does for RTL-SDR.
163+
#
164+
# Attribution: airspy_adsb is distributed by wiedehopf/airspy-conf (and Airspy);
165+
# SkyTracker installs it unchanged. See ACKNOWLEDGMENTS.md.
166+
configure_airspy() {
167+
if ! command -v readsb &>/dev/null; then
168+
warn "readsb not installed — cannot configure Airspy ADS-B pipeline"
169+
return
170+
fi
171+
172+
info "Configuring Airspy ADS-B decoder (airspy_adsb → readsb --net-only)"
173+
apt-get install -y -qq libairspy0 >/dev/null 2>&1 || true
174+
175+
# Install the airspy_adsb decoder if not already present. The "only-airspy"
176+
# arg installs just the decoder/service and skips airspy-conf's own readsb
177+
# rewrite (we do that ourselves below so it works regardless of whether the
178+
# readsb unit is currently 'enabled').
179+
if command -v airspy_adsb &>/dev/null || [[ -x /usr/local/bin/airspy_adsb ]]; then
180+
success "airspy_adsb already installed"
181+
elif bash -c "$(curl -fsSL https://raw.githubusercontent.com/wiedehopf/airspy-conf/master/install.sh)" only-airspy >/dev/null 2>&1; then
182+
success "airspy_adsb installed (thanks to wiedehopf)"
183+
else
184+
warn "Could not install airspy_adsb — see https://github.com/wiedehopf/airspy-conf"
185+
return
186+
fi
187+
188+
# Switch readsb to net-only so it ingests the Airspy Beast feed on port 30004.
189+
if [[ -f /etc/default/readsb ]] && ! grep -q -- '--net-only' /etc/default/readsb; then
190+
cp -n /etc/default/readsb /etc/default/readsb.pre-airspy 2>/dev/null || true
191+
sed -i 's|^RECEIVER_OPTIONS=.*|RECEIVER_OPTIONS="--net-only"|' /etc/default/readsb
192+
info "Set readsb to --net-only (backup at /etc/default/readsb.pre-airspy)"
193+
fi
194+
systemctl enable readsb >/dev/null 2>&1 || true
195+
systemctl restart readsb >/dev/null 2>&1 || true
196+
success "Airspy ADS-B pipeline configured"
197+
}
198+
142199
install_adsb_decoder() {
143200
if [[ "${SKYTRACKER_SKIP_DECODER:-0}" == "1" ]]; then
144201
info "SKYTRACKER_SKIP_DECODER=1 — skipping decoder install"
@@ -147,8 +204,16 @@ install_adsb_decoder() {
147204
return
148205
fi
149206

207+
local have_airspy=0
208+
if detect_airspy; then
209+
have_airspy=1
210+
info "Airspy SDR detected (1d50:60a1)"
211+
fi
212+
150213
if command -v readsb &>/dev/null; then
151214
success "ADS-B decoder already installed"
215+
# readsb present, but an Airspy may still need its decoder pipeline wired.
216+
[[ "$have_airspy" == "1" ]] && configure_airspy
152217
return
153218
fi
154219

@@ -191,6 +256,12 @@ install_adsb_decoder() {
191256
else
192257
warn "Could not install readsb — see https://github.com/wiedehopf/adsb-scripts"
193258
fi
259+
260+
# With an Airspy, readsb can't open it directly (no airspy driver in this
261+
# build) — wire up airspy_adsb and flip readsb to net-only instead.
262+
if [[ "$have_airspy" == "1" ]]; then
263+
configure_airspy
264+
fi
194265
}
195266

196267
# ── Install GPS support ──────────────────────────────────────────────────────

0 commit comments

Comments
 (0)