Skip to content

linkside-radio

The script on the Pi that sets up the radios, tunes them, sweeps channels, checks the sensor and recovers the USB bus. The app runs it over SSH; it is also the first tool by hand.

Command What
sudo linkside-radio up [capA] Removes all interfaces of both radios, including the driver wlanX, lifts a soft rfkill block on the radios, and creates capA and capB as monitor with the flags control otherbss. Then runs status. With a role only that radio, for example while the other A9000 is the test client on the Fedora PC; status then skips separate-controllers
sudo linkside-radio tune capA 5180 80 [5210] Primary channel in MHz, width (20, 40, 80, 160) and optionally center1. Without center1 the script calculates it from the 5 and 6 GHz channelization; with center1 it checks that it is correct. 2.4 GHz at 40 MHz requires center1 (primary ± 10). Then it reads back via iw: a mismatch gives exit 1
linkside-radio status [capA] First what the sensor runs (sensor): the version of its sources from /etc/linkside/version, which make install writes (below), the interface number of the script (below), install=incomplete while the marker of an install cut off is there (/etc/linkside/installing, which make install writes before its first file and removes after the version), uname -r, and the version of the firmware-mediatek package, none for what it cannot tell. Then per role the radio, USB port, speed, controller, interface and the channel; the speed and duplex of eth0 (link, a line and not a check: the app warns below 1000 Mbit/s); then the checks: rule 1 (only-monitor, nm-unmanaged, rfkill), regdomain, no-ntp (timedatectl reports no time service) and separate-controllers. Exit 1 on a failed check
sudo linkside-radio sweep capB 120 5180 5200/40 6135/160 [--rounds 2] One sweep per role: tune, up and a new sweep first stop a sweep that still runs on that role (TERM, then wait), so the last command wins. Hops the radio along the channels: primary channel in MHz, optionally /width (default 20) and /center1. Dwell in ms, at least 110 (one beacon interval plus the retune). Only iw dev … set freq, never a scan. Each channel must be in the channel list; that is checked before the first hop. Without --rounds the sweep runs until TERM, INT or HUP and then ends with a sweep … state=end line. A hop the driver rejects gives ok=0 and the sweep continues; exit 1 at the end
linkside-radio channels 2.4\|5\|6\|6-psc The channel list as primary frequencies, one per line. Default NL: 2.4 GHz 1–13, 5 GHz 36–64, 100–144 and 149–165, 6 GHz 1–93; 6-psc gives the preferred scanning channels among those (5, 21, 37, 53, 69, 85). A custom list of channel numbers in radios.conf: channels_2g, channels_5g, channels_6g
sudo linkside-radio usb-reset Turns the USB ports off and on again with uhubctl (recovery after the warm reboot bug), then runs up
sudo linkside-radio claim [--take-over] <session> <host> Holds the sensor for a session until the command ends, which it does when its SSH channel closes: a held line every second, and a write to a closed channel ends it, within a second. The claim file /run/lock/linkside-claim names the session, the Mac, the time and the claim's PID; a claim whose process is gone holds nothing. A claim of the same session replaces the old one. Another session's waits 3 s for it to end, then is refused with exit 3 and the holder's claim line; with --take-over it ends the holder's claim, after writing its own file, so the session taken over finds who took it
sudo linkside-radio release <session> Ends that session's claim and waits for it; nothing when another session holds the sensor, or none
linkside-radio holder The claim line of the session that holds the sensor; exit 3 while one does other than -s's
sudo linkside-radio install <dir> Installs the sources in <dir>, as make copy and the app's Update Sensor leave them, with their own make install, linkside-cap built there first by pi (make -C <dir>/linkside-cap; without it exit 1). Sources whose script offers a lower interface than the installed one are refused under the lock, exit 1: an app with older sources never sets the sensor back; sudo make install by hand can. make's own output goes to stderr; stdout is one install line with the version from the directory's VERSION, the interface its script offers and reboot=yes when linkside-system-setup changed a boot setting, which takes effect with reboot. A failed make install gives exit 1 with its output. Once make install succeeded the exit is 0 even when its SSH channel closed meanwhile: the output is lost, not the install, and the command after it on the line still runs
sudo linkside-radio poweroff, reboot Powers the Pi off or restarts it two seconds later, through a transient systemd timer, so the command returns and its SSH channel closes before sshd stops. The app's File > Shut Down Sensor… runs poweroff

