Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save justingreerbbi/c6dc7e4a962c1f5d4947d773c2ad45b8 to your computer and use it in GitHub Desktop.

Select an option

Save justingreerbbi/c6dc7e4a962c1f5d4947d773c2ad45b8 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# noxradio-install.sh
# Installs + configures: Direwolf (AIOC auto-detect), AX.25/ROSE modules, KISS TCP, and a minimal web UI to test packets.
# Updated: prompts for callsign during install.
#
# Run:
# chmod +x noxradio-install.sh
# sudo ./noxradio-install.sh
set -euo pipefail
LOG_TAG="[noxradio]"
DW_REPO="https://github.com/wb2osz/direwolf.git"
DW_SRC_DIR="/opt/direwolf-src"
DW_CONF="/etc/direwolf.conf"
DW_USER="direwolf"
WEB_DIR="/opt/noxradio-web"
WEB_PORT="8080"
KISS_PORT="8001"
DEFAULT_MYCALL="N0CALL-10"
# --- helpers ---
log() { echo "${LOG_TAG} $*"; }
warn() { echo "${LOG_TAG} WARNING: $*" >&2; }
die() { echo "${LOG_TAG} ERROR: $*" >&2; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
is_root() { [[ "${EUID}" -eq 0 ]]; }
# Determine "main" non-root user for web app files/group membership
detect_primary_user() {
if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
echo "${SUDO_USER}"
else
local u
u="$(ls -1 /home 2>/dev/null | head -n 1 || true)"
[[ -n "${u}" ]] && echo "${u}" || echo "pi"
fi
}
apt_install() {
local pkgs=("$@")
log "Installing packages: ${pkgs[*]}"
DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}"
}
# --- callsign prompt ---
prompt_callsign() {
local existing=""
if [[ -f "${DW_CONF}" ]]; then
existing="$(sed -n 's/^MYCALL[[:space:]]\+\(.*\)$/\1/p' "${DW_CONF}" | head -n 1 || true)"
fi
echo ""
echo "============================================================"
echo " Direwolf Callsign Setup"
echo "============================================================"
if [[ -n "${existing}" ]]; then
echo "Current MYCALL in ${DW_CONF}: ${existing}"
fi
echo "Enter your callsign for Direwolf (any value 4+ characters)"
echo "Example (common APRS style): K1ABC-10"
echo "Press ENTER to keep current value or use default ${DEFAULT_MYCALL}."
echo ""
local input=""
read -r -p "MYCALL [${existing:-${DEFAULT_MYCALL}}]: " input || true
input="${input//[[:space:]]/}" # strip whitespace
if [[ -n "${input}" ]]; then
echo "${input}"
elif [[ -n "${existing}" ]]; then
echo "${existing}"
else
echo "${DEFAULT_MYCALL}"
fi
}
# Basic callsign validation (lenient; allows A-Z 0-9 and optional -SSID)
validate_callsign() {
local cs="$1"
cs="${cs//[[:space:]]/}"
[[ ${#cs} -gt 3 ]]
}
# --- hardware detection (AIOC) ---
detect_audio_capture_card() {
local line card
line="$(arecord -l 2>/dev/null | grep -E 'card [0-9]+' | grep -viE 'bcm2835|vc4hdmi' | head -n 1 || true)"
if [[ -z "${line}" ]]; then
echo ""
return
fi
card="$(echo "${line}" | sed -n 's/.*card \([0-9]\+\).*/\1/p' | head -n 1)"
echo "${card}"
}
detect_ptt_hidraw_device() {
local dev props
for dev in /dev/hidraw*; do
[[ -e "${dev}" ]] || continue
props="$(udevadm info -q property -n "${dev}" 2>/dev/null || true)"
if echo "${props}" | grep -qiE 'CM108|C-Media|Cmedia|USB Audio'; then
echo "${dev}"
return
fi
done
for dev in /dev/hidraw*; do
[[ -e "${dev}" ]] || continue
echo "${dev}"
return
done
echo ""
}
detect_serial_tty_device() {
if [[ -e /dev/ttyACM0 ]]; then
echo "/dev/ttyACM0"
return
fi
if [[ -e /dev/ttyUSB0 ]]; then
echo "/dev/ttyUSB0"
return
fi
echo ""
}
udev_vid_pid_for_node() {
local node="$1"
local props vid pid
props="$(udevadm info -q property -n "${node}" 2>/dev/null || true)"
vid="$(echo "${props}" | sed -n 's/^ID_VENDOR_ID=\(.*\)$/\1/p' | head -n 1)"
pid="$(echo "${props}" | sed -n 's/^ID_MODEL_ID=\(.*\)$/\1/p' | head -n 1)"
if [[ -n "${vid}" && -n "${pid}" ]]; then
echo "${vid} ${pid}"
else
echo ""
fi
}
write_udev_rules() {
local hid_node="$1"
local tty_node="$2"
local rule_file="/etc/udev/rules.d/99-noxradio-aioc.rules"
local hid_vp tty_vp hid_vid hid_pid tty_vid tty_pid
hid_vp="$(udev_vid_pid_for_node "${hid_node}")"
tty_vp="$(udev_vid_pid_for_node "${tty_node}")"
{
echo "# NoxRadio AIOC permissions (generated)"
echo "# Allows non-root Direwolf access to hidraw (CM108-style PTT) and serial (DTR/RTS PTT)"
echo ""
if [[ -n "${hid_vp}" ]]; then
hid_vid="$(echo "${hid_vp}" | awk '{print $1}')"
hid_pid="$(echo "${hid_vp}" | awk '{print $2}')"
echo "SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"${hid_vid}\", ATTRS{idProduct}==\"${hid_pid}\", MODE=\"0660\", GROUP=\"input\""
else
echo "# hidraw VID/PID not detected; falling back to generic (may be too broad):"
echo "SUBSYSTEM==\"hidraw\", MODE=\"0660\", GROUP=\"input\""
fi
if [[ -n "${tty_vp}" ]]; then
tty_vid="$(echo "${tty_vp}" | awk '{print $1}')"
tty_pid="$(echo "${tty_vp}" | awk '{print $2}')"
echo "SUBSYSTEM==\"tty\", ATTRS{idVendor}==\"${tty_vid}\", ATTRS{idProduct}==\"${tty_pid}\", MODE=\"0660\", GROUP=\"dialout\""
else
echo "# tty VID/PID not detected; generic dialout permissions already handle /dev/ttyACM* and /dev/ttyUSB*"
fi
} > "${rule_file}"
chmod 0644 "${rule_file}"
udevadm control --reload-rules
udevadm trigger
log "Wrote udev rules: ${rule_file}"
}
# --- direwolf install ---
install_direwolf() {
if command -v direwolf >/dev/null 2>&1; then
log "Direwolf already installed at: $(command -v direwolf)"
return
fi
log "Installing Direwolf from source into /usr/local..."
rm -rf "${DW_SRC_DIR}"
git clone "${DW_REPO}" "${DW_SRC_DIR}"
mkdir -p "${DW_SRC_DIR}/build"
pushd "${DW_SRC_DIR}/build" >/dev/null
cmake ..
make -j2
make install
make install-conf || true
popd >/dev/null
need_cmd direwolf
log "Direwolf installed: $(command -v direwolf)"
}
ensure_direwolf_user() {
if id -u "${DW_USER}" >/dev/null 2>&1; then
log "User ${DW_USER} exists"
else
log "Creating system user: ${DW_USER}"
useradd -r -m -s /usr/sbin/nologin "${DW_USER}"
fi
usermod -aG audio,dialout,input "${DW_USER}" || true
}
write_direwolf_config() {
local audio_card="$1"
local hid_node="$2"
local tty_node="$3"
local mycall="$4"
local adevice_line=""
local ptt_line=""
local ptt_comment=""
if [[ -n "${audio_card}" ]]; then
adevice_line="ADEVICE plughw:${audio_card},0"
else
adevice_line="# ADEVICE plughw:1,0 # TODO: set correct card,device"
fi
if [[ -n "${hid_node}" ]]; then
ptt_line="PTT CM108"
ptt_comment="# Using CM108-style HID PTT (recommended for AIOC)"
elif [[ -n "${tty_node}" ]]; then
ptt_line="PTT ${tty_node} DTR -RTS"
ptt_comment="# Using serial DTR/RTS PTT (fallback)"
else
ptt_line="# PTT CM108 # TODO: enable PTT once AIOC is detected"
ptt_comment="# No hidraw/tty device detected at install time."
fi
cat > "${DW_CONF}" <<EOF
# /etc/direwolf.conf (generated by noxradio-install.sh)
# Edit this file as needed.
${adevice_line}
ARATE 48000
# Your callsign
MYCALL ${mycall}
CHANNEL 0
${ptt_comment}
${ptt_line}
# Expose KISS over TCP for your web app / custom software
KISSPORT ${KISS_PORT}
EOF
chmod 0644 "${DW_CONF}"
log "Wrote Direwolf config: ${DW_CONF}"
}
write_direwolf_service() {
local svc="/etc/systemd/system/direwolf.service"
cat > "${svc}" <<EOF
[Unit]
Description=Direwolf Soundcard TNC
After=network.target sound.target
[Service]
Type=simple
User=${DW_USER}
ExecStart=/usr/local/bin/direwolf -c ${DW_CONF} -t 0
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now direwolf
log "Enabled + started direwolf.service"
}
# --- AX.25 / ROSE setup ---
write_modules_load() {
local f="/etc/modules-load.d/noxradio.conf"
cat > "${f}" <<'EOF'
# NoxRadio packet modules
ax25
rose
netrom
EOF
chmod 0644 "${f}"
modprobe ax25 2>/dev/null || true
modprobe rose 2>/dev/null || true
modprobe netrom 2>/dev/null || true
log "Configured module autoload: ${f}"
}
write_axports() {
local mycall="$1"
local f="/etc/ax25/axports"
mkdir -p /etc/ax25
if [[ -f "${f}" ]] && grep -qE '^ax0[[:space:]]' "${f}"; then
# Update ax0 line if exists
sed -i -E "s/^ax0[[:space:]]+.*/ax0\t${mycall}\t9600\t255\t2\tNoxRadio Direwolf KISS bridge/" "${f}"
log "Updated existing ax0 in: ${f}"
return
fi
cat >> "${f}" <<EOF
# NoxRadio AX.25 port (generated)
ax0 ${mycall} 9600 255 2 NoxRadio Direwolf KISS bridge
EOF
log "Updated: ${f}"
}
write_ax25_bridge_script() {
local script="/usr/local/sbin/noxradio-ax25-bridge.sh"
cat > "${script}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
KISS_TCP_HOST="127.0.0.1"
KISS_TCP_PORT="8001"
PTY_LINK="/tmp/dwkiss"
AXPORT="ax0"
INET_ADDR="44.1.1.1"
log() { echo "[ax25-bridge] $*"; }
rm -f "${PTY_LINK}"
log "Starting socat PTY <-> TCP KISS (${KISS_TCP_HOST}:${KISS_TCP_PORT})"
socat -d -d pty,raw,echo=0,link="${PTY_LINK}" "tcp:${KISS_TCP_HOST}:${KISS_TCP_PORT}" &
SOCAT_PID=$!
for i in $(seq 1 30); do
[[ -e "${PTY_LINK}" ]] && break
sleep 0.2
done
if [[ ! -e "${PTY_LINK}" ]]; then
kill "${SOCAT_PID}" 2>/dev/null || true
exit 1
fi
log "Attaching KISS: ${PTY_LINK} -> ${AXPORT} (${INET_ADDR})"
kissattach "${PTY_LINK}" "${AXPORT}" "${INET_ADDR}" || true
ip link set "${AXPORT}" up || true
wait "${SOCAT_PID}"
EOF
chmod 0755 "${script}"
log "Wrote: ${script}"
}
write_ax25_bridge_service() {
local svc="/etc/systemd/system/noxradio-ax25-bridge.service"
cat > "${svc}" <<EOF
[Unit]
Description=NoxRadio AX.25 bridge (Direwolf KISS TCP -> ax0)
After=network.target direwolf.service
Wants=direwolf.service
[Service]
Type=simple
User=root
ExecStart=/usr/local/sbin/noxradio-ax25-bridge.sh
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now noxradio-ax25-bridge
log "Enabled + started noxradio-ax25-bridge.service"
}
# --- Web test app ---
write_web_app() {
mkdir -p "${WEB_DIR}"
cat > "${WEB_DIR}/app.py" <<'EOF'
import asyncio
import socket
from typing import Set, Optional, Tuple
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
app = FastAPI()
FEND = 0xC0
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
clients: Set[WebSocket] = set()
_reader_task: Optional[asyncio.Task] = None
def kiss_unescape(data: bytes) -> bytes:
out = bytearray()
i = 0
while i < len(data):
b = data[i]
if b == FESC and i + 1 < len(data):
nxt = data[i + 1]
if nxt == TFEND:
out.append(FEND)
i += 2
continue
if nxt == TFESC:
out.append(FESC)
i += 2
continue
out.append(b)
i += 1
return bytes(out)
def ax25_addr_decode(addr7: bytes) -> Tuple[str, int, bool]:
call = ""
for c in addr7[0:6]:
ch = (c >> 1) & 0x7F
if ch != 0x20:
call += chr(ch)
ssid = (addr7[6] >> 1) & 0x0F
last = bool(addr7[6] & 0x01)
return call, ssid, last
def parse_ax25_ui(frame: bytes) -> Optional[str]:
if len(frame) < 16:
return None
i = 0
dest, dest_ssid, _ = ax25_addr_decode(frame[i:i+7]); i += 7
src, src_ssid, last = ax25_addr_decode(frame[i:i+7]); i += 7
path = []
while not last:
if i + 7 > len(frame):
return None
digi, digi_ssid, last = ax25_addr_decode(frame[i:i+7]); i += 7
path.append(f"{digi}-{digi_ssid}" if digi_ssid else digi)
if i + 2 > len(frame):
return None
control = frame[i]; pid = frame[i+1]; i += 2
if control != 0x03 or pid != 0xF0:
return None
payload = frame[i:].decode("ascii", errors="replace")
src_str = f"{src}-{src_ssid}" if src_ssid else src
dest_str = f"{dest}-{dest_ssid}" if dest_ssid else dest
if path:
return f"{src_str}>{dest_str},{','.join(path)}:{payload}"
return f"{src_str}>{dest_str}:{payload}"
async def broadcast(msg: str) -> None:
dead = []
for ws in clients:
try:
await ws.send_text(msg)
except Exception:
dead.append(ws)
for ws in dead:
clients.discard(ws)
async def direwolf_reader(host="127.0.0.1", port=8001) -> None:
while True:
try:
s = socket.create_connection((host, port), timeout=10)
s.settimeout(1.0)
await broadcast("[web] Connected to Direwolf KISS TCP")
buf = bytearray()
while True:
try:
chunk = s.recv(4096)
except socket.timeout:
chunk = b""
if chunk:
buf.extend(chunk)
while True:
try:
start = buf.index(FEND)
except ValueError:
buf.clear()
break
try:
end = buf.index(FEND, start + 1)
except ValueError:
if start > 0:
del buf[:start]
break
raw = bytes(buf[start + 1:end])
del buf[:end + 1]
if not raw:
continue
cmd = raw[0]
data = kiss_unescape(raw[1:])
kiss_cmd = cmd & 0x0F
if kiss_cmd != 0x00:
continue
tnc2 = parse_ax25_ui(data)
if tnc2:
await broadcast(tnc2)
except Exception as e:
await broadcast(f"[web] Direwolf connection error: {e}. Retrying in 2s...")
await asyncio.sleep(2)
finally:
try:
s.close()
except Exception:
pass
@app.get("/")
def index():
return HTMLResponse(
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>NoxRadio - Direwolf Monitor</title>
<style>
body { font-family: sans-serif; margin: 16px; }
#log { white-space: pre-wrap; border: 1px solid #ccc; padding: 12px; height: 70vh; overflow: auto; }
.row { display: flex; gap: 8px; margin-bottom: 10px; }
button { padding: 8px 10px; }
</style>
</head>
<body>
<h2>NoxRadio - Direwolf Live Monitor</h2>
<div class="row">
<button onclick="clearLog()">Clear</button>
</div>
<div id="log"></div>
<script>
const log = document.getElementById("log");
function add(line) {
log.textContent += line + "\\n";
log.scrollTop = log.scrollHeight;
}
function clearLog() { log.textContent = ""; }
const ws = new WebSocket(`ws://${location.host}/ws`);
ws.onmessage = (ev) => add(ev.data);
ws.onopen = () => add("[web] WebSocket connected");
ws.onclose = () => add("[web] WebSocket closed");
ws.onerror = () => add("[web] WebSocket error");
</script>
</body>
</html>
"""
)
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
global _reader_task
await ws.accept()
clients.add(ws)
if _reader_task is None or _reader_task.done():
_reader_task = asyncio.create_task(direwolf_reader())
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
clients.discard(ws)
EOF
log "Wrote web app: ${WEB_DIR}/app.py"
}
setup_web_venv() {
local primary_user="$1"
python3 -m venv "${WEB_DIR}/.venv"
"${WEB_DIR}/.venv/bin/pip" install --upgrade pip
"${WEB_DIR}/.venv/bin/pip" install fastapi "uvicorn[standard]"
chown -R "${primary_user}:${primary_user}" "${WEB_DIR}"
log "Web venv ready: ${WEB_DIR}/.venv"
}
write_web_service() {
local svc="/etc/systemd/system/noxradio-web.service"
local primary_user
primary_user="$(detect_primary_user)"
cat > "${svc}" <<EOF
[Unit]
Description=NoxRadio Web (Direwolf Test UI)
After=network.target direwolf.service
Wants=direwolf.service
[Service]
Type=simple
User=${primary_user}
WorkingDirectory=${WEB_DIR}
Environment=PATH=${WEB_DIR}/.venv/bin:/usr/bin:/bin
ExecStart=${WEB_DIR}/.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${WEB_PORT}
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now noxradio-web
log "Enabled + started noxradio-web.service"
}
# --- verification ---
verify_services() {
log "Service status:"
systemctl --no-pager --full status direwolf || true
systemctl --no-pager --full status noxradio-web || true
systemctl --no-pager --full status noxradio-ax25-bridge || true
log "Listening ports:"
ss -ltnp | grep -E ":(${KISS_PORT}|${WEB_PORT})" || true
log "AX.25 interface (if created):"
ip link show ax0 2>/dev/null || true
}
main() {
[[ "${EUID}" -eq 0 ]] || die "Run as root: sudo $0"
need_cmd apt-get
need_cmd git
need_cmd udevadm
local primary_user
primary_user="$(detect_primary_user)"
log "Primary user: ${primary_user}"
log "Updating OS packages..."
apt-get update -y
apt-get full-upgrade -y
apt_install git build-essential cmake pkg-config \
libasound2-dev libudev-dev alsa-utils socat \
python3 python3-venv python3-pip \
ax25-tools ax25-apps libax25 || true
if ! command -v kissattach >/dev/null 2>&1; then
warn "kissattach not found after install. AX.25 bridge may not work until ax25-tools/ax25-apps packages are available on your OS."
fi
need_cmd arecord
need_cmd aplay
log "Detecting AIOC devices (audio + PTT)..."
local audio_card hid_node tty_node
audio_card="$(detect_audio_capture_card)"
hid_node="$(detect_ptt_hidraw_device)"
tty_node="$(detect_serial_tty_device)"
[[ -n "${audio_card}" ]] && log "Detected USB capture card index: ${audio_card}" || warn "No USB capture device detected yet. Plug in the AIOC and re-run this script."
[[ -n "${hid_node}" ]] && log "Detected hidraw device for PTT: ${hid_node}" || warn "No /dev/hidraw* detected (CM108 PTT). Will try serial PTT if available."
[[ -n "${tty_node}" ]] && log "Detected serial device: ${tty_node}" || warn "No /dev/ttyACM0 or /dev/ttyUSB0 detected."
ensure_direwolf_user
usermod -aG audio,dialout,input "${primary_user}" || true
if [[ -n "${hid_node}" || -n "${tty_node}" ]]; then
write_udev_rules "${hid_node}" "${tty_node}"
else
warn "Skipping udev rules (no hidraw/tty detected)."
fi
install_direwolf
# Prompt user for callsign (validate and re-prompt if bad)
local mycall
while true; do
mycall="$(prompt_callsign)"
if validate_callsign "${mycall}"; then
break
fi
warn "Callsign must be at least 4 characters. Try again."
done
log "Using MYCALL: ${mycall}"
write_direwolf_config "${audio_card}" "${hid_node}" "${tty_node}" "${mycall}"
write_direwolf_service
write_modules_load
write_axports "${mycall}"
if command -v kissattach >/dev/null 2>&1; then
write_ax25_bridge_script
write_ax25_bridge_service
else
warn "Skipping AX.25 bridge service (kissattach missing)."
fi
write_web_app
setup_web_venv "${primary_user}"
write_web_service
local ip
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
echo ""
echo "============================================================"
echo " Install complete"
echo "============================================================"
echo "Direwolf config: ${DW_CONF}"
echo "Web UI: http://${ip:-<pi-ip>}:${WEB_PORT}"
echo "KISS TCP: 127.0.0.1:${KISS_PORT}"
echo ""
echo "Logs:"
echo " sudo journalctl -u direwolf -n 120 --no-pager"
echo " sudo journalctl -u noxradio-web -n 120 --no-pager"
echo " sudo journalctl -u noxradio-ax25-bridge -n 120 --no-pager"
echo ""
verify_services
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment