So other companions can add this one (a prerequisite for them sharing telemetry with it): - web UI: send a zero-hop or flood advert on demand - optional automatic adverts ([advert] interval_hours, 3-168 h like the repeater firmware; flood or zero-hop), timer persisted in advert.json and restarted by a successful manual advert of the same kind - the companion's own contact card (export_contact) shown as a QR code (segno) and copyable meshcore:// link - UI and README explain that telemetry must be allowed for the contact; the favourite flag alone doesn't grant it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
8.3 KiB
Bash
Executable File
240 lines
8.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install (or upgrade) the MeshCore repeater -> InfluxDB collector as a systemd service.
|
|
#
|
|
# git clone <repo> && cd meshcore-repeater-stat-influx
|
|
# sudo ./install.sh # install / upgrade (web UI on port 8080)
|
|
# sudo ./install.sh --port 9090 # first install with a different web UI port
|
|
# sudo ./install.sh --uninstall # remove service + code (keeps config and state)
|
|
#
|
|
# Layout:
|
|
# /opt/meshcore-influx/ code + Python venv
|
|
# /etc/meshcore-influx/config.toml config, also edited by the web UI (owned by the service user)
|
|
# /var/lib/meshcore-influx/ state.json (low-battery mode, last poll times)
|
|
# meshcore-influx.service runs as system user 'meshcore'
|
|
|
|
set -euo pipefail
|
|
|
|
NAME=meshcore-influx
|
|
APP_DIR=/opt/$NAME
|
|
CONF_DIR=/etc/$NAME
|
|
CONF=$CONF_DIR/config.toml
|
|
STATE_DIR=/var/lib/$NAME
|
|
UNIT=/etc/systemd/system/$NAME.service
|
|
SVC_USER=meshcore
|
|
SRC_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
|
say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ $EUID -eq 0 ]] || die "run as root: sudo $0 $*"
|
|
command -v systemctl >/dev/null || die "systemd is required"
|
|
|
|
WEB_PORT=8080
|
|
UNINSTALL=0
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--uninstall) UNINSTALL=1 ;;
|
|
--port) [[ ${2:-} =~ ^[0-9]+$ ]] || die "--port needs a number"; WEB_PORT=$2; shift ;;
|
|
*) die "usage: $0 [--port N] [--uninstall]" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ $UNINSTALL -eq 1 ]]; then
|
|
say "Stopping and removing $NAME.service"
|
|
systemctl disable --now "$NAME.service" 2>/dev/null || true
|
|
rm -f "$UNIT"
|
|
systemctl daemon-reload
|
|
rm -rf "$APP_DIR"
|
|
say "Removed. Kept $CONF_DIR and $STATE_DIR and user '$SVC_USER' (delete manually if wanted)."
|
|
exit 0
|
|
fi
|
|
|
|
[[ -f $SRC_DIR/meshcore_influx.py ]] || die "run this from the project checkout ($SRC_DIR has no meshcore_influx.py)"
|
|
|
|
# ---------------------------------------------------------------- python
|
|
say "Checking Python"
|
|
# Pick the newest suitable interpreter; a custom python3 earlier in PATH (e.g. an
|
|
# old /usr/local build) shouldn't hide the distro's newer one.
|
|
PY=""
|
|
for cand in python3.13 python3.12 python3.11 python3.10 python3 /usr/bin/python3; do
|
|
p=$(command -v "$cand" 2>/dev/null) || continue
|
|
if "$p" -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
|
|
PY=$p
|
|
break
|
|
fi
|
|
done
|
|
[[ -n $PY ]] || die "Python >= 3.10 not found (found: $(python3 -V 2>&1)); apt install python3 python3-venv"
|
|
say "Using $PY ($("$PY" -V 2>&1))"
|
|
if ! "$PY" -c 'import venv, ensurepip' 2>/dev/null; then
|
|
if command -v apt-get >/dev/null; then
|
|
say "Installing python3-venv"
|
|
apt-get update -qq
|
|
apt-get install -y -qq python3-venv
|
|
else
|
|
die "Python venv module missing; install your distro's python3-venv package"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- user
|
|
if ! id "$SVC_USER" >/dev/null 2>&1; then
|
|
say "Creating system user '$SVC_USER'"
|
|
useradd --system --no-create-home --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$SVC_USER"
|
|
fi
|
|
# Serial (USB companion) and BLE access.
|
|
for grp in dialout uucp bluetooth; do
|
|
if getent group "$grp" >/dev/null; then
|
|
usermod -aG "$grp" "$SVC_USER"
|
|
fi
|
|
done
|
|
|
|
# ---------------------------------------------------------------- code + venv
|
|
say "Installing code to $APP_DIR"
|
|
install -d -m 755 "$APP_DIR" "$APP_DIR/static"
|
|
install -m 644 "$SRC_DIR/meshcore_influx.py" "$SRC_DIR/web.py" "$SRC_DIR/requirements.txt" \
|
|
"$SRC_DIR/config.example.toml" "$SRC_DIR/README.md" "$APP_DIR/"
|
|
install -m 644 "$SRC_DIR"/static/* "$APP_DIR/static/"
|
|
if [[ -d $SRC_DIR/grafana ]]; then
|
|
install -d -m 755 "$APP_DIR/grafana"
|
|
install -m 644 "$SRC_DIR"/grafana/*.json "$APP_DIR/grafana/"
|
|
fi
|
|
if [[ -d $SRC_DIR/.git ]] && command -v git >/dev/null; then
|
|
git -C "$SRC_DIR" describe --always --dirty --tags 2>/dev/null > "$APP_DIR/VERSION" || true
|
|
fi
|
|
|
|
say "Creating virtualenv and installing dependencies (this can take a minute)"
|
|
[[ -x $APP_DIR/.venv/bin/python ]] || "$PY" -m venv "$APP_DIR/.venv"
|
|
"$APP_DIR/.venv/bin/pip" install -q --upgrade pip
|
|
"$APP_DIR/.venv/bin/pip" install -q --upgrade -r "$APP_DIR/requirements.txt"
|
|
"$APP_DIR/.venv/bin/python" -c 'import meshcore, influxdb_client, aiohttp, tomli_w, segno' \
|
|
|| die "dependency install failed"
|
|
|
|
# ---------------------------------------------------------------- config + state
|
|
# The web UI rewrites the config, so the service user owns it (it holds secrets: 600).
|
|
install -d -m 700 -o "$SVC_USER" -g "$SVC_USER" "$CONF_DIR"
|
|
NEW_CONFIG=0
|
|
if [[ ! -f $CONF ]]; then
|
|
SERIAL=$(ls /dev/serial/by-id/* 2>/dev/null | head -1 || true)
|
|
say "Creating starter config $CONF${SERIAL:+ (serial port: $SERIAL)}"
|
|
cat > "$CONF" <<EOF
|
|
# Managed by the web UI (http://<this host>:$WEB_PORT/). Hand edits are picked up
|
|
# automatically, but comments are not preserved when the UI saves.
|
|
|
|
[connection]
|
|
type = "serial"
|
|
port = "$SERIAL"
|
|
baudrate = 115200
|
|
|
|
[influxdb]
|
|
url = ""
|
|
token = ""
|
|
org = ""
|
|
bucket = ""
|
|
|
|
[poll]
|
|
interval_seconds = 900
|
|
retries = 3
|
|
timeout_seconds = 0
|
|
state_file = "state.json"
|
|
local_stats_interval_seconds = 60
|
|
low_battery_mv = 3500
|
|
recover_battery_mv = 3750
|
|
low_battery_interval_seconds = 3600
|
|
low_battery_skip = ["neighbours"]
|
|
|
|
[web]
|
|
listen = "0.0.0.0"
|
|
port = $WEB_PORT
|
|
EOF
|
|
NEW_CONFIG=1
|
|
fi
|
|
chown "$SVC_USER:$SVC_USER" "$CONF"
|
|
chmod 600 "$CONF"
|
|
install -d -m 750 -o "$SVC_USER" -g "$SVC_USER" "$STATE_DIR"
|
|
|
|
run_as_svc() { runuser -u "$SVC_USER" -- "$@"; }
|
|
CLI="$APP_DIR/.venv/bin/python $APP_DIR/meshcore_influx.py -c $CONF"
|
|
|
|
# ---------------------------------------------------------------- web password
|
|
if ! grep -q '^password_hash' "$CONF"; then
|
|
if [[ -t 0 ]]; then
|
|
say "Set the web UI admin password (at least 8 characters)"
|
|
while true; do
|
|
read -rsp " Password: " PW1; echo
|
|
read -rsp " Repeat: " PW2; echo
|
|
if [[ ${#PW1} -lt 8 ]]; then warn "too short"; continue; fi
|
|
if [[ $PW1 != "$PW2" ]]; then warn "passwords don't match"; continue; fi
|
|
break
|
|
done
|
|
printf '%s\n' "$PW1" | run_as_svc $CLI --set-password >/dev/null
|
|
unset PW1 PW2
|
|
else
|
|
warn "no terminal to ask for a web UI password; set one with:"
|
|
warn " sudo runuser -u $SVC_USER -- $CLI --set-password"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- systemd
|
|
say "Writing $UNIT"
|
|
cat > "$UNIT" <<EOF
|
|
[Unit]
|
|
Description=MeshCore repeater telemetry -> InfluxDB (with web UI)
|
|
Documentation=file://$APP_DIR/README.md
|
|
Wants=network-online.target
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=$SVC_USER
|
|
Group=$SVC_USER
|
|
SupplementaryGroups=$(for g in dialout uucp bluetooth; do getent group "$g" >/dev/null && printf '%s ' "$g"; done)
|
|
# Relative state_file ("state.json") resolves here.
|
|
WorkingDirectory=$STATE_DIR
|
|
Environment=PYTHONUNBUFFERED=1
|
|
ExecStart=$CLI
|
|
Restart=always
|
|
RestartSec=30
|
|
TimeoutStopSec=20
|
|
|
|
# Hardening (serial devices and the D-Bus socket for BLE stay reachable)
|
|
NoNewPrivileges=yes
|
|
ProtectSystem=strict
|
|
ProtectHome=yes
|
|
PrivateTmp=yes
|
|
ReadWritePaths=$STATE_DIR $CONF_DIR
|
|
ProtectKernelTunables=yes
|
|
ProtectKernelModules=yes
|
|
ProtectControlGroups=yes
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl enable "$NAME.service" >/dev/null
|
|
|
|
# ---------------------------------------------------------------- start / finish
|
|
say "Starting $NAME.service"
|
|
systemctl restart "$NAME.service"
|
|
sleep 3
|
|
if ! systemctl is-active --quiet "$NAME.service"; then
|
|
warn "service is not running; check: journalctl -u $NAME -n 50"
|
|
exit 1
|
|
fi
|
|
|
|
PORT=$(sed -n 's/^port *= *\([0-9][0-9]*\).*/\1/p' <(sed -n '/^\[web\]/,/^\[/p' "$CONF") | head -1)
|
|
HOST=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
say "Running. Web UI: http://${HOST:-<this host>}:${PORT:-8080}/"
|
|
if [[ $NEW_CONFIG -eq 1 ]]; then
|
|
cat <<EOF
|
|
Next, in the web UI:
|
|
1. Companion radio: check the serial port (or switch to TCP/BLE)
|
|
2. InfluxDB: enter URL / org / bucket / token, then "Test connection"
|
|
3. Repeaters: "Add repeater" and pick from the repeaters the companion has heard
|
|
EOF
|
|
fi
|
|
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
|
warn "ufw is active; to reach the UI from your LAN: sudo ufw allow ${PORT:-8080}/tcp"
|
|
fi
|
|
echo " Logs: journalctl -u $NAME -f"
|