-n shows the commands that change something, without running them: linkside-radio -n up.

-s <session> names the session a command acts for. While a session holds the sensor, up, tune, sweep, usb-reset, poweroff, reboot and install of any other session, or without -s, are refused with exit 3: "the sensor is held by session … from … since …: tune refused". The check runs before a running sweep is stopped and again under the lock a claim takes to change hands, and a sweep checks before each hop: the sweep of a session taken over ends before its next hop, with exit 3. With no session holding it they run, by hand as before. The app and linkside-cli live claim the sensor before the radio check and release it after the last capture, waiting 3 s at most for a sensor that does not answer. Every capture from the app and from linkside-cli goes through linkside-radio holder first, with -s for a session's own: a session taken over starts no capture again, and linkside-cli capture does not start while a session holds the sensor, whose capture it would load. linkside-cap started by hand does not check. status names the holder in its claim line, a line and not a check. A second session therefore cannot touch the radios of a running one, from another window, the CLI or a second Mac.

On sensor-1 on 2026-10-25, with linkside-cli live and the app over ssh's ControlMaster: a tune without the session, a capture and a second session were refused with the holder named, the second session after its 3 s wait; a take-over, from the CLI and with the app's Take Over, ended the session taken over with the one that took it named, its sweep gone from the Pi; a session's claim was gone 1.6 s after its linkside-cli was killed with SIGKILL, its sweep and captures with it, and at once after a session's end.

The role is tied to the permanent MAC address of the radio, not to the interface name or the USB port. After swapping the sticks, capA therefore stays the same stick.

Output on stdout, one line per item:

sensor version=4e841f2 interface=1 kernel=6.18.50+rpt-rpi-2712 firmware=1:20260519-1~bpo13+1+rpt1
sensor version=4e841f2 interface=1 install=incomplete kernel=6.18.50+rpt-rpi-2712 firmware=1:20260519-1~bpo13+1+rpt1
claim held=1 session=20261025-141203-3f9a host=MacBook-Pro since=1761394923
radio role=capA mac=… present=1 phy=phy0 usb=2-1 speed=5000 controller=xhci-hcd.0 iface=capA type=monitor addr=… freq=5180 width=80 center1=5210
link iface=eth0 speed=1000 duplex=full
check name=only-monitor ok=1
check name=nm-unmanaged ok=1
check name=rfkill ok=1
check name=regdomain ok=1 country=NL
check name=separate-controllers ok=1 controllers=xhci-hcd.0,xhci-hcd.1
tune role=capA freq=5180 width=80 center1=5210 verified=yes ts=1760000000.123
sweep role=capB state=start dwell_ms=120 channels=3 rounds=0 ts=1760000000.200
hop role=capB freq=5180 width=20 center1=5180 ok=1 ts=1760000000.205
hop role=capB freq=5260 width=20 center1=5260 ok=0 error=command_failed:_Invalid_argument_(-22) ts=1760000000.330
sweep role=capB state=end rounds=4 hops=13 failed=4 ts=1760000001.900
usb-reset hubs=2,4 seconds=9
poweroff in=2 ts=1760000002.000
held session=20261025-141203-3f9a ts=1761394924.004
release session=20261025-141203-3f9a held=1
install version=4e841f2 interface=1 reboot=no

With sweep, the ts of a hop comes after the retune: from that moment the radio listens on that channel, until the next hop. rounds in the end line counts only complete rounds. The sweep takes the lock per hop, so a tune of the other radio waits at most one hop.

Check What
only-monitor Every Wi-Fi interface on the Pi is a monitor interface (sysfs type 803)
nm-unmanaged NetworkManager does not manage capA and capB. It leaves monitor interfaces out of nmcli device; absent counts as unmanaged
rfkill No rfkill block on the radios (blocked=capA:soft)
regdomain iw reg get shows the country from radios.conf (NL)
separate-controllers The two radios hang off different xHCI controllers

verified=unknown with tune: iw shows no channel for the interface, so the setting has not been read back.

usb-reset switches all four USB-A ports together. Other USB devices then also lose power, and a running linkside-cap stops with an error. Ethernet is not on USB, so SSH stays up. The script checks that the radios disappear after switching off; if they stay visible, the hub numbers in usb_hubs are wrong and it stops with an error.

The interface number

What the app depends on is the interface of the sensor's scripts: the commands and arguments of linkside-radio, the lines and fields of status, the start line of linkside-cap. INTERFACE in linkside-radio numbers it, and status prints it on the sensor line. It is raised in the commit that changes any of those, so that an app of that commit or later can tell a sensor that does not offer it yet. The app knows the number of the sources it carries (the BuildStamp plugin reads it) and the lowest it works with (SensorSources.minimumInterface), raised in the commit from which the app depends on what the number before does not offer. A commit is no measure: two commits a week apart can offer the same interface, and the app cannot order commits.

Number Since What
none, read as 0 A sensor installed before the number: it may lack claim, and has no install
1 2026-10-25 claim, release, holder, -s, install, and install=incomplete on the sensor line

Against its own number the app holds a sensor below its minimum as too old: Test connection says so with both numbers, New Session does not start, and a live session started otherwise ends with "too old for this app". A sensor at or above the minimum and below the app's own number, or at the same number from another build of its sources (the version), is offered Update Sensor without that stop. A sensor above the app's own number is newer than the app: a note, never an update, which would set it back for the Mac that installed it. A sensor whose last install was cut off holds files of two versions, whatever its number: it is not ready either, and Update Sensor installs the app's sources whole unless it is newer than the app. Update Sensor needs install, so a sensor without the number gets its sources by hand once (Updating).

The version

The version of the sensor line is the version of the sensor's sources alone: tools/source-version --only sensor, the last commit that changed a file under sensor/, and for files there not committed, changed or new, -dirty+ and a fingerprint of those changes (996309f, 996309f-dirty+e1ac8741fe22). make copy writes it into VERSION beside the files it sends, and make install writes that into /etc/linkside/version. The app's build stamps the same version beside the archive of those files it carries for Update Sensor (SensorSources.version), so the two read alike exactly when the sensor runs the files the app carries: a commit that changed nothing under sensor/, of the documentation or the app, leaves it as it was, and an app built after it reads the sensor as its own. At the same interface number another version is another build of the sensor's sources, a note with Update Sensor. A commit that brings back the files of an older one reads as itself, not as that one, until Update Sensor installs it. The app's own commit, which session.json records in app and Settings names as this app's, is that of the whole checkout (tools/source-version), and moves with every commit.

The sudoers rule

poweroff and reboot sit within the sudoers rule of pi, which allows linkside-radio and nothing else. A powered-off Pi starts again only when its power is cut and connected again. A reboot is no power cycle: after the warm reboot bug only cutting the power brings a stick back (CLAUDE.md, "USB and A9000").

install sits within it too, on purpose: it runs make install as root from a directory pi owns, so with it pi is in effect root, and whoever holds an operator's key can install anything. On this Pi the gap is small: a key already drives the radios and receives the captures, and the Pi hangs link-local on the Mac without a gateway. What the app installs is the code of its own commit, the same review as make install by hand. Asking for pi's password instead would keep pi from root only until every operator knows it (decided 2026-10-25). iw as root, which could add a managed interface or start a scan (rule 1), stays outside the rule.