#!/usr/bin/env bash # # Provisioning for a ClockworkPi uConsole (CM4) on Rex's Debian Bookworm image # with the HackerGadgets AIO v2 board. See README.md in this directory for # what it installs, key bindings, and post-install steps. # # curl -fsSL pid1.space/rx | sudo bash # # pid1.space/rx redirects to rx.pid1.space, which Cloudflare deploys straight # from this repository on every push. If it is unreachable, the raw URL still # works: # # curl -fsSL https://raw.githubusercontent.com/pid1/rx/main/setup.sh | sudo bash # # Flags must go through `bash -s --`; `| sudo bash --dry-run` hands the flag to # bash instead of to this script: # # ... | sudo bash -s -- --dry-run # ... | sudo bash -s -- --only sdr --skip lora # ... | sudo TS_AUTHKEY=tskey-auth-... bash -s -- --only tailscale # # Two constraints shape this file: # # The body is wrapped in main() so a truncated download -- the pipe dropping # mid-transfer -- dies on an incomplete function definition instead of # executing half a script that rewrites config.txt. # # stdin is the script itself, so nothing here may read from it. apt is fully # non-interactive; the escape hatch for a real prompt is `< /dev/tty`. # # Every step is idempotent: re-running is the intended way to use this as # config management. A run that changes nothing writes nothing. main() { set -euo pipefail # ------------------------------------------------------------------ config local GH_KEY_USER=pid1 local DEFAULT_USER=jroemer local TIMEZONE=America/Chicago # US/Central; America/Chicago is the canonical name # Meshtastic will not transmit until a region is set. Wrong region is a # regulatory problem, so this is explicit rather than guessed. local LORA_REGION=US # Node identity as it appears to everyone else on the mesh. Short name is # capped at 4 characters. local NODE_NAME=KA1PID local NODE_SHORT=PID1 # This station, for rxd (/etc/rxd/station.json): the callsign and grid WSPR # spots are reported as, and the RTL-SDR's serial (a Nooelec NESDR SMArt v5, # behind the Ham It Up). local STATION_CALL=KA1PID local STATION_GRID=EM13lc local SDR_SERIAL=57818712 # Offline map style for meshtastic-ui; bundles live in meshtastic/device-ui. # Alternatives: positron, atlas, dark-matter-brown. local MAP_STYLE=osm local AIOV2_REPO=https://github.com/hackergadgets/aiov2_ctl.git local AIOV2_DIR=/opt/aiov2_ctl local MESHTASTIC_REPO=http://download.opensuse.org/repositories/network:/Meshtastic:/beta/Raspbian_12/ # Rex (ak-rex) maintains the ClockworkPi apt repo that his Bookworm image # ships with. It is served straight out of a GitHub repo, and carries the # things Debian does not have: the hackergadgets AIO metapackage, pinctrl, # and the rtlsdrblog fork of rtl-sdr. # Anthropic's release signing key, per code.claude.com/docs/en/setup. local CLAUDE_KEY_URL=https://downloads.claude.ai/keys/claude-code.asc local CLAUDE_KEY=/etc/apt/keyrings/claude-code.asc local CLAUDE_KEY_FP=31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE local CLAUDE_LIST=/etc/apt/sources.list.d/claude-code.list # AIO v2 puts each subsystem behind a GPIO-switched power rail. Nothing on # the board responds until these are driven high. (V1 had no such gating.) local RAIL_GPS=27 RAIL_LORA=16 RAIL_SDR=7 RAIL_USB=23 # Rails brought up at boot. SDR needs USB as well: the RTL-SDR is an # internal USB device behind the AIO hub, so powering the SDR rail alone # will not make it enumerate. GPS is up because chrony disciplines the clock # from it, and LoRa because meshtasticd runs at boot. local BOOT_RAILS=(SDR USB GPS LORA) local BEGIN_MARK='# >>> uconsole-setup >>>' local END_MARK='# <<< uconsole-setup <<<' local ALL_SECTIONS=(base firewall desktop claude aio rtc gps lora sdr ham tailscale) # Hardware groups. The account created by the image's first-boot wizard is # not in these, and no AIO peripheral works non-root without them. local HW_GROUPS=(dialout spi i2c gpio plugdev audio video netdev) # Kernel and boot firmware are held. The display, panel rotation and the AIO # overlays all depend on kernel patches matched to this image, and the apt # suite configured here carries newer kernels than the image ships -- so an # unattended `maint` run could otherwise replace the kernel and leave a # portable device with no display, away from a desk. Upgrade these # deliberately: unhold, upgrade, reboot, verify, re-hold. local HOLD_PACKAGES=(akrex-kernel clockworkpi-kernel clockworkpi-cm-firmware raspberrypi-kernel raspberrypi-bootloader) # --------------------------------------------------------------- arg parse local DRY_RUN=false local DESKTOP_USER="" local -a ONLY=() SKIP=() while (( $# )); do case $1 in --dry-run) DRY_RUN=true ;; --user) DESKTOP_USER=${2:?--user needs a value}; shift ;; --user=*) DESKTOP_USER=${1#*=} ;; --only) ONLY+=("${2:?--only needs a section}"); shift ;; --only=*) ONLY+=("${1#*=}") ;; --skip) SKIP+=("${2:?--skip needs a section}"); shift ;; --skip=*) SKIP+=("${1#*=}") ;; -h|--help) printf 'usage: setup.sh [--dry-run] [--user NAME] [--only SECTION]... [--skip SECTION]...\n' printf 'sections: %s\n' "${ALL_SECTIONS[*]}" return 0 ;; *) printf 'unknown argument: %s\n' "$1" >&2; return 2 ;; esac shift done # ---------------------------------------------------------------- plumbing local -a NOTES=() log() { printf ' %s\n' "$*"; } warn() { printf ' !! %s\n' "$*" >&2; } die() { printf '\nFATAL: %s\n' "$*" >&2; exit 1; } section() { printf '\n== %s\n' "$*"; } note() { NOTES+=("$*"); } run() { if $DRY_RUN; then printf ' [dry-run] %s\n' "$*"; return 0; fi "$@" } # write_file [mode]; content on stdin. Writes via a same-directory # temp file so a failure part-way never leaves a truncated target. write_file() { local path=$1 mode=${2:-0644} content tmp content=$(cat) if $DRY_RUN; then printf ' [dry-run] write %s (mode %s):\n' "$path" "$mode" printf '%s\n' "$content" | sed 's/^/ | /' return 0 fi mkdir -p "$(dirname "$path")" tmp=$(mktemp "${path}.XXXXXX") printf '%s\n' "$content" > "$tmp" chmod "$mode" "$tmp" mv -f "$tmp" "$path" } pkg_installed() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q 'ok installed' } pkg_available() { local cand cand=$(apt-cache policy "$1" 2>/dev/null | awk -F': ' '/Candidate:/{print $2}') [[ -n $cand && $cand != '(none)' ]] } apt_install() { run apt-get install -y "$@"; } # For optional extras: warn rather than abort. A missing optional package # should not take down a whole provisioning run, and under set -e a plain # apt_install would. apt_install_opt() { run apt-get install -y "$@" || warn "optional install failed: $*" return 0 } # apt refuses to update a repo whose Release metadata changed, until the # change is confirmed. Two benign ones show up here: Debian bumping Version # across point releases (12.13 -> 12.15), and Rex relabelling his repo. Both # are accepted narrowly by field. # # Origin, Codename and Suite are deliberately NOT accepted: those indicate # the repository is claiming to be something other than what it was, which is # the case apt-secure(8) actually exists to stop. Confirm those by hand. # # Non-fatal: one third-party repo failing should not abort provisioning, and # the error stays visible above the warning. # Reports apt's real exit status, so callers that need to roll back can. apt_update() { run apt-get update -y \ --allow-releaseinfo-change-version \ --allow-releaseinfo-change-label } # Never fatal: one third-party repo failing should not abort provisioning, # and apt's own error stays visible above the warning. apt_update_soft() { apt_update || warn "apt-get update reported errors (see above); continuing anyway" return 0 } # Replace our marker-delimited block in a file, or append it if absent. apply_block() { local file=$1 content stripped content=$(cat) stripped=$(sed "\|^${BEGIN_MARK}\$|,\|^${END_MARK}\$|d" "$file") printf '%s\n\n%s\n%s\n%s\n' \ "$stripped" "$BEGIN_MARK" "$content" "$END_MARK" | write_file "$file" 0644 } # Rex's image ships his apt repo already configured (it appears as # ClockworkPi-apt), and that is where the HackerGadgets AIO metapackage, # pinctrl and the rtlsdrblog rtl-sdr build come from. This # script targets that image, so it verifies the repo is there rather than # carrying the key-fetch and sources.list plumbing to install a second copy # of the same packages under a different name. local AKREX_CHECKED=false require_akrex_repo() { $AKREX_CHECKED && return 0 AKREX_CHECKED=true pkg_available hackergadgets-uconsole-aio-board || { warn "Rex's ClockworkPi apt repo does not look configured: hackergadgets-uconsole-aio-board does not resolve." warn "This script targets Rex's image, which ships it. On another image, add it from" warn " https://github.com/ak-rex/akrex-arm-repo then re-run." } return 0 } want() { local s=$1 x if (( ${#ONLY[@]} )); then for x in "${ONLY[@]}"; do [[ $x == "$s" ]] && return 0; done return 1 fi for x in "${SKIP[@]}"; do [[ $x == "$s" ]] && return 1; done return 0 } # --------------------------------------------------------------- preflight section "Preflight" (( EUID == 0 )) || die "must run as root (pipe to 'sudo bash', not 'bash')" local s known for s in "${ONLY[@]}" "${SKIP[@]}"; do known=false for x in "${ALL_SECTIONS[@]}"; do [[ $x == "$s" ]] && known=true; done $known || die "unknown section '$s' (valid: ${ALL_SECTIONS[*]})" done $DRY_RUN && log "DRY RUN — no changes will be made" # Bookworm moved the firmware partition; older images still use /boot. local BOOT_DIR if [[ -f /boot/firmware/config.txt ]]; then BOOT_DIR=/boot/firmware elif [[ -f /boot/config.txt ]]; then BOOT_DIR=/boot else die "cannot find config.txt in /boot/firmware or /boot — is this a Pi image?" fi log "boot dir: $BOOT_DIR" local OS_ID=debian OS_CODENAME=bookworm if [[ -r /etc/os-release ]]; then OS_ID=$(awk -F= '/^ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release) OS_CODENAME=$(awk -F= '/^VERSION_CODENAME=/{gsub(/"/,"",$2); print $2}' /etc/os-release) fi log "os: ${OS_ID} ${OS_CODENAME}" [[ $OS_CODENAME == bookworm ]] || warn "expected bookworm, found '${OS_CODENAME}' — continuing anyway" local MODEL="unknown" [[ -r /proc/device-tree/model ]] && MODEL=$(tr -d '\0' < /proc/device-tree/model) log "model: $MODEL" case $MODEL in *"Compute Module 4"*) ;; *) warn "this script targets CM4; overlays and device paths may be wrong on '$MODEL'" ;; esac # Resolve the desktop account. Never create it — the password policy and # sudo membership are decisions this script should not be making silently. [[ -n $DESKTOP_USER ]] || DESKTOP_USER=$DEFAULT_USER # Rex's Lite image runs a first-boot wizard that creates the user account, so # by the time this runs it should already exist. if ! id -u "$DESKTOP_USER" >/dev/null 2>&1; then if [[ -n ${SUDO_USER:-} ]] && id -u "$SUDO_USER" >/dev/null 2>&1; then warn "user '$DESKTOP_USER' does not exist; falling back to '\$SUDO_USER' ($SUDO_USER)" DESKTOP_USER=$SUDO_USER else die "user '$DESKTOP_USER' does not exist. Create it first: adduser $DESKTOP_USER && adduser $DESKTOP_USER sudo then re-run, or pass --user NAME for a different account." fi fi local USER_HOME USER_GROUP USER_HOME=$(getent passwd "$DESKTOP_USER" | cut -d: -f6) USER_GROUP=$(id -gn "$DESKTOP_USER") [[ -d $USER_HOME ]] || die "home directory '$USER_HOME' for '$DESKTOP_USER' does not exist" log "desktop user: $DESKTOP_USER ($USER_HOME)" # Back up the boot files once per run, before anything touches them. local STAMP; STAMP=$(date +%Y%m%d-%H%M%S) # Back up once, not per run: this script is meant to be re-runnable as # config management, and /boot/firmware is a small FAT partition that would # slowly fill with timestamped copies. The first backup is the pristine # pre-script state, which is the one worth keeping. local f for f in "$BOOT_DIR/config.txt" "$BOOT_DIR/cmdline.txt"; do [[ -f $f ]] || continue if compgen -G "${f}.bak-*" >/dev/null; then log "$(basename "$f"): pre-existing backup kept ($(basename "$(ls -1 "${f}".bak-* | head -1)"))" else run cp -a "$f" "${f}.bak-${STAMP}" log "$(basename "$f") backed up as .bak-${STAMP}" fi done export DEBIAN_FRONTEND=noninteractive apt_update_soft # -------------------------------------------------------------------- base if want base; then section "Base system" # US/Central. Also matters beyond the clock display: rxd's slot decoders # and the RTC both care, and gpsd/chrony will be feeding real time in shortly. if command -v timedatectl >/dev/null 2>&1; then local current_tz current_tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo "") if [[ $current_tz == "$TIMEZONE" ]]; then log "timezone already $TIMEZONE" else run timedatectl set-timezone "$TIMEZONE" log "timezone set to $TIMEZONE (was ${current_tz:-unknown})" fi else warn "timedatectl not found — timezone unchanged" fi run touch /root/.hushlogin run install -o "$DESKTOP_USER" -g "$USER_GROUP" -m 0644 /dev/null "$USER_HOME/.hushlogin" log "hushlogin set for root and $DESKTOP_USER" # -A generates one host key per supported type, skipping any that exist. run ssh-keygen -A log "ssh host keys present" # pi-gen's stage2 contains both `systemctl enable ssh` and # `systemctl disable ssh` behind a build-time condition, so whether the # image arrives with a listener is not something to assume either way. apt_install openssh-server # A masked unit cannot be enabled, and `systemctl enable` on one fails -- # which under set -e would take the whole base section down. Some images # ship ssh masked rather than merely disabled. run systemctl unmask ssh.service 2>/dev/null || true run systemctl unmask ssh.socket 2>/dev/null || true # Bookworm uses ssh.service; socket activation (ssh.socket) exists but is # not the default. If the socket is enabled, the service is held inactive # and starting it directly conflicts -- so defer to whichever is in play. if systemctl is-enabled ssh.socket >/dev/null 2>&1; then run systemctl restart ssh.socket || warn "ssh.socket would not start" log "sshd via socket activation (ssh.socket)" else run systemctl enable ssh || warn "could not enable ssh" run systemctl restart ssh || warn "could not start ssh" log "sshd enabled (ssh.service)" fi # Say plainly whether anything is actually listening, rather than leaving # a 'connection refused' to be discovered from another machine. if ! $DRY_RUN; then if ss -tln 2>/dev/null | grep -qE ':22[[:space:]]'; then log "sshd listening on 22" else warn "nothing is listening on port 22 — check: systemctl status ssh; journalctl -u ssh -n 30" note "SSH is not listening. 'systemctl status ssh' and 'journalctl -u ssh -n 30' will say why; a missing host key or a masked unit are the usual causes." fi fi apt_install curl ca-certificates gnupg git # GitHub-backed authorized_keys sync. Fetches to a temp file, requires the # result to be non-empty and to parse as SSH public keys, and only then # swaps it in atomically: a failed fetch must never be able to empty # authorized_keys on a device whose only other input is its own keyboard. write_file /usr/local/sbin/sync-github-keys 0755 <<'SYNCKEYS' #!/bin/sh # Managed by rx/setup.sh — refresh authorized_keys from GitHub. set -eu GH_USER="__GH_USER__" URL="https://github.com/${GH_USER}.keys" sync_for() { _user=$1 _home=$2 [ -d "$_home" ] || return 0 _tmp=$(mktemp "${_home}/.ssh-keys.XXXXXX" 2>/dev/null) || return 0 if ! curl -fsSL --max-time 20 "$URL" -o "$_tmp"; then rm -f "$_tmp" return 0 fi # Empty response, or an HTML error page, must never reach authorized_keys. if [ ! -s "$_tmp" ] || ! ssh-keygen -l -f "$_tmp" >/dev/null 2>&1; then rm -f "$_tmp" return 0 fi # Only rewrite when the content actually differs — this runs every 5 # minutes against an SD card, and an unchanged key set is the normal case. if [ -f "${_home}/.ssh/authorized_keys" ] \ && cmp -s "$_tmp" "${_home}/.ssh/authorized_keys"; then rm -f "$_tmp" return 0 fi mkdir -p "${_home}/.ssh" chmod 700 "${_home}/.ssh" chown "$_user" "${_home}/.ssh" chmod 600 "$_tmp" chown "$_user" "$_tmp" mv -f "$_tmp" "${_home}/.ssh/authorized_keys" } sync_for root /root sync_for "__DESKTOP_USER__" "__USER_HOME__" SYNCKEYS if ! $DRY_RUN; then sed -i \ -e "s|__GH_USER__|${GH_KEY_USER}|" \ -e "s|__DESKTOP_USER__|${DESKTOP_USER}|" \ -e "s|__USER_HOME__|${USER_HOME}|" \ /usr/local/sbin/sync-github-keys fi write_file /etc/cron.d/keys 0644 <<'CRON' # Managed by rx/setup.sh SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin */5 * * * * root /usr/local/sbin/sync-github-keys CRON # Seed immediately so the first login does not wait on cron. run /usr/local/sbin/sync-github-keys || warn "initial key sync failed (network?); cron will retry" log "github key sync installed (${GH_KEY_USER}, every 5 min)" # Key-only SSH. Guarded on a usable key actually being present: turning # password auth off against an empty authorized_keys would leave the # physical console as the only way in. local ak="$USER_HOME/.ssh/authorized_keys" if [[ -s $ak ]] && ssh-keygen -l -f "$ak" >/dev/null 2>&1; then # KbdInteractiveAuthentication too: with it left on, PAM can still put # up a password prompt even when PasswordAuthentication is no. if grep -qs '^[[:space:]]*Include[[:space:]]\+/etc/ssh/sshd_config.d/\*.conf' /etc/ssh/sshd_config; then write_file /etc/ssh/sshd_config.d/10-no-password.conf 0644 <<'SSHDNOPW' # Managed by rx/setup.sh PasswordAuthentication no KbdInteractiveAuthentication no SSHDNOPW else printf '%s\n' \ 'PasswordAuthentication no' \ 'KbdInteractiveAuthentication no' \ | apply_block /etc/ssh/sshd_config fi # Validate before reloading: a config sshd rejects would otherwise take # the listener down on restart. if $DRY_RUN; then log "[dry-run] would disable SSH password authentication" elif sshd -t 2>/dev/null; then systemctl reload ssh 2>/dev/null || systemctl restart ssh 2>/dev/null || true log "SSH password authentication disabled (keys only)" else warn "sshd rejected the config; password auth left enabled:" sshd -t 2>&1 | sed 's/^/ /' >&2 fi else warn "not disabling SSH password auth: no usable key in $ak" note "SSH password auth is still enabled because $ak held no usable key. Once 'ssh-keygen -l -f $ak' succeeds, re-run --only base." fi # One command for routine upkeep: package updates plus a re-apply of this # script, which is idempotent and so only changes what has drifted or is # newly added. /usr/local/bin rather than sbin -- Debian keeps sbin off a # normal user's PATH, so `sudo maint` would not resolve. write_file /usr/local/bin/maint 0755 <<'MAINT' #!/bin/bash # Managed by rx/setup.sh — daily maintenance. # # sudo maint update packages, then re-apply configuration # sudo maint --dry-run show what the config step would change # # Arguments pass through to setup.sh, so --only/--skip work here too. set -euo pipefail # pid1.space/rx first. It is no longer a mirror that can silently freeze: # Cloudflare deploys it from the repository on push, and a push Cloudflare # misses is caught by .github/workflows/cf-fallback.yml, which redeploys when # the live /.build-id does not match the commit. The raw URL stays as the # fallback for when Cloudflare is unreachable. PRIMARY=https://pid1.space/rx FALLBACK=https://raw.githubusercontent.com/pid1/rx/main/setup.sh if [ "$(id -u)" -ne 0 ]; then echo "maint: needs root — run: sudo maint $*" >&2 exit 1 fi export DEBIAN_FRONTEND=noninteractive echo "== packages" # Benign Release metadata changes (Debian point releases, Rex relabelling his # repo) would otherwise abort the update. Origin/Codename/Suite still stop it. apt-get update -y \ --allow-releaseinfo-change-version \ --allow-releaseinfo-change-label \ || echo "maint: apt update reported errors, continuing" >&2 apt-get full-upgrade -y apt-get autoremove --purge -y echo echo "== held packages" # Held packages are skipped by the upgrade above by design, so say when one # has an update waiting rather than letting it sit unnoticed for months. held=$(apt-mark showhold 2>/dev/null || true) if [ -z "$held" ]; then echo " none" else for p in $held; do inst=$(apt-cache policy "$p" 2>/dev/null | awk '/Installed:/{print $2}') cand=$(apt-cache policy "$p" 2>/dev/null | awk '/Candidate:/{print $2}') if [ "$inst" != "$cand" ]; then echo " $p: held at $inst, $cand available" echo " upgrade deliberately: sudo apt-mark unhold $p && sudo apt install $p" echo " then reboot and confirm the display comes up before re-holding" else echo " $p: $inst (current)" fi done fi echo echo "== configuration" tmp=$(mktemp) src="" trap 'rm -f "$tmp"' EXIT # The last setup.sh that passed the checks below, for when neither URL # answers: with it, and the rxd release setup.sh caches beside it, a run # without network re-applies the same configuration. CACHE=/var/cache/rx/setup.sh if curl -fsSL --max-time 30 "$PRIMARY" -o "$tmp"; then src=$PRIMARY elif curl -fsSL --max-time 30 "$FALLBACK" -o "$tmp"; then src=$FALLBACK echo "maint: pid1.space unreachable, fetched from GitHub instead" >&2 elif [ -s "$CACHE" ]; then cp "$CACHE" "$tmp" src="$CACHE (cached; no network)" echo "maint: could not fetch setup.sh; re-applying the cached copy" >&2 fi if [ -s "$tmp" ]; then # Say what is being applied, so a wrong or stale copy is visible rather # than inferred from behaviour. echo " source: $src" echo " sha256: $(sha256sum "$tmp" | cut -c1-16) ($(wc -l < "$tmp") lines)" # Downloaded to a file rather than piped, so a truncated transfer is caught # here instead of part-executing. Same checks the publish workflow applies: # the body lives inside main(), so a file missing its trailing invocation # parses fine and then silently does nothing. if bash -n "$tmp" \ && head -1 "$tmp" | grep -qx '#!/usr/bin/env bash' \ && tail -1 "$tmp" | grep -qx 'main "\$@"'; then if [ "$src" = "$PRIMARY" ] || [ "$src" = "$FALLBACK" ]; then mkdir -p /var/cache/rx && install -m 0644 "$tmp" "$CACHE.part" && mv -f "$CACHE.part" "$CACHE" fi bash "$tmp" "$@" else echo "maint: downloaded setup.sh failed verification; config step skipped" >&2 exit 1 fi else echo "maint: could not fetch setup.sh from either URL; config step skipped" >&2 exit 1 fi if [ -f /var/run/reboot-required ]; then echo echo "== reboot required" sed 's/^/ /' /var/run/reboot-required.pkgs 2>/dev/null || true fi MAINT log "installed the 'maint' maintenance command" local hp for hp in "${HOLD_PACKAGES[@]}"; do pkg_installed "$hp" || continue if apt-mark showhold 2>/dev/null | grep -qx "$hp"; then log "already held: $hp" else run apt-mark hold "$hp" || warn "could not hold $hp" fi done # Hardware group membership — required for the AIO peripherals to be # usable without sudo. Only add groups that actually exist on this image. local g present=() for g in "${HW_GROUPS[@]}"; do getent group "$g" >/dev/null 2>&1 && present+=("$g") done if (( ${#present[@]} )); then local joined; joined=$(IFS=,; printf '%s' "${present[*]}") run usermod -aG "$joined" "$DESKTOP_USER" log "added $DESKTOP_USER to: ${present[*]}" note "Group changes need a full logout/login (or reboot) before they apply." note "Routine upkeep is one command: 'sudo maint' — apt update + full-upgrade + autoremove, then re-applies this script (idempotent, so only drift changes). 'sudo maint --dry-run' previews the config half." note "SSH is listening on 22, key-only (password auth disabled), keys synced from github.com/${GH_KEY_USER}.keys every 5 minutes." fi fi # ---------------------------------------------------------------- firewall # Default-deny inbound. Nothing on this device needs to accept unsolicited # traffic except SSH, and rxd's HTTP port from the LAN, and some of what it # runs would otherwise be wide open -- meshtasticd serves an unauthenticated # API (4403) and web client (443) on every interface, as packaged. # # Its own table and its own unit rather than /etc/nftables.conf: Debian's # file starts with `flush ruleset`, which would wipe the rules Tailscale # installs through iptables-nft. A separate table's drop still applies to # everything, since a packet must pass every base chain on its hook. if want firewall; then section "Firewall" apt_install nftables write_file /etc/rx/firewall.nft 0644 <<'NFT' #!/usr/sbin/nft -f # Managed by rx/setup.sh — inbound: SSH, rxd's HTTP from the LAN, and # replies. Loaded by rx-firewall.service. Replaces only its own table, never # the ruleset. table inet rx_firewall delete table inet rx_firewall table inet rx_firewall { # The LAN: private IPv4, IPv6 unique-local and link-local. The tailnet is # matched by interface instead. set lan4 { type ipv4_addr; flags interval; elements = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } } set lan6 { type ipv6_addr; flags interval; elements = { fc00::/7, fe80::/10 } } chain input { type filter hook input priority filter; policy drop; iif "lo" accept ct state established,related accept ct state invalid drop # IPv6 does not work without these: neighbour discovery, router # adverts (link-local, hop limit 255) and DHCPv6 replies, which go # to a multicast solicit that conntrack cannot match. icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept ip6 saddr fe80::/10 ip6 hoplimit 255 icmpv6 type nd-router-advert accept ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept # Key-only (sshd_config.d/10-no-password.conf), on every interface. tcp dport 22 accept # rxd's HTTP API and browser UI, from the LAN and the tailnet only. # Unauthenticated: anyone allowed here can tune the radio. rxd # listens on it only when /etc/rxd/rxd.env says so. ip saddr @lan4 tcp dport 8073 accept ip6 saddr @lan6 tcp dport 8073 accept iifname "tailscale0" tcp dport 8073 accept } # Not a router. chain forward { type filter hook forward priority filter; policy drop; } } NFT write_file /etc/systemd/system/rx-firewall.service 0644 <<'FWUNIT' [Unit] Description=rx inbound firewall (SSH, rxd from the LAN, and replies) DefaultDependencies=no Wants=network-pre.target Before=network-pre.target shutdown.target Conflicts=shutdown.target [Service] Type=oneshot RemainAfterExit=yes ExecStartPre=/usr/sbin/nft -c -f /etc/rx/firewall.nft ExecStart=/usr/sbin/nft -f /etc/rx/firewall.nft ExecReload=/usr/sbin/nft -f /etc/rx/firewall.nft ExecStop=-/usr/sbin/nft delete table inet rx_firewall [Install] WantedBy=sysinit.target FWUNIT run systemctl daemon-reload run systemctl enable rx-firewall.service # Check before loading: a ruleset that fails to parse loads nothing, but # this says so here rather than leaving the device open without a word. # Loading keeps established connections, an SSH session included. if $DRY_RUN; then log "[dry-run] nft -c -f /etc/rx/firewall.nft, then load it" elif nft -c -f /etc/rx/firewall.nft; then run systemctl restart rx-firewall.service log "firewall loaded: inbound SSH, rxd HTTP from the LAN, and replies" else warn "firewall rules failed to parse; NOT loaded — the device is unfiltered" fi note "Firewall: nothing reaches this device unsolicited except SSH on 22, and rxd's HTTP on 8073 from private addresses and Tailscale. Other machines can no longer resolve clockworkpi.local (mDNS answers are inbound); use its IP or Tailscale name. Rules: /etc/rx/firewall.nft; 'sudo nft list table inet rx_firewall'." fi # ----------------------------------------------------------------- desktop if want desktop; then section "Desktop (i3 + X11)" apt_install xserver-xorg xinit x11-xserver-utils \ i3 i3status dmenu alacritty brightnessctl \ fonts-dejavu-core fontconfig # A Lite image has no audio userland at all, and rxd needs one. clockworkpi-audio carries the device-specific config. apt_install pipewire pipewire-pulse wireplumber || warn "audio stack install failed" if pkg_available clockworkpi-audio; then apt_install clockworkpi-audio || warn "clockworkpi-audio failed" fi # The complete config, written here rather than patched onto Debian's, so # every binding in it is one this script set. Writing it also avoids i3's # interactive first-run config wizard, which would block a headless boot. # # No binding uses a bare Return anywhere, including to leave resize mode. # Enter is always just Enter. # # This file is managed: local edits are overwritten on the next run. write_file "$USER_HOME/.config/i3/config" 0644 <<'I3CONF' # Managed by rx/setup.sh -- edits here are overwritten on the next run. # $mod is Alt (Mod1). The uConsole's CMD key needs Fn+CMD, a two-key chord for # every window operation. # # i3 grabs $mod combinations globally, so any plain Alt+ bound here is # unavailable to the shell, where readline uses Meta for word motion. Window # management therefore lives on Alt+Shift, and i3 claims only these: # # taken by i3: Alt+h/j/k/l (focus), Alt+1..9 (workspaces), # Alt+r, Alt+Return # left to bash: Alt+b Alt+f Alt+d Alt+t Alt+u Alt+p Alt+y Alt+. # Alt+BackSpace (word motion, kill-word, yank-arg) # # The cost is readline's M-l, M-r and M-. set $mod Mod1 # 12pt is a starting point for the 5" 720p panel (~290 DPI); raise if small. font pango:Atkinson Hyperlegible Mono 12 # --- launching -------------------------------------------------------------- bindsym $mod+Return exec alacritty bindsym $mod+Shift+Return exec alacritty bindsym $mod+Shift+d exec dmenu_run bindsym $mod+Shift+q kill # --- focus ------------------------------------------------------------------ bindsym $mod+h focus left bindsym $mod+j focus down bindsym $mod+k focus up bindsym $mod+l focus right bindsym $mod+Left focus left bindsym $mod+Down focus down bindsym $mod+Up focus up bindsym $mod+Right focus right # --- moving ----------------------------------------------------------------- bindsym $mod+Shift+h move left bindsym $mod+Shift+j move down bindsym $mod+Shift+k move up bindsym $mod+Shift+l move right bindsym $mod+Shift+Left move left bindsym $mod+Shift+Down move down bindsym $mod+Shift+Up move up bindsym $mod+Shift+Right move right # --- layout ----------------------------------------------------------------- # The panel is wide and short, so default to side-by-side splits. bindsym $mod+Shift+b split h bindsym $mod+Shift+v split v bindsym $mod+Shift+f fullscreen toggle bindsym $mod+Shift+s layout stacking bindsym $mod+Shift+w layout tabbed bindsym $mod+Shift+e layout toggle split bindsym $mod+Shift+space floating toggle bindsym $mod+Shift+a focus parent # --- workspaces ------------------------------------------------------------- bindsym $mod+1 workspace number 1 bindsym $mod+2 workspace number 2 bindsym $mod+3 workspace number 3 bindsym $mod+4 workspace number 4 bindsym $mod+5 workspace number 5 bindsym $mod+6 workspace number 6 bindsym $mod+7 workspace number 7 bindsym $mod+8 workspace number 8 bindsym $mod+9 workspace number 9 bindsym $mod+Shift+1 move container to workspace number 1 bindsym $mod+Shift+2 move container to workspace number 2 bindsym $mod+Shift+3 move container to workspace number 3 bindsym $mod+Shift+4 move container to workspace number 4 bindsym $mod+Shift+5 move container to workspace number 5 bindsym $mod+Shift+6 move container to workspace number 6 bindsym $mod+Shift+7 move container to workspace number 7 bindsym $mod+Shift+8 move container to workspace number 8 bindsym $mod+Shift+9 move container to workspace number 9 # --- session ---------------------------------------------------------------- bindsym $mod+Shift+c reload bindsym $mod+Shift+r restart bindsym $mod+Shift+x exec "i3-nagbar -t warning -m 'Exit i3?' -B 'Yes' 'i3-msg exit'" # --- hardware keys ---------------------------------------------------------- bindsym XF86MonBrightnessUp exec brightnessctl set +10% bindsym XF86MonBrightnessDown exec brightnessctl set 10%- # Automatic blanking is disabled (the panel does not survive DPMS), so this is # the deliberate way to kill the backlight on battery. Brightness-up restores # it; --save/--restore keeps the previous level. bindsym $mod+Shift+o exec --no-startup-id brightnessctl --save set 0 bindsym $mod+Shift+p exec --no-startup-id brightnessctl --restore bindsym XF86AudioRaiseVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ bindsym XF86AudioLowerVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- bindsym XF86AudioMute exec wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle # --- resize ----------------------------------------------------------------- # Escape only. Debian's default also binds a bare Return here, which is # exactly the kind of stray Enter binding this config avoids. mode "resize" { bindsym h resize shrink width 10 px or 10 ppt bindsym j resize grow height 10 px or 10 ppt bindsym k resize shrink height 10 px or 10 ppt bindsym l resize grow width 10 px or 10 ppt bindsym Escape mode "default" } bindsym $mod+r mode "resize" # --- application workspaces ------------------------------------------------- # meshtastic-ui sets no WM_CLASS at all, so assign has no criteria to match # on. for_window fires once the window is mapped and titled instead. for_window [title="^Meshtastic"] move to workspace number 3 # Only the receiver starts on its own. The Meshtastic dashboard keeps its # placement rule above, so it lands on 3 whenever started -- from dmenu, or # via `radio mesh`, which also brings meshtasticd up. # # rxd is headless, so it has no window to place. `radio resume` brings the # active profile back up as the rxd@PROFILE user service, already playing, # rather than starting rxd by hand. exec --no-startup-id radio resume bar { status_command i3status position top tray_output primary } I3CONF log "wrote a managed i3 config (no bare Return bindings)" # Alacritty config, pulled from this same repo. Its theme import points at # cytracom_light.toml, which is in neither the upstream alacritty-theme # repo nor any local checkout, so the import is commented out and the # built-in default colours are used. Uncomment once the file exists. local aldir="$USER_HOME/.config/alacritty" run mkdir -p "$aldir" if $DRY_RUN; then log "[dry-run] fetch alacritty.toml into $aldir" elif curl -fsSL --max-time 20 \ "https://raw.githubusercontent.com/${GH_KEY_USER}/rx/main/alacritty.toml" \ -o "$aldir/alacritty.toml"; then sed -i 's|^\( *\)\("~/.*themes.*\.toml"\)|\1# \2 # uconsole-setup: no such file|' \ "$aldir/alacritty.toml" log "installed alacritty.toml (theme import disabled, using defaults)" else warn "could not fetch alacritty.toml — using alacritty defaults" fi # Atkinson Hyperlegible Next and Mono. Debian's fonts-atkinson-hyperlegible # is the original family only; Next and Mono are separate newer families # and are not packaged, so take them from the upstream Google Fonts repos. # alacritty.toml asks for "Atkinson Hyperlegible Mono", which is the family # name shipped by the -next-mono repo. local fontdir=/usr/local/share/fonts frepo dest tmpd for frepo in atkinson-hyperlegible-next atkinson-hyperlegible-next-mono; do dest="$fontdir/$frepo" if [[ -d $dest ]]; then log "fonts: $frepo already present" continue fi if $DRY_RUN; then log "[dry-run] install fonts from googlefonts/$frepo" continue fi tmpd=$(mktemp -d) if git clone --depth 1 "https://github.com/googlefonts/$frepo" "$tmpd" >/dev/null 2>&1 \ && compgen -G "$tmpd/fonts/otf/*.otf" >/dev/null; then mkdir -p "$dest" cp "$tmpd"/fonts/otf/*.otf "$dest"/ chmod 0644 "$dest"/*.otf log "fonts: installed $frepo ($(ls "$dest" | wc -l | tr -d ' ') faces)" else warn "fonts: could not fetch googlefonts/$frepo" fi rm -rf "$tmpd" done # Point the generic families at Atkinson: Mono for monospace, Next for # both proportional families. puts these at the head of the # substitution list without removing the existing fallbacks, so anything # they lack a glyph for still resolves. write_file /etc/fonts/local.conf 0644 <<'FONTCONF' monospace Atkinson Hyperlegible Mono sans-serif Atkinson Hyperlegible Next serif Atkinson Hyperlegible Next FONTCONF if ! $DRY_RUN; then fc-cache -f >/dev/null 2>&1 || warn "fc-cache failed" # Confirm the aliases actually resolve; a silent miss here means every # generic-family lookup quietly falls back to DejaVu. local generic resolved for generic in monospace sans-serif serif; do resolved=$(fc-match "$generic" 2>/dev/null | head -1) || resolved="" case $resolved in *Atkinson*) log "font: $generic -> $resolved" ;; *) warn "font: $generic resolved to '$resolved', not Atkinson" ;; esac done fi # i3status: Debian's default shows "ethernet" and "battery all", neither of # which reports anything here -- there is no wired NIC, and the uConsole # battery is not exposed as a standard power_supply device. Replaced with # labelled CPU, RAM and temperature so the numbers are identifiable. write_file "$USER_HOME/.config/i3status/config" 0644 <<'I3STATUS' # Managed by rx/setup.sh general { colors = true interval = 5 } order += "battery 0" order += "cpu_usage" order += "memory" order += "cpu_temperature 0" order += "disk /" order += "wireless _first_" order += "tztime local" # The PMIC exposes the pack as axp20x-battery rather than BAT0, so the path is # given explicitly. It reports ENERGY_NOW/ENERGY_FULL and POWER_NOW, so # %remaining is meaningful while discharging. battery 0 { # No %remaining: it reads 00:00 whenever the pack is charging or full, # which is most of the time on a device that is usually plugged in. # format_percentage drops the two decimal places i3status defaults to. format = "BAT %status %percentage" format_percentage = "%.00f%s" format_down = "BAT n/a" path = "/sys/class/power_supply/axp20x-battery/uevent" # Without this i3status divides ENERGY_NOW (uWh) by CHARGE_FULL_DESIGN # (uAh) -- different units, and the result reads several hundred # percent. last_full_capacity makes it use ENERGY_FULL as denominator. last_full_capacity = true status_chr = "CHR" status_bat = "DIS" status_full = "FULL" low_threshold = 20 threshold_type = percentage } cpu_usage { format = "CPU %usage" } memory { format = "RAM %used / %total" threshold_degraded = "10%" format_degraded = "RAM LOW %available" } cpu_temperature 0 { format = "TEMP %degrees°C" path = "/sys/class/thermal/thermal_zone0/temp" } disk "/" { format = "SD %avail" } wireless _first_ { format_up = "%essid %quality" format_down = "no wifi" } tztime local { format = "%H:%M" } I3STATUS log "wrote i3status config (CPU/RAM/temp/disk/wifi/clock)" # Start X on tty1 only. The panel is mounted rotated, so it comes up # portrait and needs a transform; detect at runtime rather than assume, # because some images already apply it at the DRM level and rotating a # second time leaves the display sideways. # Screen blanking and power management are disabled at every layer, not # just in X. This device is almost always on battery and the screen is # wanted on regardless; the DSI panel also wakes from DPMS to a grey # screen, so blanking is actively harmful here rather than merely unwanted. # # 1. Xorg itself, from server start. This is the durable one: it applies # before any session script runs and survives anything that resets xset. write_file /etc/X11/xorg.conf.d/10-no-blanking.conf 0644 <<'XORGBLANK' # Managed by rx/setup.sh Section "ServerFlags" Option "BlankTime" "0" Option "StandbyTime" "0" Option "SuspendTime" "0" Option "OffTime" "0" EndSection Section "Extensions" Option "DPMS" "Disable" EndSection XORGBLANK # 2. logind, so no idle action fires at the seat level. write_file /etc/systemd/logind.conf.d/10-no-idle.conf 0644 <<'LOGIND' # Managed by rx/setup.sh [Login] IdleAction=ignore IdleActionSec=0 LOGIND # 3. The kernel framebuffer console, which blanks independently of X and # is what you land on if X ever exits. consoleblank is a boot parameter, # so this needs the reboot the overlays already require. if [[ -f $BOOT_DIR/cmdline.txt ]]; then if grep -q 'consoleblank=' "$BOOT_DIR/cmdline.txt"; then $DRY_RUN || sed -i 's/consoleblank=[0-9]*/consoleblank=0/' "$BOOT_DIR/cmdline.txt" else # cmdline.txt must stay exactly one line. $DRY_RUN || sed -i '1s/$/ consoleblank=0/' "$BOOT_DIR/cmdline.txt" fi log "console blanking disabled (consoleblank=0)" fi write_file "$USER_HOME/.xinitrc" 0755 <<'XINITRC' #!/bin/sh # Managed by rx/setup.sh line=$(xrandr | grep -m1 ' connected') out=${line%% *} geom=$(printf '%s\n' "$line" | grep -oE '[0-9]+x[0-9]+\+[0-9]+\+[0-9]+' | head -1) w=${geom%%x*} h=${geom#*x}; h=${h%%+*} case "$w" in ''|*[!0-9]*) w=0 ;; esac case "$h" in ''|*[!0-9]*) h=0 ;; esac if [ -n "$out" ] && [ "$h" -gt "$w" ]; then xrandr --output "$out" --rotate right fi # Belt and braces. Xorg is already configured not to blank (see # /etc/X11/xorg.conf.d/10-no-blanking.conf), but anything in a session can # turn it back on at runtime, so assert it here too. The panel wakes from # DPMS to a solid grey screen that needs a VT switch to clear, and this # device is almost always on battery with the screen wanted on regardless. xset s off xset s noblank xset -dpms exec i3 XINITRC # Sourcing .profile keeps this from shadowing the shell's normal setup, # which bash would otherwise skip once .bash_profile exists. write_file "$USER_HOME/.bash_profile" 0644 <<'BASHPROF' # Managed by rx/setup.sh [ -f "$HOME/.profile" ] && . "$HOME/.profile" # Deliberately not `exec startx`: with exec, a failing X replaces the login # shell, the shell exits, getty respawns, autologin fires again -- an endless # tty1 login loop with no prompt to debug from. This way a failure (or exiting # i3) drops you at a shell on tty1 instead. if [ -z "${DISPLAY:-}" ] && [ "$(tty)" = "/dev/tty1" ]; then startx || echo "startx exited ($?); you are at a shell on tty1." fi BASHPROF write_file /etc/systemd/system/getty@tty1.service.d/autologin.conf 0644 < Atkinson Hyperlegible Mono, serif/sans-serif -> Atkinson Hyperlegible Next, via /etc/fonts/local.conf. i3 uses Mono at 12pt (~/.config/i3/config); raise it if it reads small on the 5\" panel." note "Display: blanking and power management are off at every layer — Xorg (10-no-blanking.conf), logind (IdleAction=ignore), the kernel console (consoleblank=0, needs the reboot) and xset in .xinitrc. Nothing locks or blanks the screen. Mod+Shift+b kills the backlight deliberately, Mod+Shift+n restores it." note "Desktop: screen rotation is detected at X startup, so it is a no-op if the image already rotates the panel. If it lands sideways, edit ~/.xinitrc." note "Wi-Fi on a Lite image: 'sudo raspi-config' (System Options -> Wireless LAN), or nmtui if NetworkManager is in use." fi # ------------------------------------------------------------------ claude if want claude; then section "Claude Code" # The signed apt repo rather than the native curl|bash installer. That # installer puts everything under $HOME and explicitly refuses to run # under sudo -- here it would either abort or land in /root/.local/bin, # where the desktop user's shell would never find it. apt also makes this # step idempotent for free and folds updates into the normal upgrade path. if ! pkg_available claude-code; then apt_install curl gnupg if $DRY_RUN; then log "[dry-run] add downloads.claude.ai apt repo (key ${CLAUDE_KEY_FP})" else install -d -m 0755 /etc/apt/keyrings if curl -fsSL --max-time 30 "$CLAUDE_KEY_URL" -o "$CLAUDE_KEY"; then # Check the key is the expected one before trusting it. A truncated # or captive-portal-mangled download otherwise shows up much later # as an opaque NO_PUBKEY error, and a substituted key would be worse. local fp fp=$(gpg --show-keys --with-colons "$CLAUDE_KEY" 2>/dev/null \ | awk -F: '/^fpr:/{print $10; exit}') if [[ $fp == "$CLAUDE_KEY_FP" ]]; then printf 'deb [signed-by=%s] https://downloads.claude.ai/claude-code/apt/stable stable main\n' \ "$CLAUDE_KEY" > "$CLAUDE_LIST" apt_update_soft log "added the Claude Code apt repo (key verified)" else rm -f "$CLAUDE_KEY" warn "claude-code signing key fingerprint mismatch (got '${fp:-none}', expected $CLAUDE_KEY_FP) — repo NOT added" fi else warn "could not fetch the Claude Code signing key" fi fi fi if pkg_available claude-code || $DRY_RUN; then apt_install claude-code log "installed claude-code" note "Claude Code: run 'claude' to start; log in via the browser prompt. Needs a Pro/Max/Team/Enterprise or Console account (the free plan does not include it)." note "Claude Code installed from apt does not auto-update: 'sudo apt upgrade claude-code'." else warn "claude-code not available from apt" note "Claude Code was not installed. Fall back to the native installer, run as your own user and NOT with sudo: curl -fsSL https://claude.ai/install.sh | bash" fi fi # --------------------------------------------------------------- AIO board local AIO_VIA_PACKAGE=false if want aio; then section "HackerGadgets AIO v2 board" require_akrex_repo if pkg_available hackergadgets-uconsole-aio-board; then log "vendor metapackage available — using it" # No --install-recommends: the Recommends are tar1090, sdrpp-brown (an # SDR++ build, which rxd replaces) and pygpsclient. tar1090 is an ADS-B # stack that builds from source in its postinst (pulling gcc, make, git, # lighttpd, tk-dev) and starts readsb, which claims the RTL-SDR and # fails when none is visible. Only Depends: rtl-sdr is required here. if run apt-get install -y --no-install-recommends hackergadgets-uconsole-aio-board; then AIO_VIA_PACKAGE=true else warn "hackergadgets-uconsole-aio-board failed to install — falling back to manual overlays" note "The AIO metapackage failed. Check 'sudo dpkg --configure -a' output; the boot overlays were written by hand instead." fi else log "vendor metapackage not in any configured repo — writing overlays by hand" note "hackergadgets-uconsole-aio-board was unavailable; boot config was written manually. If you later add Rex's ClockworkPi apt repo, the metapackage is the more maintainable path." fi # The metapackage's postinst writes into the desktop user's home while # running as root -- a pygpsclient venv, a .cache directory, an installer # script. A root-owned .cache in particular breaks any GUI app that tries # to write its own cache, so hand these back. if ! $DRY_RUN; then local stray while IFS= read -r stray; do [[ -n $stray ]] || continue chown -R "${DESKTOP_USER}:${USER_GROUP}" "$stray" 2>/dev/null \ && log "reclaimed $(basename "$stray") from root" done < <(find "$USER_HOME" -mindepth 1 -maxdepth 1 ! -user "$DESKTOP_USER" 2>/dev/null) fi # aiov2_ctl drives the v2 power rails and reapplies them at boot. Install # it regardless of which path above ran — the metapackage may or may not # include it, and the rails are what make the board respond at all. if command -v aiov2_ctl >/dev/null 2>&1; then log "aiov2_ctl already installed" else apt_install python3 python3-pyqt6 if [[ -d $AIOV2_DIR/.git ]]; then run git -C "$AIOV2_DIR" pull --ff-only else run git clone --depth 1 "$AIOV2_REPO" "$AIOV2_DIR" fi if ! run python3 "$AIOV2_DIR/aiov2_ctl.py" --install; then warn "aiov2_ctl install failed — falling back to a plain pinctrl rail unit" fi fi if command -v aiov2_ctl >/dev/null 2>&1 || $DRY_RUN; then local rail local rail for rail in "${BOOT_RAILS[@]}"; do # Current state, and separately the state applied at boot. These are # distinct in aiov2_ctl: `aiov2_ctl SDR on` powers the rail now, while # aiov2-rails-boot.service replays only what --boot-rail has recorded, # so setting just the former leaves every rail dark after a reboot. run aiov2_ctl "$rail" on || warn "aiov2_ctl $rail on failed" run aiov2_ctl --boot-rail "$rail" on || warn "could not set $rail to come up at boot" done # GPS and LoRa are deliberately not touched rather than forced off, so a # re-run does not undo a rail you switched on by hand. run systemctl enable aiov2-rails-boot.service \ || warn "aiov2-rails-boot.service not found; rails may not persist across reboot" log "power rails on now and at boot: ${BOOT_RAILS[*]} (others left as-is)" else # Fallback: raw pinctrl plus our own oneshot, so the board still comes # up with its rails on even without the vendor tooling. apt_install pinctrl || apt_install raspi-utils || true write_file /usr/local/sbin/uconsole-aio-rails 0755 <<'RAILS' #!/bin/sh # Managed by rx/setup.sh — AIO v2 GPIO power rails. # # usage: uconsole-aio-rails [on|off] boot rails (SDR + USB hub) # uconsole-aio-rails [on|off] one rail by name # # Rails: GPS LORA SDR USB. Only the boot set comes up automatically; the # RTL-SDR needs USB too, being an internal USB device behind the AIO hub. set -eu rail_pin() { case "$1" in GPS) echo __RAIL_GPS__ ;; LORA) echo __RAIL_LORA__ ;; SDR) echo __RAIL_SDR__ ;; USB) echo __RAIL_USB__ ;; *) echo "unknown rail: $1 (GPS LORA SDR USB)" >&2; exit 2 ;; esac } level_for() { case "$1" in on) echo dh ;; off) echo dl ;; *) echo "usage: $0 [RAIL] [on|off]" >&2; exit 2 ;; esac } case "${1:-}" in GPS|LORA|SDR|USB) pin=$(rail_pin "$1") lvl=$(level_for "${2:-on}") pinctrl "$pin" op pinctrl "$pin" "$lvl" ;; *) lvl=$(level_for "${1:-on}") for r in __BOOT_RAILS__; do pin=$(rail_pin "$r") pinctrl "$pin" op pinctrl "$pin" "$lvl" done ;; esac RAILS if ! $DRY_RUN; then sed -i \ -e "s|__RAIL_GPS__|${RAIL_GPS}|" \ -e "s|__RAIL_LORA__|${RAIL_LORA}|" \ -e "s|__RAIL_SDR__|${RAIL_SDR}|" \ -e "s|__RAIL_USB__|${RAIL_USB}|" \ -e "s|__BOOT_RAILS__|${BOOT_RAILS[*]}|" \ /usr/local/sbin/uconsole-aio-rails fi write_file /etc/systemd/system/uconsole-aio-rails.service 0644 <<'UNIT' [Unit] Description=uConsole AIO v2 power rails DefaultDependencies=no After=sysinit.target Before=basic.target [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/sbin/uconsole-aio-rails on [Install] WantedBy=sysinit.target UNIT run systemctl daemon-reload run systemctl enable uconsole-aio-rails.service run /usr/local/sbin/uconsole-aio-rails on || warn "pinctrl rail enable failed (pinctrl missing?)" log "power rails on via pinctrl fallback unit: ${BOOT_RAILS[*]}" fi fi # ----------------------------------------------------- boot config (CM4) # --------------------------------------------------------- boot config # The overlays this hardware needs, independent of which sections are # running. Building the list from the active sections means `--only gps` # rewrites the block with just the GPS lines and silently drops the RTC and # SPI overlays the other subsystems depend on. # # Lines already set elsewhere in config.txt are skipped rather than # repeated: the AIO metapackage writes some of these itself, and a second # dtoverlay for the same device would try to instantiate it twice. if [[ -f $BOOT_DIR/config.txt ]]; then section "Boot configuration ($BOOT_DIR/config.txt)" local -a want_overlays=( "dtparam=i2c_arm=on" # RTC bus "dtoverlay=i2c-rtc,pcf85063a" # RTC "enable_uart=1" # GPS on the PL011 "dtoverlay=pps-gpio,gpiopin=6" # GPS PPS for chrony "dtparam=spi=on" "dtoverlay=spi1-1cs" # LoRa: creates /dev/spidev1.0 ) local -a stanza=("[all]" "# HackerGadgets AIO v2 on uConsole CM4") local ov existing existing=$(sed "\|^${BEGIN_MARK}\$|,\|^${END_MARK}\$|d" "$BOOT_DIR/config.txt" \ | grep -vE '^[[:space:]]*#') for ov in "${want_overlays[@]}"; do if printf '%s\n' "$existing" | grep -qxF "$ov"; then log "already set elsewhere: $ov" else stanza+=("$ov") log "adding: $ov" fi done if (( ${#stanza[@]} > 2 )); then printf '%s\n' "${stanza[@]}" | apply_block "$BOOT_DIR/config.txt" note "Boot overlays changed — a reboot is required before RTC/GPS/LoRa work." else # Still rewrite, so a block left over from an earlier narrower run is # replaced by the full set rather than kept. printf '%s\n' "${stanza[@]}" | apply_block "$BOOT_DIR/config.txt" log "all required overlays already present" fi fi # --------------------------------------------------------------------- rtc if want rtc; then section "Real-time clock" apt_install i2c-tools # The kernel probes the PCF85063A early in boot, before aiov2-rails-boot # has brought the AIO rails up, gets no answer ("RTC chip is not present", # -121) and gives up -- so /dev/rtc0 never appears and the clock boots at # whatever date systemd last knew. The chip answers fine once the rails # are on, so bind it then, and load the time from it before chrony starts. # # The same script seeds the chip when the system clock is already # disciplined, which is the state any re-run of this script finds it in. # That replaces a manual 'hwclock -w' that could never work while the # chip was unbound. write_file /usr/local/sbin/uconsole-rtc 0755 <<'RTC' #!/bin/sh # Managed by rx/setup.sh — bind the AIO v2 RTC once the power rails are up, # then either load the system clock from it (boot) or seed it (clock synced). set -u if [ ! -e /dev/rtc0 ]; then for dev in /sys/bus/i2c/devices/*-0051; do [ -e "$dev" ] || continue [ -e "$dev/driver" ] && continue # The rail may still be settling; give the chip a few seconds. for _ in 1 2 3 4 5 6 7 8 9 10; do echo "${dev##*/}" > /sys/bus/i2c/drivers/rtc-pcf85063/bind 2>/dev/null && break sleep 1 done done fi if [ ! -e /dev/rtc0 ]; then echo "uconsole-rtc: no RTC bound — the chip is not answering at 0x51" >&2 exit 1 fi # At boot this runs before chrony, so 'tracking' fails and the RTC is read. # Run later against a disciplined clock, it writes the chip instead -- never # the other way round, which would step good time back to the RTC's drift. if chronyc -n tracking 2>/dev/null | grep -q '^Leap status *: Normal'; then hwclock --systohc && echo "uconsole-rtc: RTC seeded from the synchronised system clock" elif hwclock --hctosys; then echo "uconsole-rtc: system clock set from the RTC" else # A chip that lost power (flat or missing CR1220) holds no valid time. # The clock then keeps fake-hwclock's restore until GPS or NTP fixes it. echo "uconsole-rtc: RTC holds no valid time; system clock left alone" >&2 fi exit 0 RTC write_file /etc/systemd/system/uconsole-rtc.service 0644 <<'UNIT' [Unit] Description=uConsole AIO v2 RTC (bind after the power rails, then load time) After=aiov2-rails-boot.service uconsole-aio-rails.service fake-hwclock.service Before=chrony.service time-sync.target [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/sbin/uconsole-rtc [Install] WantedBy=multi-user.target UNIT run systemctl daemon-reload run systemctl enable uconsole-rtc.service # Not --now: start is a no-op on an already-active oneshot, so call the # script directly; on a synced clock that binds and seeds the chip. run /usr/local/sbin/uconsole-rtc || warn "RTC did not bind — see 'dmesg | grep -i rtc'" # fake-hwclock stays on as the fallback for a flat CR1220. It restores # the last-saved time early in boot and only ever moves the clock # forward, and uconsole-rtc runs after it, so a working RTC still wins. if pkg_installed fake-hwclock; then run systemctl enable fake-hwclock log "fake-hwclock enabled as the fallback behind the RTC" fi note "RTC: 'systemctl status uconsole-rtc' says whether the chip bound and which way time went. 'Power loss detected' in dmesg after a full power-off means the CR1220 is flat, missing or in backwards." fi # --------------------------------------------------------------------- gps if want gps; then section "GPS" apt_install gpsd gpsd-clients pps-tools minicom # The kernel serial console owns /dev/ttyS0 and will fight gpsd for it. if [[ -f $BOOT_DIR/cmdline.txt ]] && grep -q 'console=serial0,115200' "$BOOT_DIR/cmdline.txt"; then if $DRY_RUN; then log "[dry-run] strip console=serial0,115200 from cmdline.txt" else sed -i 's/console=serial0,115200 *//' "$BOOT_DIR/cmdline.txt" fi log "removed serial console from cmdline.txt" fi run systemctl disable --now serial-getty@ttyS0.service 2>/dev/null || true write_file /etc/default/gpsd 0644 <<'GPSD' # Managed by rx/setup.sh — AIO v2 GPS on the CM4 PL011 UART. START_DAEMON="true" USBAUTO="false" DEVICES="/dev/ttyS0" # -n polls the receiver without waiting for a client to connect. GPSD_OPTIONS="-n -s 9600" GPSD # The service, not just the socket: socket activation only starts gpsd when # a client connects to 2947, and chrony reads shared memory rather than # that socket -- so under socket activation alone gpsd would never run and # no time would ever reach chrony. run systemctl enable gpsd.socket run systemctl enable --now gpsd.service || warn "gpsd.service would not start" log "gpsd running against /dev/ttyS0 @ 9600" # GPS-disciplined time. This matters off-grid: JS8 and FT8 need sub-second # accuracy, which is exactly when there is no network to get it from. apt_install chrony # Only one thing may discipline the clock. run systemctl disable --now systemd-timesyncd 2>/dev/null || true local chrony_conf=/etc/chrony/chrony.conf local chrony_drop=/etc/chrony/conf.d/10-uconsole-gps.conf local chrony_target="$chrony_conf" [[ -d /etc/chrony/conf.d ]] && chrony_target="$chrony_drop" # rtcsync is in Debian's shipped chrony.conf; declaring it twice is a # config error, so only add it when it is not already there. local rtcsync_line="" if ! grep -qs '^[[:space:]]*rtcsync' "$chrony_conf"; then rtcsync_line=$'\n# Keep the battery-backed RTC in step, so the clock is right at boot\n# before any GPS fix or network.\nrtcsync' fi if [[ $chrony_target == "$chrony_drop" ]]; then write_file "$chrony_drop" 0644 </dev/null 2>&1 || true; then :; fi systemctl restart chrony 2>/dev/null || warn "chrony would not restart" fi log "chrony configured for GPS + PPS (wrote ${chrony_target})" note "GPS time: after reboot check 'chronyc sources' — NMEA and PPS should both appear, PPS with a '*' once locked. 'ppstest /dev/pps0' proves the pulse itself." note "GPS: needs the antenna on the 'GPS' IPEX pad and a clear sky view. The rail comes up at boot; check with 'cgps -s' or 'gpsmon'." fi # -------------------------------------------------------------------- lora if want lora; then section "LoRa mesh (Meshtastic / MeshCore)" require_akrex_repo apt_install libgpiod-dev libyaml-cpp-dev libbluetooth-dev libusb-1.0-0-dev \ libi2c-dev openssl libssl-dev if ! pkg_available meshtasticd; then log "adding the Meshtastic apt repo" # The vendor guide wgets a pinned .deb that is long stale; the OBS repo # keeps meshtasticd updatable through normal apt upgrades. if $DRY_RUN; then log "[dry-run] fetch Meshtastic signing key and add ${MESHTASTIC_REPO}" else mkdir -p /etc/apt/keyrings if curl -fsSL --max-time 30 "${MESHTASTIC_REPO}Release.key" \ | gpg --dearmor --yes -o /etc/apt/keyrings/meshtastic.gpg; then chmod 0644 /etc/apt/keyrings/meshtastic.gpg printf 'deb [signed-by=/etc/apt/keyrings/meshtastic.gpg] %s /\n' "$MESHTASTIC_REPO" \ > /etc/apt/sources.list.d/meshtastic.list apt_update_soft else warn "could not fetch the Meshtastic signing key" fi fi fi if pkg_available meshtasticd || $DRY_RUN; then apt_install meshtasticd else warn "meshtasticd unavailable; install it by hand from https://github.com/meshtastic/firmware/releases" note "meshtasticd was not installed — the SPI overlays are in place, so the radio is ready once you do." fi # meshtasticd gets a config of its own rather than the packaged # config.yaml, which the AIO metapackage edits to add `GPS: SerialPath: # /dev/ttyS0`. A config.d drop-in cannot remove that key, and gpsd owns # the port to feed chrony; two readers on one UART garble both streams. # Same state directory as before, so the node keeps its identity. if [[ -f /etc/meshtasticd/config.d/uconsole-aio-v2.yaml ]] \ && grep -qs 'Managed by rx/setup.sh' /etc/meshtasticd/config.d/uconsole-aio-v2.yaml; then run rm -f /etc/meshtasticd/config.d/uconsole-aio-v2.yaml log "removed the old config.d drop-in (superseded by rx.yaml)" fi write_file /etc/meshtasticd/rx.yaml 0644 <<'MESHYAML' # Managed by rx/setup.sh — meshtasticd on the HackerGadgets AIO v2 (SX1262). # Started with --config by meshtasticd.service.d/50-rx.conf, in place of the # packaged config.yaml. Lora: Module: sx1262 DIO2_AS_RF_SWITCH: true DIO3_TCXO_VOLTAGE: true IRQ: 26 Busy: 24 Reset: 25 spidev: spidev1.0 # No GPS block on purpose: gpsd owns /dev/ttyS0 to feed chrony. Logging: LogLevel: info Webserver: Port: 443 RootPath: /usr/share/meshtasticd/web SSLKey: /etc/meshtasticd/ssl/private_key.pem SSLCert: /etc/meshtasticd/ssl/certificate.pem General: MaxNodes: 200 MaxMessageQueue: 100 ConfigDirectory: /etc/meshtasticd/config.d/ MESHYAML # Both mesh daemons drive the same SX1262, so at most one may run, and # only when asked for. aiov2_ctl starts meshtasticd every time it powers # the LoRa rail -- at boot, and on every run of the aio section -- so # rather than fight it, each unit refuses to start unless rx-mesh has # left a flag in /run, which is empty at every boot. write_file /etc/systemd/system/meshtasticd.service.d/50-rx.conf 0644 <<'MESHUNIT' # Managed by rx/setup.sh — start only through rx-mesh, with rx's config. [Service] ExecCondition=/usr/bin/test -e /run/rx/mesh/meshtastic ExecStart= ExecStart=/usr/bin/meshtasticd --config=/etc/meshtasticd/rx.yaml MESHUNIT write_file /usr/local/sbin/rx-mesh 0755 <<'RXMESH' #!/bin/sh # Managed by rx/setup.sh — the one way to start or stop the mesh daemons that # share the AIO's LoRa radio. Run as root (radio calls it through sudo). # # rx-mesh start meshtastic|meshcore stop the other, then start this one # rx-mesh stop stop both # rx-mesh status the running network, or "off" # rx-mesh release-pins free the radio's GPIO lines # # Each unit has ExecCondition=test -e /run/rx/mesh/, so nothing else # can start it: not boot, and not aiov2_ctl, which starts meshtasticd whenever # it powers the LoRa rail. set -eu FLAGS=/run/rx/mesh unit() { case $1 in meshtastic) echo meshtasticd ;; meshcore) echo raspberrypimc-companion ;; *) echo "rx-mesh: unknown network '$1'" >&2; exit 2 ;; esac } release_pins() { # MeshCoreRPI drives BUSY, RESET and IRQ through sysfs and leaves them # exported when it stops; meshtasticd then cannot claim them ("cannot # claim pin GPIO26"). sysfs numbers are offset by the lowest chip base, # 512 on current Pi kernels -- the same rule MeshCoreRPI uses. base="" for c in /sys/class/gpio/gpiochip*; do [ -e "$c/base" ] || continue b=$(cat "$c/base") if [ -z "$base" ] || [ "$b" -lt "$base" ]; then base=$b; fi done [ -n "$base" ] || return 0 for pin in 24 25 26; do n=$((base + pin)) if [ -e "/sys/class/gpio/gpio$n" ]; then echo "$n" > /sys/class/gpio/unexport || true; fi done } stop_all() { rm -f "$FLAGS/meshtastic" "$FLAGS/meshcore" systemctl stop meshtasticd raspberrypimc-companion 2>/dev/null || true release_pins } case ${1:-} in start) net=${2:-}; u=$(unit "$net") if [ -e "$FLAGS/$net" ] && systemctl is-active --quiet "$u"; then exit 0; fi stop_all mkdir -p "$FLAGS" : > "$FLAGS/$net" systemctl reset-failed "$u" 2>/dev/null || true systemctl start "$u" ;; stop) stop_all ;; status) for net in meshtastic meshcore; do if systemctl is-active --quiet "$(unit "$net")"; then echo "$net"; exit 0; fi done echo off ;; release-pins) release_pins ;; *) echo "usage: rx-mesh start meshtastic|meshcore | stop | status | release-pins" >&2 exit 2 ;; esac RXMESH run systemctl daemon-reload # Not at boot: the flags above already guarantee that, and disabling # keeps systemctl's own view honest. aiov2_ctl's switch is left off too, # but only written when it is not already -- its "off" path runs # `disable --now`, which would stop a running node on every maint run. run systemctl disable meshtasticd 2>/dev/null || true if command -v aiov2_ctl >/dev/null 2>&1 \ && ! aiov2_ctl --mesh-on-boot status 2>/dev/null | grep -q 'mesh_on_boot: off'; then run aiov2_ctl --mesh-on-boot off || warn "could not turn off aiov2_ctl's mesh autostart" fi # meshtastic-mui provides /usr/bin/meshtastic-ui, the dashboard. if pkg_available meshtastic-mui; then apt_install_opt meshtastic-mui else warn "meshtastic-mui not available in any configured repo" fi # The radio needs /dev/spidev1.0 from the spi1-1cs overlay. Say so plainly # rather than leaving a daemon that starts and finds nothing. if ! $DRY_RUN && [[ ! -e /dev/spidev1.0 ]]; then warn "/dev/spidev1.0 does not exist — the LoRa radio cannot be reached until the spi1-1cs overlay is active (reboot)." note "LoRa: /dev/spidev1.0 was missing at setup time. Reboot, then check it exists before 'radio meshtastic'." fi # MeshCore on Linux: MeshCoreRPI, a port of the MeshCore firmware to the # Pi, run as a companion (the node a client app talks to). A third-party # fork, so pinned to one release and checked against its digest; bump # both together. Its 1.8 V TCXO default and its handling of the 512 sysfs # GPIO offset were read in its source before choosing it. local MC_VER=0.2.4+de72da2 local MC_DEB="raspberrypimc-native-companion_${MC_VER}_arm64.deb" local MC_URL="https://github.com/engperini/MeshCoreRPI/releases/download/v${MC_VER%%+*}/${MC_DEB}" local MC_SHA=3301d139cc206c4bd548c5935cf59afcfc0353cd1d62fed9292e067b72a7a318 # The env file first: the package's postinst copies its example only when # none exists, and restarts the service on upgrade (which the # ExecCondition below then refuses unless MeshCore is the active network). write_file /etc/raspberrypimc/companion.env 0644 <<'MCENV' # Managed by rx/setup.sh — MeshCoreRPI companion on the AIO v2's SX1262. # Radio: the North Texas MeshCore settings (ntxmesh.com/join): 910.525 MHz, # 62.5 kHz, SF 7, CR 5. Applied at every start; the client app cannot # change them. RPI_FREQ_HZ=910525000 RPI_BW_HZ=62500 RPI_SF=7 RPI_CR=5 # The firmware reports 20 dBm as its maximum. RPI_TX_DBM=20 RPI_RADIO_DRIVER=sx1262 RPI_DATA_DIR=/var/lib/raspberrypimc/userdata # spidev1.0, with chip select left to the kernel (-1: no manual CS pin). RPI_SPI_DEV_PREFIX=/dev/spidev RPI_SPI_BUS=1 RPI_SPI_CS=0 RPI_CS_PIN=-1 RPI_SPI_SPEED_HZ=1000000 RPI_RESET_PIN=25 RPI_BUSY_PIN=24 RPI_IRQ_PIN=26 RPI_TXEN_PIN=-1 RPI_RXEN_PIN=-1 # 1.8 V TCXO on DIO3, RF switch on DIO2 -- as in meshtasticd's config. RPI_USE_TCXO=1 RPI_USE_DIO2_RF=1 # Clients connect over loopback only: the port is unauthenticated. RPI_COMPANION_TCP_PORT=5000 RPI_COMPANION_TCP_HOST=127.0.0.1 RPI_COMPANION_BLE_ENABLE=0 # Its web GUI would listen on 0.0.0.0:8080 without a password. RPI_COMPANION_WEB_ENABLE=0 RPI_ADVERT_INTERVAL_MIN=0 RPI_ADVERT_MODE=flood MCENV write_file /etc/systemd/system/raspberrypimc-companion.service.d/50-rx.conf 0644 <<'MCUNIT' # Managed by rx/setup.sh — start only through rx-mesh; free the radio's pins # on the way out so meshtasticd can claim them. [Service] ExecCondition=/usr/bin/test -e /run/rx/mesh/meshcore ExecStopPost=/usr/local/sbin/rx-mesh release-pins MCUNIT run systemctl daemon-reload local mc_have="" mc_have=$(dpkg-query -W -f='${Version}' raspberrypimc-native-companion 2>/dev/null) || mc_have="" if [[ $mc_have == "$MC_VER" ]]; then log "MeshCoreRPI companion $MC_VER already installed" elif [[ $(dpkg --print-architecture) != arm64 ]]; then warn "MeshCoreRPI ships arm64 only; skipping MeshCore" elif $DRY_RUN; then log "[dry-run] install MeshCoreRPI companion $MC_VER from $MC_URL" else local mc_tmp; mc_tmp=$(mktemp -d) if curl -fsSL --max-time 120 "$MC_URL" -o "$mc_tmp/$MC_DEB" \ && printf '%s %s\n' "$MC_SHA" "$mc_tmp/$MC_DEB" | sha256sum -c --quiet -; then if apt-get install -y "$mc_tmp/$MC_DEB"; then log "installed MeshCoreRPI companion $MC_VER" else warn "MeshCoreRPI install failed" fi else warn "MeshCoreRPI download failed or did not match its pinned digest; MeshCore not installed" fi rm -rf "$mc_tmp" fi # Its postinst enables the service at boot; rx-mesh starts it instead. if [[ -f /lib/systemd/system/raspberrypimc-companion.service ]]; then run systemctl disable raspberrypimc-companion 2>/dev/null || true fi # Python clients, each in a venv: neither has a Debian package, and # bookworm marks the system interpreter externally-managed. radio talks # to the nodes through them. local mtcli=/opt/meshtastic-cli mccli=/opt/meshcore-cli if ! $DRY_RUN; then apt_install_opt python3-venv if [[ ! -x $mtcli/bin/meshtastic ]]; then if python3 -m venv "$mtcli" 2>/dev/null \ && "$mtcli/bin/pip" install --quiet --upgrade pip meshtastic 2>/dev/null; then log "installed the meshtastic CLI into $mtcli" else warn "could not install the meshtastic CLI" fi fi [[ -x $mtcli/bin/meshtastic ]] && ln -sf "$mtcli/bin/meshtastic" /usr/local/bin/meshtastic if [[ ! -x $mccli/bin/meshcli ]]; then if python3 -m venv "$mccli" 2>/dev/null \ && "$mccli/bin/pip" install --quiet --upgrade pip meshcore-cli 2>/dev/null; then log "installed meshcore-cli into $mccli" else warn "could not install meshcore-cli" fi fi [[ -x $mccli/bin/meshcli ]] && ln -sf "$mccli/bin/meshcli" /usr/local/bin/meshcore-cli fi # The node-side half of `radio meshtastic`: its settings and the mesh # commands, through the meshtastic library in the CLI's venv. Identity # and region come from the config at the top of this script. write_file /usr/local/lib/rx/meshtastic_ops.py 0644 <<'MTOPS' """Managed by rx/setup.sh — Meshtastic operations for radio, run with the meshtastic CLI's venv interpreter (/opt/meshtastic-cli/bin/python). apply put the North Texas settings and our identity in effect nodes heard nodes, most recent first send TEXT to the primary channel dm NAME TEXT direct message; NAME is a long or short name, or !id join URL add the channels in a shared URL, keeping ours info identity, radio, channels and policy Settings follow NTX Mesh (ntxmesh.com/join): every metroplex county runs LONG_FAST except Wise. Position is never sent. """ import json import logging import os import sys import time from meshtastic.tcp_interface import TCPInterface # The library logs tracebacks from its heartbeat thread whenever a connection # closes under it, which every short-lived command here does. logging.disable(logging.CRITICAL) def mesh_conf(): # Written by setup.sh from the config at the top of it. conf = {} with open("/etc/rx/mesh.conf", encoding="utf-8") as fh: for line in fh: key, sep, value = line.strip().partition("=") if sep and not key.startswith("#"): conf[key] = value return conf CONF = mesh_conf() OWNER_LONG, OWNER_SHORT, REGION = CONF["NODE_NAME"], CONF["NODE_SHORT"], CONF["LORA_REGION"] # Slot 20 is LongFast's default, 906.875 MHz in the US. Pinned rather than # left at 0, which hashes the primary channel's name: joining a private # channel as primary would otherwise move the node off the regional # frequency. WANT = { "config": { "lora": {"region": REGION, "use_preset": True, "modem_preset": "LONG_FAST", "channel_num": 20, "hop_limit": 3, "tx_enabled": True, "ignore_mqtt": True, "config_ok_to_mqtt": False}, "device": {"role": "CLIENT", "node_info_broadcast_secs": 10800}, # NTX also suggests a 60 s smart minimum interval; the firmware clamps # it to 300, and with no position ever sent it would not matter. "position": {"gps_mode": "NOT_PRESENT", "fixed_position": False, "position_broadcast_secs": 3600, "position_broadcast_smart_enabled": True, "broadcast_smart_minimum_distance": 100}, }, "module": { "telemetry": {"device_update_interval": 10800, "environment_update_interval": 3600}, "mqtt": {"enabled": False}, }, } NAMES_CACHE = os.path.expanduser("~/.cache/rx/mesh-names-meshtastic") def connect(): last = None for _ in range(20): try: iface = TCPInterface(hostname="localhost") except Exception as exc: # the daemon takes a few seconds to listen last = exc time.sleep(1.5) continue # The library's reader thread finishes connecting -- and sends its # first heartbeat -- just after the constructor returns. Closing # before then makes it print a BrokenPipe traceback. time.sleep(0.5) return iface sys.exit("meshtastic: cannot reach meshtasticd on localhost:4403 (%s)" % last) def coerce(msg, key, value): field = msg.DESCRIPTOR.fields_by_name[key] if field.enum_type is not None and isinstance(value, str): return field.enum_type.values_by_name[value].number return value def differences(iface): node = iface.localNode out = [] for kind, groups in WANT.items(): root = node.localConfig if kind == "config" else node.moduleConfig for group, fields in groups.items(): msg = getattr(root, group) for key, value in fields.items(): value = coerce(msg, key, value) if getattr(msg, key) != value: out.append((msg, group, key, value)) user = (iface.getMyNodeInfo() or {}).get("user", {}) owner_ok = user.get("longName") == OWNER_LONG and user.get("shortName") == OWNER_SHORT return out, owner_ok def apply(): # A config write makes the node reboot itself about 7 s later, and writes # that land around that reboot can be lost -- seen with lora and position # together. So write, wait out the reboot, read back, and retry what is # still different. applied = set() for _ in range(3): iface = connect() try: diffs, owner_ok = differences(iface) if not diffs and owner_ok: break if diffs: iface.localNode.beginSettingsTransaction() for msg, group, key, value in diffs: setattr(msg, key, value) applied.add("%s.%s" % (group, key)) for group in sorted({d[1] for d in diffs}): iface.localNode.writeConfig(group) iface.localNode.commitSettingsTransaction() if not owner_ok: iface.localNode.setOwner(long_name=OWNER_LONG, short_name=OWNER_SHORT) applied.add("owner") time.sleep(2) finally: iface.close() time.sleep(12) else: iface = connect() try: diffs, owner_ok = differences(iface) finally: iface.close() left = ["%s.%s" % (d[1], d[2]) for d in diffs] + ([] if owner_ok else ["owner"]) if left: sys.exit("meshtastic: these would not stick: " + ", ".join(left)) print("meshtastic: applied " + ", ".join(sorted(applied)) if applied else "meshtastic: settings already in effect") def heard(iface): me = iface.myInfo.my_node_num rows = [] for n in (iface.nodes or {}).values(): if n.get("num") == me: continue u = n.get("user", {}) rows.append({"id": u.get("id", ""), "long": u.get("longName", "?"), "short": u.get("shortName", ""), "hw": u.get("hwModel", ""), "role": u.get("role", "CLIENT"), "snr": n.get("snr"), "hops": n.get("hopsAway"), "last": n.get("lastHeard") or 0}) rows.sort(key=lambda r: r["last"], reverse=True) os.makedirs(os.path.dirname(NAMES_CACHE), exist_ok=True) with open(NAMES_CACHE, "w", encoding="utf-8") as fh: fh.write("\n".join(r["short"] or r["long"] for r in rows if r["short"] or r["long"]) + "\n") return rows def age(epoch): if not epoch: return "-" s = int(time.time() - epoch) for unit, n in (("d", 86400), ("h", 3600), ("m", 60)): if s >= n: return "%d%s ago" % (s // n, unit) return "%ds ago" % s def nodes(iface): rows = heard(iface) if not rows: print("no nodes heard yet — node info goes out every few hours, or when a node boots") return print("%-20s %-5s %-10s %-12s %6s %4s %s" % ("NAME", "SHORT", "ID", "HARDWARE", "SNR", "HOPS", "LAST HEARD")) for r in rows: snr = "" if r["snr"] is None else "%.1f" % r["snr"] hops = "" if r["hops"] is None else str(r["hops"]) print("%-20s %-5s %-10s %-12s %6s %4s %s" % (r["long"][:20], r["short"][:5], r["id"], r["hw"][:12], snr, hops, age(r["last"]))) def resolve(iface, name): want = name.lower() for n in (iface.nodes or {}).values(): u = n.get("user", {}) if want in (u.get("id", "").lower(), u.get("longName", "").lower(), u.get("shortName", "").lower()): return u["id"], u.get("longName", u["id"]) sys.exit("meshtastic: no heard node named '%s' (see: radio mesh nodes)" % name) def info(iface): node = iface.localNode lora, dev = node.localConfig.lora, node.localConfig.device enum = lambda msg, key: msg.DESCRIPTOR.fields_by_name[key].enum_type.values_by_number[getattr(msg, key)].name me = (iface.getMyNodeInfo() or {}).get("user", {}) print("network Meshtastic") print("identity %s (%s) %s" % (me.get("longName"), me.get("shortName"), me.get("id"))) print("radio %s %s, slot %d, hop limit %d, tx %s" % ( enum(lora, "region"), enum(lora, "modem_preset"), lora.channel_num, lora.hop_limit, "on" if lora.tx_enabled else "off")) print("role %s; node info every %dh; position never sent" % ( enum(dev, "role"), dev.node_info_broadcast_secs // 3600)) print("mqtt %s" % ("on" if node.moduleConfig.mqtt.enabled else "off")) for ch in node.channels or []: if ch.role == 0: continue role = "primary" if ch.role == 1 else "secondary" print("channel %d %s %s" % (ch.index, role, ch.settings.name or "(default LongFast)")) print("heard %d nodes" % len(heard(iface))) def main(): if len(sys.argv) < 2: sys.exit(__doc__) cmd, args = sys.argv[1], sys.argv[2:] if cmd == "apply": apply() return iface = connect() try: if cmd == "nodes": nodes(iface) elif cmd == "send" and args: iface.sendText(" ".join(args)) time.sleep(2) # let the queue drain before disconnecting print("sent to the primary channel") elif cmd == "dm" and len(args) >= 2: dest, label = resolve(iface, args[0]) iface.sendText(" ".join(args[1:]), destinationId=dest, wantAck=True) time.sleep(2) print("sent to %s" % label) elif cmd == "join" and len(args) == 1: iface.localNode.setURL(args[0], addOnly=True) print("joined; channels now:") info(iface) elif cmd == "info": info(iface) else: sys.exit(__doc__) finally: iface.close() if __name__ == "__main__": main() MTOPS # Identity for both networks, read by radio and meshtastic_ops.py. write_file /etc/rx/mesh.conf 0644 < "$sudo_tmp" if visudo -cqf "$sudo_tmp" 2>/dev/null; then install -m 0440 -o root -g root "$sudo_tmp" /etc/sudoers.d/50-rx-mesh log "sudo rule installed: $DESKTOP_USER may run rx-mesh" # Superseded: it allowed only `systemctl start meshtasticd`. rm -f /etc/sudoers.d/50-meshtasticd else warn "sudoers snippet failed validation; not installed" fi rm -f "$sudo_tmp" fi # `radio mesh` replaces the old `mesh` launcher. Remove it, if ours. if grep -qs 'Managed by rx/setup.sh' /usr/local/bin/mesh; then run rm -f /usr/local/bin/mesh log "removed the old mesh launcher (now: radio mesh)" fi # Offline map tiles for meshtastic-ui, in the SD-card layout its bundles # use: maps/