feat: kit-busrouter v2.0 — complete Synology + GL fleet router platform
Includes: - package/ GL-XE3000 kit-busrouter (opkg) - scripts/provision.sh (GL) and provision-synology.sh (Synology) - syno-balance/ — new WAN balancer replacing aiwanbal (SmartWAN adapter) - kit-connect/ — unified connectivity SPK (Tailscale + reverse SSH) - docs/deployment/synology-rt2600ac-checklist.md — 62-point checklist - docs/provisioning/device-identity.md — fleet identity spec - docs/pilot/checklist.md — field pilot validation - x4078_20260721.dss — reference config backup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
PKG_NAME:=kit-busrouter
|
||||
PKG_VERSION:=0.1.0
|
||||
PKG_RELEASE:=1
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
define Package/kit-busrouter
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=Pioneer Bus Router multi-WAN balancer
|
||||
DEPENDS:=+mwan3 +curl +jq
|
||||
endef
|
||||
define Package/kit-busrouter/description
|
||||
Intelligent 5G+Starlink balancing, SIM auto-switch, and fleet telemetry.
|
||||
endef
|
||||
define Build/Compile
|
||||
endef
|
||||
define Package/kit-busrouter/install
|
||||
$(INSTALL_DIR) $(1)/usr/lib/busrouter $(1)/etc/init.d $(1)/etc/config $(1)/etc/busrouter
|
||||
$(CP) ./files/usr/lib/busrouter/* $(1)/usr/lib/busrouter/
|
||||
$(INSTALL_BIN) ./files/etc/init.d/busrouter $(1)/etc/init.d/busrouter
|
||||
$(INSTALL_CONF) ./files/etc/config/busrouter $(1)/etc/config/busrouter
|
||||
$(INSTALL_CONF) ./files/etc/busrouter/busrouter.conf $(1)/etc/busrouter/busrouter.conf
|
||||
endef
|
||||
$(eval $(call BuildPackage,kit-busrouter))
|
||||
@@ -0,0 +1 @@
|
||||
INTERVAL=30
|
||||
@@ -0,0 +1,2 @@
|
||||
config busrouter 'main'
|
||||
option interval '30'
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
USE_PROCD=1
|
||||
START=95
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command /usr/lib/busrouter/daemon.sh
|
||||
procd_set_param respawn 3600 5 0 # respawn, 5s delay — fail-safe supervision
|
||||
procd_set_param stdout 1; procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/bin/sh
|
||||
# daemon.sh - busrouter control loop.
|
||||
# Sources all libs and runs the scoring/balancing/telemetry cycle.
|
||||
# Helper functions are defined in global scope so tests can load this file
|
||||
# without starting the loop. The loop only runs via busrouter_main().
|
||||
|
||||
CONFIG=/etc/busrouter/busrouter.conf
|
||||
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
|
||||
. "${LIB_DIR}/lib-decide.sh"
|
||||
. "${LIB_DIR}/lib-score.sh"
|
||||
. "${LIB_DIR}/metrics-net.sh"
|
||||
. "${LIB_DIR}/speedtest.sh"
|
||||
. "${LIB_DIR}/modem-sim.sh"
|
||||
. "${LIB_DIR}/starlink.sh"
|
||||
. "${LIB_DIR}/wan-mwan.sh"
|
||||
. "${LIB_DIR}/telemetry.sh"
|
||||
|
||||
: "${INTERVAL:=30}"
|
||||
: "${CELL_IFACE:=rmnet_mhi0}"
|
||||
: "${ETH_IFACE:=eth0}"
|
||||
: "${CELL_MBR:=modem_0001}"
|
||||
: "${ETH_MBR:=wan}"
|
||||
: "${STATE_DIR:=/tmp/busrouter}"
|
||||
: "${SCORE_SAMPLES:=3}"
|
||||
|
||||
# Update score history for one WAN member (keeps last SCORE_SAMPLES scores).
|
||||
# Prints current history (space-separated) for use with rolling_avg.
|
||||
_score_history_update() {
|
||||
local key="$1" new_score="$2"
|
||||
local hist_file="${STATE_DIR}/score_history_${key}"
|
||||
local hist
|
||||
hist=$(cat "$hist_file" 2>/dev/null || echo "50 50 50")
|
||||
local new_hist
|
||||
new_hist=$(echo "$hist $new_score" | awk -v n="${SCORE_SAMPLES:-3}" '{
|
||||
start = (NF > n) ? NF - n + 1 : 1
|
||||
for (i = start; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : "\n")
|
||||
}')
|
||||
printf '%s\n' "$new_hist" > "$hist_file"
|
||||
echo "$new_hist"
|
||||
}
|
||||
|
||||
# Collect net+speed metrics for one WAN and write state files.
|
||||
# Usage: _collect_wan <linux_iface> <mwan3_mbr> [rsrp_dBm]
|
||||
# Writes: STATE_DIR/metric_<mbr>, STATE_DIR/score_<mbr>
|
||||
# Prints rolling-average score on stdout.
|
||||
_collect_wan() {
|
||||
local iface="$1" mbr="$2" rsrp="${3:-}"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Net probe: lat_ms jitter_ms loss_pct (fail-open: use worst-case)
|
||||
local net_out lat jitter loss
|
||||
net_out=$(net_probe_iface "$iface" 2>/dev/null) || net_out="0 0 100"
|
||||
lat=$(echo "$net_out" | awk '{print $1}'); : "${lat:=0}"
|
||||
jitter=$(echo "$net_out" | awk '{print $2}'); : "${jitter:=0}"
|
||||
loss=$(echo "$net_out" | awk '{print $3}'); : "${loss:=100}"
|
||||
|
||||
# Speed: use cached value unless a new test is due
|
||||
local dl ul speed_out
|
||||
if speed_test_due "$iface" 2>/dev/null; then
|
||||
speed_out=$(speed_test_iface "$iface" 2>/dev/null) || speed_out="0 0"
|
||||
dl=$(echo "$speed_out" | awk '{print $1}'); : "${dl:=0}"
|
||||
ul=$(echo "$speed_out" | awk '{print $2}'); : "${ul:=0}"
|
||||
printf '%s %s\n' "$dl" "$ul" > "${STATE_DIR}/speed_cache_${iface}"
|
||||
else
|
||||
speed_out=$(cat "${STATE_DIR}/speed_cache_${iface}" 2>/dev/null || echo "0 0")
|
||||
dl=$(echo "$speed_out" | awk '{print $1}'); : "${dl:=0}"
|
||||
ul=$(echo "$speed_out" | awk '{print $2}'); : "${ul:=0}"
|
||||
fi
|
||||
|
||||
# Score composite + rolling average
|
||||
local score hist avg
|
||||
if [ -n "$rsrp" ]; then
|
||||
score=$(score_composite "$lat" "$dl" "$ul" "$jitter" "$loss" "$rsrp" 2>/dev/null) || score=0
|
||||
printf '%s %s %s %s %s %s\n' "$lat" "$dl" "$ul" "$jitter" "$loss" "$rsrp" > "${STATE_DIR}/metric_${mbr}"
|
||||
else
|
||||
score=$(score_composite "$lat" "$dl" "$ul" "$jitter" "$loss" 2>/dev/null) || score=0
|
||||
printf '%s %s %s %s %s\n' "$lat" "$dl" "$ul" "$jitter" "$loss" > "${STATE_DIR}/metric_${mbr}"
|
||||
fi
|
||||
: "${score:=0}"
|
||||
hist=$(_score_history_update "$mbr" "$score")
|
||||
avg=$(rolling_avg $hist 2>/dev/null) || avg=50
|
||||
printf '%d\n' "${avg:-50}" > "${STATE_DIR}/score_${mbr}"
|
||||
|
||||
logger -t busrouter "metrics[$iface]: lat=${lat}ms dl=${dl} ul=${ul} jitter=${jitter}ms loss=${loss}% rsrp=${rsrp:-N/A} score=${score} avg=${avg}"
|
||||
echo "${avg:-50}"
|
||||
}
|
||||
|
||||
# One control loop iteration. Exported as a function for bench testing.
|
||||
busrouter_cycle() {
|
||||
logger -t busrouter -p daemon.debug "cycle start: $(date +%s)"
|
||||
|
||||
# Cellular signal (fail-open: rsrp stays empty)
|
||||
local cell_rsrp sig_out
|
||||
cell_rsrp=""
|
||||
sig_out=$(modem_signal 2>/dev/null) && {
|
||||
cell_rsrp=$(echo "$sig_out" | awk '{print $1}')
|
||||
} || logger -t busrouter "daemon: modem_signal unavailable (continuing)"
|
||||
|
||||
# Per-WAN metrics + rolling scores
|
||||
local cell_avg eth_avg
|
||||
cell_avg=$(_collect_wan "$CELL_IFACE" "$CELL_MBR" "$cell_rsrp" 2>/dev/null) || cell_avg=50
|
||||
eth_avg=$(_collect_wan "$ETH_IFACE" "$ETH_MBR" 2>/dev/null) || eth_avg=50
|
||||
|
||||
# Starlink status
|
||||
local sl_out
|
||||
sl_out=$(starlink_status 2>/dev/null) && {
|
||||
printf '%s\n' "$sl_out" > "${STATE_DIR}/starlink_last"
|
||||
} || logger -t busrouter -p daemon.debug "daemon: starlink_status unavailable"
|
||||
|
||||
# Weight decision (mwan_set_weight has internal hysteresis guard)
|
||||
local new_cell_w new_eth_w
|
||||
new_cell_w=$(wan_weight "$cell_avg" "$eth_avg" 2>/dev/null) || new_cell_w=50
|
||||
new_eth_w=$(wan_weight "$eth_avg" "$cell_avg" 2>/dev/null) || new_eth_w=50
|
||||
mwan_set_weight "$CELL_MBR" "$new_cell_w" 2>/dev/null || true
|
||||
mwan_set_weight "$ETH_MBR" "$new_eth_w" 2>/dev/null || true
|
||||
|
||||
# Dead-gateway check (cellular only; Starlink manages its own uplink)
|
||||
if ! gw_probe_iface "$CELL_IFACE" 2>/dev/null; then
|
||||
logger -t busrouter "daemon: cellular gateway dead, cycling $CELL_MBR"
|
||||
ifdown "$CELL_MBR" 2>/dev/null; ifup "$CELL_MBR" 2>/dev/null
|
||||
fi
|
||||
|
||||
# SIM auto-switch
|
||||
[ -n "$cell_rsrp" ] && sim_check_and_switch "$cell_rsrp" 2>/dev/null || true
|
||||
|
||||
# GPS
|
||||
local gps_out
|
||||
gps_out=$(gps_fix 2>/dev/null) && {
|
||||
printf '%s\n' "$gps_out" > "${STATE_DIR}/gps_last"
|
||||
} || true
|
||||
|
||||
# SIM slot (for telemetry)
|
||||
local sim_slot
|
||||
sim_slot=$(modem_sim_slot 2>/dev/null) && {
|
||||
printf '%s\n' "$sim_slot" > "${STATE_DIR}/sim_slot"
|
||||
} || true
|
||||
|
||||
# Telemetry
|
||||
telemetry_send 2>/dev/null || true
|
||||
|
||||
logger -t busrouter -p daemon.debug "cycle done"
|
||||
}
|
||||
|
||||
# Main loop — runs only when this file is executed directly (not sourced).
|
||||
busrouter_main() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
local ver
|
||||
ver=$(cat /etc/busrouter/version 2>/dev/null || echo "0.2.0")
|
||||
logger -t busrouter "daemon started (v${ver})"
|
||||
while :; do
|
||||
busrouter_cycle
|
||||
sleep "${INTERVAL:-30}"
|
||||
done
|
||||
}
|
||||
|
||||
# Guard: only start loop when executed directly, not when sourced for testing.
|
||||
case "${0##*/}" in daemon.sh) busrouter_main ;; esac
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# lib-decide.sh — PURE hysteresis + weight-formula decision helpers.
|
||||
# No side effects: args in, stdout/exit-code out. POSIX/ash-safe.
|
||||
|
||||
# Weight for WAN A given scoreA scoreB:
|
||||
# 50 + 2*(a-b), rounded to nearest 10, clamped to 20..80.
|
||||
# e.g. wan_weight 70 70 -> 50 ; wan_weight 80 60 -> 90 -> clamp 80.
|
||||
wan_weight() {
|
||||
awk -v a="$1" -v b="$2" 'BEGIN{
|
||||
w = 50 + (a-b)*2
|
||||
w = int((w+5)/10)*10 # round to nearest 10 (works for w>=-5; clamp handles low end)
|
||||
if (w < 20) w = 20
|
||||
if (w > 80) w = 80
|
||||
print w }'
|
||||
}
|
||||
|
||||
# Exit 0 (changed) if |target-current| >= 10, else exit 1 (within hysteresis band).
|
||||
weight_changed() {
|
||||
[ "$(( $2 > $1 ? $2 - $1 : $1 - $2 ))" -ge 10 ]
|
||||
}
|
||||
|
||||
# Exit 0 if bad_cycles >= threshold (time to switch SIM), else exit 1.
|
||||
sim_should_switch() {
|
||||
[ "$1" -ge "$2" ]
|
||||
}
|
||||
|
||||
# Exit 0 (gateway alive) if at least 2 of 3 probes pass (each arg: 1=pass 0=fail).
|
||||
gw_verdict() {
|
||||
[ "$(( $1 + $2 + $3 ))" -ge 2 ]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# lib-score.sh — PURE per-metric + composite WAN scoring (ported from aiwanbal §2.3).
|
||||
# No side effects: args in, single integer 0..100 on stdout.
|
||||
# POSIX/ash-safe; integer/float math delegated to awk (available on OpenWrt).
|
||||
#
|
||||
# Scoring anchors (spec §2.3):
|
||||
# latency 5ms -> 100 , 200ms -> 0
|
||||
# download 25 -> 100 , 0.5 -> 0 (MB/s)
|
||||
# upload 12.5 -> 100 , 0.5 -> 0 (MB/s)
|
||||
# jitter 1ms -> 100 , 50ms -> 0
|
||||
# loss 0% -> 100 , 40% -> 0
|
||||
# signal -65 -> 100 , -95 -> 0 (dBm)
|
||||
|
||||
# clamp a numeric value to 0..100 and round half-up to an integer
|
||||
_clamp() {
|
||||
awk -v v="$1" 'BEGIN{ if(v<0)v=0; if(v>100)v=100; printf "%d", (v+0.5) }'
|
||||
}
|
||||
|
||||
# read one value from stdin and clamp/round it (pipe helper)
|
||||
_pipeclamp() { read v; _clamp "$v"; }
|
||||
|
||||
score_latency() { awk -v x="$1" 'BEGIN{ print 100*(200-x)/(200-5) }' | _pipeclamp; }
|
||||
score_download() { awk -v x="$1" 'BEGIN{ print 100*(x-0.5)/(25-0.5) }' | _pipeclamp; }
|
||||
score_upload() { awk -v x="$1" 'BEGIN{ print 100*(x-0.5)/(12.5-0.5) }' | _pipeclamp; }
|
||||
score_jitter() { awk -v x="$1" 'BEGIN{ print 100*(50-x)/(50-1) }' | _pipeclamp; }
|
||||
score_loss() { awk -v x="$1" 'BEGIN{ print 100*(40-x)/40 }' | _pipeclamp; }
|
||||
score_signal() { awk -v x="$1" 'BEGIN{ print 100*(x-(-95))/((-65)-(-95)) }' | _pipeclamp; }
|
||||
|
||||
# Weighted 6-factor composite score.
|
||||
# args: lat dl up jitter loss signal(optional)
|
||||
# weights: latency35 dl25 up10 jit10 loss10 sig10.
|
||||
# When signal is empty (no modem), its 10 points redistribute to latency(+5)
|
||||
# and download(+5) so the remaining weights still total 100.
|
||||
score_composite() {
|
||||
sl=$(score_latency "$1"); sd=$(score_download "$2"); su=$(score_upload "$3")
|
||||
sj=$(score_jitter "$4"); sp=$(score_loss "$5")
|
||||
if [ -n "$6" ]; then
|
||||
ss=$(score_signal "$6"); wsig=10; wlat=35; wdl=25
|
||||
else
|
||||
ss=0; wsig=0; wlat=40; wdl=30 # redistribute signal weight
|
||||
fi
|
||||
awk -v sl="$sl" -v sd="$sd" -v su="$su" -v sj="$sj" -v sp="$sp" -v ss="$ss" \
|
||||
-v wlat="$wlat" -v wdl="$wdl" -v wsig="$wsig" 'BEGIN{
|
||||
t=(sl*wlat + sd*wdl + su*10 + sj*10 + sp*10 + ss*wsig)/100
|
||||
printf "%d",(t+0.5) }'
|
||||
}
|
||||
|
||||
# Rolling average of N samples (integer, round half-up). Args: sample...
|
||||
rolling_avg() {
|
||||
awk 'BEGIN{ n=ARGC-1; s=0; for(i=1;i<ARGC;i++) s+=ARGV[i]; printf "%d",(s/n+0.5) }' "$@"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# metrics-net.sh - per-WAN latency/jitter/loss probes for busrouter.
|
||||
#
|
||||
# Public API:
|
||||
# net_probe_iface <iface> 10-ping probe; echo "lat jitter loss"
|
||||
# _net_parse_ping pure parser: ping stdout -> "lat jitter loss" (testable)
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
|
||||
# Parse BusyBox ping stdout and emit "lat jitter loss".
|
||||
# lat = avg RTT across received packets (ms, 1 decimal)
|
||||
# jitter = population stddev of per-sample RTTs (ms, 1 decimal)
|
||||
# loss = packet loss percent (integer); defaults to 100 if no stats line seen
|
||||
# BusyBox ping summary has no mdev; jitter is computed from individual time= lines.
|
||||
# Emits "0 0 100" when no replies received.
|
||||
_net_parse_ping() {
|
||||
awk '
|
||||
BEGIN { loss = 100 }
|
||||
/time=/ {
|
||||
split($0, a, "time="); split(a[2], b, " "); t = b[1]+0
|
||||
sum += t; sum2 += t*t; cnt++
|
||||
}
|
||||
/% packet loss/ {
|
||||
for (i=1; i<=NF; i++) if ($i ~ /%/) { loss = $i+0; break }
|
||||
}
|
||||
END {
|
||||
if (cnt > 0) {
|
||||
avg = sum / cnt
|
||||
var = sum2/cnt - avg*avg
|
||||
if (var < 0) var = 0
|
||||
printf "%.1f %.1f %d\n", avg, sqrt(var), loss
|
||||
} else {
|
||||
print "0 0 100"
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# Run a 10-ping probe bound to an interface and emit "lat jitter loss".
|
||||
# Usage: net_probe_iface <iface>
|
||||
# <iface>: real interface name (rmnet_mhi0 or eth0)
|
||||
# -W 3 (timeout per reply) verified on GL-XE3000 BusyBox ping.
|
||||
net_probe_iface() {
|
||||
local iface="$1"
|
||||
[ -n "$iface" ] || { logger -t busrouter "net_probe_iface: missing iface arg"; return 1; }
|
||||
ping -I "$iface" -c 10 -W 3 1.1.1.1 2>/dev/null | _net_parse_ping
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/bin/sh
|
||||
# modem-sim.sh - modem signal read + SIM slot management.
|
||||
#
|
||||
# Config:
|
||||
# MODEM_BUS gl_modem -B arg (default: 1-1.2)
|
||||
# MODEM_AT_CMD AT command binary (default: gl_modem)
|
||||
#
|
||||
# Public API:
|
||||
# modem_signal echo 'rsrp rsrq sinr'; exit 1 if modem absent
|
||||
# modem_sim_slot echo active SIM slot number (1 or 2)
|
||||
# modem_sim_switch N switch to SIM slot N and reconnect
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${MODEM_BUS:=1-1.2}"
|
||||
: "${MODEM_AT_CMD:=gl_modem}"
|
||||
|
||||
# Task 4.2 SIM switch config
|
||||
: "${SIM_SWITCH_RSRP:=-110}" # dBm threshold for bad-signal detection
|
||||
: "${SIM_SWITCH_CYCLES:=3}" # consecutive bad cycles before switch
|
||||
: "${SIM_SWITCH_COOLDOWN:=300}" # seconds between allowed switches
|
||||
: "${SIM_STATE_DIR:=/tmp/busrouter}"
|
||||
|
||||
# Source decision helpers (provides sim_should_switch).
|
||||
if [ -f "${LIB_DIR}/lib-decide.sh" ]; then
|
||||
. "${LIB_DIR}/lib-decide.sh"
|
||||
fi
|
||||
# Inline fallback: define sim_should_switch if not provided by lib.
|
||||
if ! command -v sim_should_switch >/dev/null 2>&1; then
|
||||
sim_should_switch() { [ "$1" -ge "$2" ]; }
|
||||
fi
|
||||
|
||||
# Parse ubus JSON signal output -> 'rsrp rsrq sinr'.
|
||||
# Input: JSON from 'ubus call modem.signal get_signals {"time":1}'
|
||||
# Extracts value after each key, handling multiple keys on the same line.
|
||||
_modem_parse_ubus() {
|
||||
awk '
|
||||
BEGIN { rsrp=""; rsrq=""; sinr="" }
|
||||
{
|
||||
if (match($0, /"rsrp": *-?[0-9]+/)) {
|
||||
t = substr($0, RSTART, RLENGTH)
|
||||
match(t, /-?[0-9]+/); rsrp = substr(t, RSTART, RLENGTH)
|
||||
}
|
||||
if (match($0, /"rsrq": *-?[0-9]+/)) {
|
||||
t = substr($0, RSTART, RLENGTH)
|
||||
match(t, /-?[0-9]+/); rsrq = substr(t, RSTART, RLENGTH)
|
||||
}
|
||||
if (match($0, /"sinr": *-?[0-9]+/)) {
|
||||
t = substr($0, RSTART, RLENGTH)
|
||||
match(t, /-?[0-9]+/); sinr = substr(t, RSTART, RLENGTH)
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (rsrp != "") printf "%s %s %s\n", rsrp, rsrq, sinr
|
||||
else exit 1
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# Parse AT+QCSQ response line -> 'rsrp rsrq sinr'.
|
||||
# Format: +QCSQ: "<mode>",<rsrp>,<sinr>,<rsrq> (Quectel RM520N NR5G)
|
||||
_modem_parse_at() {
|
||||
awk -F',' '
|
||||
BEGIN { ok=0 }
|
||||
/\+QCSQ:/ { rsrp=$2+0; sinr=$3+0; rsrq=$4+0; ok=1 }
|
||||
END {
|
||||
if (ok) printf "%s %s %s\n", rsrp, rsrq, sinr
|
||||
else exit 1
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# Read signal metrics from modem.
|
||||
# Primary: ubus call modem.signal get_signals; fallback: AT+QCSQ.
|
||||
# Emits 'rsrp rsrq sinr' (dBm / dB integers) on success; exit 1 on failure.
|
||||
modem_signal() {
|
||||
local result
|
||||
result=$(ubus call modem.signal get_signals '{"time":1}' 2>/dev/null | _modem_parse_ubus)
|
||||
if [ -n "$result" ]; then
|
||||
logger -t busrouter -p daemon.debug "modem_signal[ubus]: $result"
|
||||
echo "$result"; return 0
|
||||
fi
|
||||
# AT fallback
|
||||
result=$($MODEM_AT_CMD -B "$MODEM_BUS" AT AT+QCSQ 2>/dev/null | _modem_parse_at)
|
||||
if [ -n "$result" ]; then
|
||||
logger -t busrouter -p daemon.debug "modem_signal[at]: $result"
|
||||
echo "$result"; return 0
|
||||
fi
|
||||
logger -t busrouter "modem_signal: no signal data"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Return active SIM slot number (1 or 2).
|
||||
modem_sim_slot() {
|
||||
$MODEM_AT_CMD -B "$MODEM_BUS" AT 'AT+QUIMSLOT?' 2>/dev/null | awk '/\+QUIMSLOT:/ { match($0, /[0-9]+/); print substr($0, RSTART, RLENGTH); exit }'
|
||||
}
|
||||
|
||||
# Switch to SIM slot N and trigger modem reconnect.
|
||||
modem_sim_switch() {
|
||||
local slot="$1"
|
||||
[ "$slot" = "1" ] || [ "$slot" = "2" ] || { logger -t busrouter "modem_sim_switch: invalid slot '$slot'"; return 1; }
|
||||
logger -t busrouter "modem_sim_switch: switching to slot $slot"
|
||||
$MODEM_AT_CMD -B "$MODEM_BUS" AT "AT+QUIMSLOT=$slot" 2>/dev/null | grep -q "^OK" || {
|
||||
logger -t busrouter "modem_sim_switch: AT+QUIMSLOT=$slot failed; not cycling interface"
|
||||
return 1
|
||||
}
|
||||
ifdown modem_0001 2>/dev/null; ifup modem_0001 2>/dev/null
|
||||
}
|
||||
|
||||
# Evaluate current signal and switch SIM if persistently weak.
|
||||
# Usage: sim_check_and_switch <rsrp_dBm>
|
||||
# Returns 0 if a switch was triggered; 1 if not due; 2 if switch command failed.
|
||||
sim_check_and_switch() {
|
||||
local rsrp="$1"
|
||||
local state_bad="${SIM_STATE_DIR}/sim_bad_cycles"
|
||||
local state_last="${SIM_STATE_DIR}/sim_last_switch"
|
||||
mkdir -p "$SIM_STATE_DIR"
|
||||
|
||||
# Cooldown guard
|
||||
local now
|
||||
now=$(date +%s)
|
||||
if [ -f "$state_last" ]; then
|
||||
local last
|
||||
last=$(cat "$state_last"); last="${last%%[!0-9]*}"
|
||||
if [ "$(( now - last ))" -lt "${SIM_SWITCH_COOLDOWN:-300}" ]; then
|
||||
logger -t busrouter -p daemon.debug "sim_check: cooldown active"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update bad-cycle counter
|
||||
local bad
|
||||
bad=$(cat "$state_bad" 2>/dev/null); bad="${bad%%[!0-9]*}"; bad="${bad:-0}"
|
||||
if [ "${rsrp:-0}" -lt "${SIM_SWITCH_RSRP:--110}" ]; then
|
||||
bad=$(( bad + 1 ))
|
||||
printf '%d\n' "$bad" > "$state_bad"
|
||||
logger -t busrouter -p daemon.info "sim_check: rsrp=$rsrp below ${SIM_SWITCH_RSRP}, bad_cycles=$bad/${SIM_SWITCH_CYCLES}"
|
||||
else
|
||||
printf '0\n' > "$state_bad"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Switch if threshold reached
|
||||
sim_should_switch "$bad" "${SIM_SWITCH_CYCLES:-3}" || return 1
|
||||
|
||||
local slot new_slot
|
||||
slot=$(modem_sim_slot)
|
||||
[ "$slot" = "1" ] && new_slot=2 || new_slot=1
|
||||
logger -t busrouter "sim_check: switching from slot $slot to $new_slot (rsrp=$rsrp, cycles=$bad)"
|
||||
modem_sim_switch "$new_slot" && {
|
||||
printf '%d\n' "$now" > "$state_last"
|
||||
printf '0\n' > "$state_bad"
|
||||
} || return 2
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# speedtest.sh - speed-test engine: Ookla CLI preferred, curl fallback.
|
||||
#
|
||||
# Config (override via environment or daemon config):
|
||||
# CELL_METERED=1 skip auto-tests on cellular (default: 0)
|
||||
# CELLULAR_IFACE cellular real iface name (default: rmnet_mhi0)
|
||||
# SPEED_INTERVAL min seconds between auto-tests (default: 300)
|
||||
# SPEED_STATE_DIR state file directory (default: /tmp/busrouter)
|
||||
#
|
||||
# Public API:
|
||||
# speed_test_iface <iface> run speed test; echo "dl_mbps ul_mbps"
|
||||
# speed_test_due <iface> exit 0 if test is due, 1 if too recent
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${CELL_METERED:=0}"
|
||||
: "${CELLULAR_IFACE:=rmnet_mhi0}"
|
||||
: "${SPEED_INTERVAL:=300}"
|
||||
: "${SPEED_STATE_DIR:=/tmp/busrouter}"
|
||||
|
||||
# Ookla CLI speed test bound to interface.
|
||||
# Outputs "dl_mbps ul_mbps" on success; returns 1 if CLI unavailable or test fails.
|
||||
_speed_ookla() {
|
||||
local iface="$1"
|
||||
local bin
|
||||
bin=$(command -v speedtest 2>/dev/null) || return 1
|
||||
$bin --interface "$iface" -f json --accept-license --accept-gdpr 2>/dev/null | grep -o '"bandwidth":[0-9]*' | awk -F: 'NR==1{dl=$2} NR==2{ul=$2} END{if(dl>0) printf "%.1f %.1f\n", dl/125000, ul/125000}'
|
||||
}
|
||||
|
||||
# Curl-based throughput test bound to interface.
|
||||
# Download: fetch 10 MB from Cloudflare; upload: POST 5 MB of /dev/urandom.
|
||||
# Outputs "dl_mbps ul_mbps"; returns 1 on failure.
|
||||
_speed_curl() {
|
||||
local iface="$1" dl ul
|
||||
dl=$(curl -sf --interface "$iface" --max-time 20 -o /dev/null -w "%{speed_download}" "http://speed.cloudflare.com/__down?bytes=10485760" 2>/dev/null)
|
||||
[ -n "$dl" ] && [ "${dl%.*}" -gt 0 ] 2>/dev/null || return 1
|
||||
ul=$(dd if=/dev/urandom bs=1048576 count=5 2>/dev/null | curl -sf --interface "$iface" --max-time 20 -X POST --data-binary @- -o /dev/null -w "%{speed_upload}" "http://speed.cloudflare.com/__up" 2>/dev/null)
|
||||
: "${ul:=0}"
|
||||
echo "$dl $ul" | awk '{printf "%.1f %.1f\n", $1/125000, $2/125000}'
|
||||
}
|
||||
|
||||
# Run a speed test on interface and emit "dl_mbps ul_mbps".
|
||||
# Skips auto-test on metered cellular (CELL_METERED=1) and emits "0 0".
|
||||
speed_test_iface() {
|
||||
local iface="$1"
|
||||
[ -n "$iface" ] || { logger -t busrouter "speed_test_iface: missing iface arg"; return 1; }
|
||||
|
||||
if [ "$CELL_METERED" = "1" ] && [ "$iface" = "$CELLULAR_IFACE" ]; then
|
||||
logger -t busrouter "speed_test: skipped (CELL_METERED=1) on $iface"
|
||||
echo "0 0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local result
|
||||
result=$(_speed_ookla "$iface" 2>/dev/null)
|
||||
[ -n "$result" ] || result=$(_speed_curl "$iface")
|
||||
if [ -z "$result" ]; then
|
||||
logger -t busrouter "speed_test: FAILED for $iface"
|
||||
return 1
|
||||
fi
|
||||
logger -t busrouter "speed_test[$iface]: $result"
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
# Returns exit 0 if a speed test is due, exit 1 if tested too recently.
|
||||
# Usage: speed_test_due <iface>
|
||||
# State persisted in SPEED_STATE_DIR/speed_last_<iface>.
|
||||
speed_test_due() {
|
||||
local iface="$1"
|
||||
local state_file="${SPEED_STATE_DIR}/speed_last_${iface}"
|
||||
local now
|
||||
mkdir -p "$SPEED_STATE_DIR"
|
||||
now=$(date +%s)
|
||||
if [ ! -f "$state_file" ]; then
|
||||
echo "$now" > "$state_file"
|
||||
return 0
|
||||
fi
|
||||
local last
|
||||
last=$(cat "$state_file"); last="${last%%[!0-9]*}"
|
||||
if [ "$(( now - last ))" -ge "${SPEED_INTERVAL:-300}" ]; then
|
||||
echo "$now" > "$state_file"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# starlink.sh - Starlink dish health via gRPC.
|
||||
#
|
||||
# Config:
|
||||
# STARLINK_HOST dish gRPC address (default: 192.168.100.1:9200)
|
||||
# GRPCURL path to grpcurl binary (default: searches PATH)
|
||||
#
|
||||
# Public API:
|
||||
# starlink_status echo 'lat_ms dl_bps obstructed outage'; exit 1 if unreachable
|
||||
# starlink_online exit 0 if dish is reachable and latency > 0
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${STARLINK_HOST:=192.168.100.1:9200}"
|
||||
|
||||
# Locate grpcurl: honour explicit GRPCURL env var, then PATH.
|
||||
_grpcurl_bin() {
|
||||
if [ -n "${GRPCURL:-}" ]; then
|
||||
[ -x "$GRPCURL" ] && { echo "$GRPCURL"; return 0; }
|
||||
return 1
|
||||
fi
|
||||
command -v grpcurl 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# Parse grpcurl JSON output -> 'lat_ms dl_bps obstructed outage'.
|
||||
# Handles camelCase field names returned by Starlink gRPC reflection.
|
||||
_starlink_parse() {
|
||||
awk '
|
||||
BEGIN { lat=0; dl=0; obs=0; outage=0; in_outage=0; frac=0 }
|
||||
/popPingLatencyMs/ { match($0, /[0-9]+\.?[0-9]*/); lat = substr($0, RSTART, RLENGTH)+0 }
|
||||
/downlinkThroughputBps/ { match($0, /[0-9]+\.?[0-9]*/); dl = substr($0, RSTART, RLENGTH)+0 }
|
||||
/currentlyObstructed.*true/ { obs = 1 }
|
||||
/fractionObstructed/ { match($0, /[0-9]+\.?[0-9]*/); frac = substr($0, RSTART, RLENGTH)+0; if (frac > 0.01) obs = 1 }
|
||||
/"outage".*\{/ { in_outage = 1 }
|
||||
in_outage && /"cause".*:.*"[A-Z]/ { outage = 1 }
|
||||
END { printf "%d %.0f %d %d\n", lat, dl, obs+0, outage+0 }
|
||||
'
|
||||
}
|
||||
|
||||
# Run the gRPC status call and emit 'lat_ms dl_bps obstructed outage'.
|
||||
# Returns exit 1 if grpcurl is absent or the dish is unreachable.
|
||||
starlink_status() {
|
||||
local bin
|
||||
bin=$(_grpcurl_bin) || { logger -t busrouter "starlink: grpcurl not found"; return 1; }
|
||||
local json
|
||||
json=$("$bin" -plaintext -d '{"getStatus":{}}' "$STARLINK_HOST" SpaceX.API.Device.Device/Handle 2>/dev/null)
|
||||
[ -n "$json" ] || { logger -t busrouter "starlink: no response from $STARLINK_HOST"; return 1; }
|
||||
local result
|
||||
result=$(echo "$json" | _starlink_parse)
|
||||
logger -t busrouter -p daemon.debug "starlink_status: $result"
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
# Exit 0 if the dish is reachable and reporting non-zero latency.
|
||||
starlink_online() {
|
||||
local lat
|
||||
lat=$(starlink_status 2>/dev/null | awk '{print $1}') || return 1
|
||||
[ "${lat:-0}" -gt 0 ] 2>/dev/null
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/bin/sh
|
||||
# telemetry-synology.sh — Synology SRM telemetry payload builder for fleet hub.
|
||||
#
|
||||
# Reads aiwanbal state files from /tmp/aiwanbal/ and optionally queries the
|
||||
# Eyeride Eyenet (OpenWrt ubus) for GPS position. Posts JSON to the fleet hub
|
||||
# over Tailscale with disk-buffered retry on failure.
|
||||
#
|
||||
# Config (environment variables):
|
||||
# TELEMETRY_HUB POST endpoint (default: http://10.88.0.1:8080/api/telemetry)
|
||||
# TELEMETRY_BUFFER_DIR disk buffer for failed POSTs (default: /tmp/busrouter/telemetry-buf)
|
||||
# AIWANBAL_STATE_DIR aiwanbal runtime state (default: /tmp/aiwanbal)
|
||||
# EYERIDE_IP Eyeride OpenWrt IP for GPS (default: 192.168.10.1)
|
||||
# EYERIDE_PORT Eyeride ubus port (default: 8080)
|
||||
# EYERIDE_USER Eyeride SSH/ubus username (default: root)
|
||||
# EYERIDE_PASSWORD Eyeride password (required for GPS)
|
||||
#
|
||||
# Public API:
|
||||
# eyeride_gps query GPS from Eyeride ubus; echo 'lat lon fix';
|
||||
# exit 1 if unavailable
|
||||
# telemetry_synology_collect emit JSON payload to stdout from aiwanbal state files
|
||||
# telemetry_synology_flush retry all buffered payloads; return 1 if hub still down
|
||||
# telemetry_synology_send collect + flush + post; buffer on failure
|
||||
#
|
||||
# Cron mode: runs telemetry_synology_send when executed directly.
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}"
|
||||
: "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}"
|
||||
: "${AIWANBAL_STATE_DIR:=/tmp/aiwanbal}"
|
||||
: "${EYERIDE_IP:=192.168.10.1}"
|
||||
: "${EYERIDE_PORT:=8080}"
|
||||
: "${EYERIDE_USER:=root}"
|
||||
_TELEM_SEQ=0
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Escape a string value for JSON (backslash and double-quote only).
|
||||
_json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
|
||||
|
||||
# Read an aiwanbal state file, return empty string if missing.
|
||||
_aiwanbal_val() {
|
||||
local key="$1"
|
||||
cat "${AIWANBAL_STATE_DIR}/${key}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Eyeride GPS (ubus JSON-RPC) ─────────────────────────────────────────────────
|
||||
#
|
||||
# The Eyeride Eyenet is an OpenWrt device at 192.168.10.1:8080 with ubus
|
||||
# JSON-RPC. GPS data may be available through:
|
||||
# 1. ubus call gps status (if gps service is running)
|
||||
# 2. ubus call location status (alternative service name)
|
||||
# 3. /tmp/gps/last_fix on the Eyeride (mounted via NFS/SMB or scraped)
|
||||
#
|
||||
# This function tries option 1 first, then falls back gracefully.
|
||||
|
||||
# Authenticate to Eyeride ubus, return session token on stdout.
|
||||
_eyeride_login() {
|
||||
local _pass="${EYERIDE_PASSWORD:-}"
|
||||
[ -n "$_pass" ] || return 1
|
||||
|
||||
local _login_body _login_resp _session
|
||||
_login_body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"00000000000000000000000000000000\",\"session\",\"login\",{\"username\":\"${EYERIDE_USER}\",\"password\":\"${_pass}\"}]}"
|
||||
_login_resp=$(curl -s --connect-timeout 5 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$_login_body" \
|
||||
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null) || return 1
|
||||
|
||||
# Check for auth failure: ubus returns [6] for permission denied
|
||||
if [ -z "$_login_resp" ] || echo "$_login_resp" | grep -q '"result":\[6\]'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
_session=$(echo "$_login_resp" | sed 's/.*"ubus_rpc_session":"\([^"]*\)".*/\1/')
|
||||
[ -n "$_session" ] && [ "$_session" != "$_login_resp" ] || return 1
|
||||
printf '%s\n' "$_session"
|
||||
}
|
||||
|
||||
# Call a ubus method on the Eyeride with an active session token.
|
||||
# Usage: _eyeride_ubus_call <session> <service> <method> [params_json]
|
||||
_eyeride_ubus_call() {
|
||||
local _session="$1" _svc="$2" _method="$3" _params="${4:-{}}"
|
||||
local _body
|
||||
_body="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"call\",\"params\":[\"${_session}\",\"${_svc}\",\"${_method}\",${_params}]}"
|
||||
curl -s --connect-timeout 5 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$_body" \
|
||||
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null
|
||||
}
|
||||
|
||||
# Query GPS position from Eyeride.
|
||||
# Emits 'lat lon fix_quality' on success; exit 1 on failure or no fix.
|
||||
eyeride_gps() {
|
||||
local _session _resp _lat _lon _fix
|
||||
|
||||
_session=$(_eyeride_login) || return 1
|
||||
|
||||
# Try 'gps' service first (common OpenWrt gpsd ubus binding)
|
||||
_resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null
|
||||
if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then
|
||||
# gps service not found — try 'location' service
|
||||
_resp=$(_eyeride_ubus_call "$_session" "location" "status") 2>/dev/null
|
||||
fi
|
||||
|
||||
# Try to extract lat/lon from ubus response JSON
|
||||
_lat=$(echo "$_resp" | sed 's/.*"latitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
_lon=$(echo "$_resp" | sed 's/.*"longitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
# Also try snake_case variants
|
||||
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
[ "$_lon" = "$_resp" ] && _lon=$(echo "$_resp" | sed 's/.*"lon":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
# Alt spelling
|
||||
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat_deg":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
[ "$_lon" = "$_resp" ] && _lon=$(echo "$_resp" | sed 's/.*"lon_deg":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
|
||||
# Sanitize: if sed didn't match, the field equals the full response
|
||||
[ "$_lat" = "$_resp" ] && _lat=""
|
||||
[ "$_lon" = "$_resp" ] && _lon=""
|
||||
|
||||
if [ -n "$_lat" ] && [ -n "$_lon" ]; then
|
||||
_fix=2 # assume 2D fix if we got coordinates
|
||||
[ -n "$_fix" ] || _fix=2
|
||||
logger -t busrouter -p daemon.debug "eyeride_gps: ${_lat} ${_lon} fix=${_fix}"
|
||||
printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
|
||||
return 0
|
||||
fi
|
||||
|
||||
logger -t busrouter "eyeride_gps: no fix from Eyeride"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Telemetry Collection ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# State files read from AIWANBAL_STATE_DIR (written by aiwanbal daemon each cycle):
|
||||
# wan1_score_avg, wan2_score_avg integer 0–100
|
||||
# wan1_latency, wan2_latency ms
|
||||
# wan1_throughput, wan2_throughput B/s (converted to Mbps below)
|
||||
# wan1_upload, wan2_upload B/s
|
||||
# wan1_jitter, wan2_jitter ms
|
||||
# wan1_packet_loss, wan2_packet_loss %
|
||||
# wan1_rsrp, wan2_rsrp dBm (cellular signal)
|
||||
# wan1_rsrq, wan2_rsrq dB
|
||||
# wan1_sinr, wan2_sinr dB
|
||||
# wan1_technology, wan2_technology e.g. "5G-NR", "LTE"
|
||||
# wan1_modem_type, wan2_modem_type e.g. "eyeride", "peplink", "none"
|
||||
# wan0_mode "active" or "failover"
|
||||
# wan0_starlink_quality quality score (if Starlink connected)
|
||||
# wan0_starlink_latency ms
|
||||
# wan0_starlink_obstruction bool-ish
|
||||
# wan0_starlink_outage bool-ish
|
||||
|
||||
# Read one WAN's metrics and emit a JSON object fragment.
|
||||
# Usage: _synology_wan_json <wan_num>
|
||||
_synology_wan_json() {
|
||||
local n="$1"
|
||||
local score lat dl ul jitter loss rsrp rsrq sinr tech carrier modem
|
||||
|
||||
score=$(_aiwanbal_val "wan${n}_score_avg")
|
||||
lat=$(_aiwanbal_val "wan${n}_latency")
|
||||
local throughput_bytes
|
||||
throughput_bytes=$(_aiwanbal_val "wan${n}_throughput")
|
||||
dl=""
|
||||
[ -n "$throughput_bytes" ] && dl=$(awk "BEGIN { printf \"%.1f\", ${throughput_bytes} / 125000 }" 2>/dev/null)
|
||||
[ -z "$dl" ] && dl="0"
|
||||
local upload_bytes
|
||||
upload_bytes=$(_aiwanbal_val "wan${n}_upload")
|
||||
ul=""
|
||||
[ -n "$upload_bytes" ] && ul=$(awk "BEGIN { printf \"%.1f\", ${upload_bytes} / 125000 }" 2>/dev/null)
|
||||
[ -z "$ul" ] && ul="0"
|
||||
jitter=$(_aiwanbal_val "wan${n}_jitter")
|
||||
loss=$(_aiwanbal_val "wan${n}_packet_loss")
|
||||
rsrp=$(_aiwanbal_val "wan${n}_rsrp")
|
||||
rsrq=$(_aiwanbal_val "wan${n}_rsrq")
|
||||
sinr=$(_aiwanbal_val "wan${n}_sinr")
|
||||
tech=$(_aiwanbal_val "wan${n}_technology")
|
||||
modem=$(_aiwanbal_val "wan${n}_modem_type")
|
||||
|
||||
# Carrier is not tracked separately by aiwanbal — derive from modem type
|
||||
carrier=""
|
||||
case "$modem" in
|
||||
eyeride) carrier="Eyeride" ;;
|
||||
peplink) carrier="Peplink" ;;
|
||||
zte) carrier="ZTE" ;;
|
||||
mofi) carrier="Mofi" ;;
|
||||
none|generic|"") carrier="" ;;
|
||||
*) carrier="$modem" ;;
|
||||
esac
|
||||
|
||||
local esc_tech esc_carrier
|
||||
esc_tech=$(_json_str "${tech:-unknown}")
|
||||
esc_carrier=$(_json_str "${carrier:-unknown}")
|
||||
|
||||
printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s,"rsrp_dbm":%s,"rsrq_db":%s,"sinr_db":%s,"technology":"%s","carrier":"%s"}' \
|
||||
"${score:-0}" \
|
||||
"${lat:-null}" \
|
||||
"${dl:-0}" \
|
||||
"${ul:-0}" \
|
||||
"${jitter:-null}" \
|
||||
"${loss:-null}" \
|
||||
"${rsrp:-null}" \
|
||||
"${rsrq:-null}" \
|
||||
"${sinr:-null}" \
|
||||
"${esc_tech}" \
|
||||
"${esc_carrier}"
|
||||
}
|
||||
|
||||
# Build the full telemetry JSON payload from aiwanbal state files.
|
||||
# Emits JSON on stdout; never fails — uses defaults for all missing fields.
|
||||
telemetry_synology_collect() {
|
||||
local now device_id uptime_s version
|
||||
now=$(date +%s)
|
||||
device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname)
|
||||
uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null)
|
||||
version=$(cat /etc/busrouter/version 2>/dev/null || echo "dev")
|
||||
|
||||
# GPS
|
||||
local gps_lat gps_lon gps_fix
|
||||
if gps_out=$(eyeride_gps 2>/dev/null); then
|
||||
read -r gps_lat gps_lon gps_fix <<-EOF
|
||||
$gps_out
|
||||
EOF
|
||||
fi
|
||||
|
||||
# WAN 1 + WAN 2
|
||||
local wan1_json wan2_json
|
||||
wan1_json=$(_synology_wan_json 1)
|
||||
wan2_json=$(_synology_wan_json 2)
|
||||
|
||||
# Active WAN — which one is currently carrying traffic
|
||||
local mode sel_primary
|
||||
mode=$(_aiwanbal_val "wan0_mode")
|
||||
case "$mode" in
|
||||
failover)
|
||||
# In failover, primary is the active one; check which is up
|
||||
local wan1_state wan2_state
|
||||
wan1_state=$(_aiwanbal_val "wan1_state")
|
||||
wan2_state=$(_aiwanbal_val "wan2_state")
|
||||
if [ "$wan2_state" = "up" ]; then
|
||||
sel_primary="wan2"
|
||||
else
|
||||
sel_primary="wan1"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Load-balance mode: both are active; wan1 is primary
|
||||
sel_primary="wan1"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Starlink (may be attached to one of the WANs)
|
||||
local sl_lat sl_dl sl_obs sl_outage
|
||||
sl_lat=$(_aiwanbal_val "wan0_starlink_latency")
|
||||
sl_dl=$(_aiwanbal_val "wan0_starlink_quality")
|
||||
sl_obs=$(_aiwanbal_val "wan0_starlink_obstruction")
|
||||
sl_outage=$(_aiwanbal_val "wan0_starlink_outage")
|
||||
|
||||
# Convert Starlink quality to bps (quality is a score 0–100, not bps)
|
||||
# Store quality as-is; the hub can interpret it
|
||||
local sl_quality
|
||||
sl_quality="${sl_dl:-0}"
|
||||
|
||||
local esc_id esc_ver esc_sel
|
||||
esc_id=$(_json_str "$device_id")
|
||||
esc_ver=$(_json_str "$version")
|
||||
esc_sel=$(_json_str "$sel_primary")
|
||||
|
||||
printf '{"device_id":"%s","timestamp":%d,"uptime":%d,"version":"%s","platform":"synology","sel_primary":"%s","gps":{"lat":%s,"lon":%s,"fix":%s},"wan":{"wan1":%s,"wan2":%s},"starlink":{"quality":%s,"latency_ms":%s,"obstructed":%s,"outage":%s}}\n' \
|
||||
"${esc_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" \
|
||||
"${gps_lat:-null}" "${gps_lon:-null}" "${gps_fix:-null}" \
|
||||
"${wan1_json}" "${wan2_json}" \
|
||||
"${sl_quality:-0}" "${sl_lat:-null}" "${sl_obs:-0}" "${sl_outage:-0}"
|
||||
}
|
||||
|
||||
# ── Hub POST + Buffering ─────────────────────────────────────────────────────────
|
||||
|
||||
# POST a JSON file to the hub. Returns 0 on HTTP 2xx, 1 on any failure.
|
||||
_telemetry_try_post() {
|
||||
local file="$1"
|
||||
local code
|
||||
code=$(curl -sf -X POST -H "Content-Type: application/json" \
|
||||
--data-binary @"$file" -o /dev/null -w "%{http_code}" \
|
||||
--max-time 10 "$TELEMETRY_HUB" 2>/dev/null)
|
||||
case "$code" in 2*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# Retry all buffered payloads in timestamp order.
|
||||
# Stops on first failure to avoid hammering a down hub.
|
||||
telemetry_synology_flush() {
|
||||
[ -d "$TELEMETRY_BUFFER_DIR" ] || return 0
|
||||
local f
|
||||
for f in "$TELEMETRY_BUFFER_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
if _telemetry_try_post "$f"; then
|
||||
rm -f "$f"
|
||||
logger -t busrouter -p daemon.debug "telemetry_synology_flush: sent $(basename "$f")"
|
||||
else
|
||||
logger -t busrouter "telemetry_synology_flush: hub unreachable, stopping"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Collect metrics, flush old buffer, POST current payload.
|
||||
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR.
|
||||
# Never blocks the caller on hub downtime.
|
||||
telemetry_synology_send() {
|
||||
local payload
|
||||
payload=$(telemetry_synology_collect)
|
||||
|
||||
telemetry_synology_flush 2>/dev/null || true
|
||||
|
||||
mkdir -p "$TELEMETRY_BUFFER_DIR"
|
||||
_TELEM_SEQ=$(( _TELEM_SEQ + 1 ))
|
||||
local tmpfile="${TELEMETRY_BUFFER_DIR}/$(date +%s)_$$_${_TELEM_SEQ}.json"
|
||||
# Cap buffer to 60 files to protect tmpfs
|
||||
local buf_count
|
||||
buf_count=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | wc -l)
|
||||
if [ "${buf_count:-0}" -ge 60 ]; then
|
||||
local oldest
|
||||
oldest=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | sort | head -n 1)
|
||||
[ -f "$oldest" ] && rm -f "$oldest"
|
||||
fi
|
||||
printf '%s\n' "$payload" > "$tmpfile"
|
||||
|
||||
if _telemetry_try_post "$tmpfile"; then
|
||||
rm -f "$tmpfile"
|
||||
logger -t busrouter -p daemon.debug "telemetry_synology_send: posted ok"
|
||||
else
|
||||
logger -t busrouter "telemetry_synology_send: hub unreachable, buffered $(basename "$tmpfile")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Entry Point ──────────────────────────────────────────────────────────────────
|
||||
# When executed directly (e.g. from cron), run a single telemetry cycle.
|
||||
case "${0##*/}" in
|
||||
telemetry-synology.sh|telemetry-synology)
|
||||
telemetry_synology_send
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/bin/sh
|
||||
# telemetry.sh - GPS read and telemetry payload builder.
|
||||
#
|
||||
# Config (GPS):
|
||||
# MODEM_BUS gl_modem -B arg (default: 1-1.2)
|
||||
# MODEM_AT_CMD AT command binary (default: gl_modem)
|
||||
#
|
||||
# Config (telemetry POST):
|
||||
# TELEMETRY_HUB POST endpoint (default: http://10.88.0.1:8080/api/telemetry)
|
||||
# TELEMETRY_INTERVAL seconds between sends when used from daemon (default: 60)
|
||||
# TELEMETRY_BUFFER_DIR disk buffer for failed POSTs (default: /tmp/busrouter/telemetry-buf)
|
||||
#
|
||||
# Public API:
|
||||
# gps_fix echo 'lat lon fix_quality'; exit 1 if no fix
|
||||
# telemetry_collect emit JSON payload to stdout from state files
|
||||
# telemetry_flush retry all buffered payloads; returns 1 if hub still down
|
||||
# telemetry_send collect + post; buffer on failure; never blocks caller
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${MODEM_BUS:=1-1.2}"
|
||||
: "${MODEM_AT_CMD:=gl_modem}"
|
||||
: "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}"
|
||||
: "${TELEMETRY_INTERVAL:=60}"
|
||||
: "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}"
|
||||
_TELEM_SEQ=0
|
||||
|
||||
# Parse AT+QGPSLOC=2 response -> 'lat lon fix_quality'.
|
||||
# Format: +QGPSLOC: <utc>,<lat>,<lon>,<hdop>,<alt>,<fix>,...
|
||||
# fix: 2=2D, 3=3D (1 or absent = no fix)
|
||||
_gps_parse() {
|
||||
awk '
|
||||
/^\+QGPSLOC:/ {
|
||||
sub(/^\+QGPSLOC: [^,]+,/, "")
|
||||
n = split($0, a, ",")
|
||||
lat = a[1]; lon = a[2]; fix = a[5]+0
|
||||
if (fix >= 2) { printf "%s %s %d\n", lat, lon, fix; found=1; exit 0 }
|
||||
}
|
||||
END { if (!found) exit 1 }
|
||||
'
|
||||
}
|
||||
|
||||
# Read GPS position from modem via AT+QGPSLOC=2.
|
||||
# Emits 'lat lon fix_quality' on success; exit 1 on no-fix or modem error.
|
||||
gps_fix() {
|
||||
local result
|
||||
result=$(timeout 5 "$MODEM_AT_CMD" -B "$MODEM_BUS" AT AT+QGPSLOC=2 2>/dev/null | _gps_parse)
|
||||
if [ -n "$result" ]; then
|
||||
logger -t busrouter -p daemon.debug "gps_fix: $result"
|
||||
echo "$result"; return 0
|
||||
fi
|
||||
logger -t busrouter "gps_fix: no fix"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Build telemetry JSON from state files written by the control loop.
|
||||
# Escape a string value for JSON (backslash and double-quote only).
|
||||
_json_str() { printf '%s' "$1" | sed 's/\/\\/g; s/"/\\"/g'; }
|
||||
|
||||
# State files (in /tmp/busrouter/):
|
||||
# gps_last "lat lon fix" (written by gps_fix caller)
|
||||
# metric_modem_0001 "lat_ms dl ul jitter loss rsrp"
|
||||
# metric_wan "lat_ms dl ul jitter loss"
|
||||
# score_modem_0001 integer 0-100
|
||||
# score_wan integer 0-100
|
||||
# starlink_last "lat_ms dl_bps obstructed outage"
|
||||
# sim_slot "1" or "2"
|
||||
telemetry_collect() {
|
||||
local now device_id uptime_s version
|
||||
now=$(date +%s)
|
||||
device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname)
|
||||
uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null)
|
||||
version=$(cat /etc/busrouter/version 2>/dev/null || echo "dev")
|
||||
|
||||
local gps_lat gps_lon gps_fix
|
||||
[ -f /tmp/busrouter/gps_last ] && read -r gps_lat gps_lon gps_fix < /tmp/busrouter/gps_last || true
|
||||
|
||||
local cell_score cell_lat cell_dl cell_ul cell_jitter cell_loss cell_rsrp
|
||||
cell_score=$(cat /tmp/busrouter/score_modem_0001 2>/dev/null)
|
||||
[ -f /tmp/busrouter/metric_modem_0001 ] && read -r cell_lat cell_dl cell_ul cell_jitter cell_loss cell_rsrp < /tmp/busrouter/metric_modem_0001 || true
|
||||
|
||||
local wan_score wan_lat wan_dl wan_ul wan_jitter wan_loss
|
||||
wan_score=$(cat /tmp/busrouter/score_wan 2>/dev/null)
|
||||
[ -f /tmp/busrouter/metric_wan ] && read -r wan_lat wan_dl wan_ul wan_jitter wan_loss < /tmp/busrouter/metric_wan || true
|
||||
|
||||
local sl_lat sl_dl sl_obs sl_outage
|
||||
[ -f /tmp/busrouter/starlink_last ] && read -r sl_lat sl_dl sl_obs sl_outage < /tmp/busrouter/starlink_last || true
|
||||
|
||||
local sim_slot
|
||||
sim_slot=$(cat /tmp/busrouter/sim_slot 2>/dev/null)
|
||||
|
||||
# sel_primary: written by the kit-balance engine to indicate the active WAN member.
|
||||
local sel_primary
|
||||
sel_primary=$(cat /tmp/busrouter/sel_primary 2>/dev/null)
|
||||
|
||||
local esc_id esc_ver esc_sel
|
||||
esc_id=$(_json_str "$device_id")
|
||||
esc_ver=$(_json_str "$version")
|
||||
esc_sel=$(_json_str "$sel_primary")
|
||||
printf '{"device_id":"%s","timestamp":%d,"uptime":%d,"version":"%s","sel_primary":"%s","gps":{"lat":%s,"lon":%s,"fix":%s},"wan":{"modem_0001":{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s,"rsrp_dbm":%s},"wan":{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}},"sim_slot":%s,"starlink":{"latency_ms":%s,"dl_bps":%s,"obstructed":%s,"outage":%s}}\n' \
|
||||
"${esc_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" \
|
||||
"${gps_lat:-null}" "${gps_lon:-null}" "${gps_fix:-null}" \
|
||||
"${cell_score:-0}" "${cell_lat:-null}" "${cell_dl:-null}" "${cell_ul:-null}" "${cell_jitter:-null}" "${cell_loss:-null}" "${cell_rsrp:-null}" \
|
||||
"${wan_score:-0}" "${wan_lat:-null}" "${wan_dl:-null}" "${wan_ul:-null}" "${wan_jitter:-null}" "${wan_loss:-null}" \
|
||||
"${sim_slot:-null}" "${sl_lat:-null}" "${sl_dl:-null}" "${sl_obs:-0}" "${sl_outage:-0}"
|
||||
}
|
||||
|
||||
# POST a JSON file to the hub. Returns 0 on HTTP 2xx, 1 on any failure.
|
||||
_telemetry_try_post() {
|
||||
local file="$1"
|
||||
local code
|
||||
code=$(curl -sf -X POST -H "Content-Type: application/json" \
|
||||
--data-binary @"$file" -o /dev/null -w "%{http_code}" \
|
||||
--max-time 10 "$TELEMETRY_HUB" 2>/dev/null)
|
||||
case "$code" in 2*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# Retry all buffered payloads in timestamp order.
|
||||
# Stops on first failure to avoid hammering a down hub.
|
||||
telemetry_flush() {
|
||||
[ -d "$TELEMETRY_BUFFER_DIR" ] || return 0
|
||||
local f
|
||||
for f in "$TELEMETRY_BUFFER_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
if _telemetry_try_post "$f"; then
|
||||
rm -f "$f"
|
||||
logger -t busrouter -p daemon.debug "telemetry_flush: sent $(basename "$f")"
|
||||
else
|
||||
logger -t busrouter "telemetry_flush: hub unreachable, stopping"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Collect metrics, flush old buffer, POST current payload.
|
||||
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR.
|
||||
# Never blocks the caller on hub downtime.
|
||||
telemetry_send() {
|
||||
local payload
|
||||
payload=$(telemetry_collect)
|
||||
|
||||
telemetry_flush 2>/dev/null || true
|
||||
|
||||
mkdir -p "$TELEMETRY_BUFFER_DIR"
|
||||
_TELEM_SEQ=$(( _TELEM_SEQ + 1 ))
|
||||
local tmpfile="${TELEMETRY_BUFFER_DIR}/$(date +%s)_$$_${_TELEM_SEQ}.json"
|
||||
# Cap buffer to 60 files to protect tmpfs
|
||||
buf_count=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | wc -l)
|
||||
if [ "${buf_count:-0}" -ge 60 ]; then
|
||||
oldest=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | sort | head -n 1)
|
||||
[ -f "$oldest" ] && rm -f "$oldest"
|
||||
fi
|
||||
printf '%s\n' "$payload" > "$tmpfile"
|
||||
|
||||
if _telemetry_try_post "$tmpfile"; then
|
||||
rm -f "$tmpfile"
|
||||
logger -t busrouter -p daemon.debug "telemetry_send: posted ok"
|
||||
else
|
||||
logger -t busrouter "telemetry_send: hub unreachable, buffered $(basename "$tmpfile")"
|
||||
fi
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/sh
|
||||
# wan-mwan.sh — mwan3 config template + runtime weight control for busrouter.
|
||||
#
|
||||
# Interfaces (from recon §1):
|
||||
# cellular -> mwan3 iface 'modem_0001' (rmnet_mhi0, T-Mobile 5G / active SIM)
|
||||
# fiber -> mwan3 iface 'wan' (eth0, Starlink / WAN2 stand-in)
|
||||
#
|
||||
# Public API:
|
||||
# mwan_apply_config write + activate busrouter mwan3 config (replaces stock)
|
||||
# mwan_set_weight <mbr> <w> update member weight if change >=10 (then mwan3 restart)
|
||||
# gw_probe_iface <iface> DNS+ping+HTTP quorum probe; exit 0=alive exit 1=dead
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
. "${LIB_DIR}/lib-decide.sh"
|
||||
|
||||
# Write the busrouter mwan3 config and restart mwan3.
|
||||
# WAN priority: wwan/repeater (metric 1) > modem_0001/cellular (metric 2) > wan/Starlink (metric 3)
|
||||
# last_resort='default' falls back to the main routing table rather than returning unreachable.
|
||||
# Idempotent — safe to call on each boot or re-provision.
|
||||
mwan_apply_config() {
|
||||
cat > /etc/config/mwan3.tmp << 'MWAN3CONF'
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option mmx_mask '0x3F00'
|
||||
|
||||
config interface 'wwan'
|
||||
option enabled '1'
|
||||
list track_ip '1.1.1.1'
|
||||
list track_ip '8.8.8.8'
|
||||
option family 'ipv4'
|
||||
option reliability '1'
|
||||
option count '1'
|
||||
option timeout '2'
|
||||
option interval '5'
|
||||
option down '3'
|
||||
option up '3'
|
||||
|
||||
config interface 'modem_0001'
|
||||
option enabled '1'
|
||||
list track_ip '1.1.1.1'
|
||||
list track_ip '8.8.8.8'
|
||||
option family 'ipv4'
|
||||
option reliability '1'
|
||||
option count '1'
|
||||
option timeout '2'
|
||||
option interval '5'
|
||||
option down '3'
|
||||
option up '3'
|
||||
|
||||
config interface 'wan'
|
||||
option enabled '1'
|
||||
list track_ip '1.1.1.1'
|
||||
list track_ip '8.8.8.8'
|
||||
option family 'ipv4'
|
||||
option reliability '1'
|
||||
option count '1'
|
||||
option timeout '2'
|
||||
option interval '5'
|
||||
option down '3'
|
||||
option up '3'
|
||||
|
||||
config member 'wwan_mbr'
|
||||
option interface 'wwan'
|
||||
option metric '1'
|
||||
option weight '100'
|
||||
|
||||
config member 'cellular'
|
||||
option interface 'modem_0001'
|
||||
option metric '2'
|
||||
option weight '100'
|
||||
|
||||
config member 'fiber'
|
||||
option interface 'wan'
|
||||
option metric '3'
|
||||
option weight '100'
|
||||
|
||||
config policy 'balanced'
|
||||
list use_member 'wwan_mbr'
|
||||
list use_member 'cellular'
|
||||
list use_member 'fiber'
|
||||
option last_resort 'default'
|
||||
|
||||
config rule 'default_rule'
|
||||
option dest_ip '0.0.0.0/0'
|
||||
option use_policy 'balanced'
|
||||
option family 'ipv4'
|
||||
MWAN3CONF
|
||||
|
||||
mv /etc/config/mwan3.tmp /etc/config/mwan3 \
|
||||
|| { logger -t busrouter "mwan3: config write FAILED"; return 1; }
|
||||
mwan3 restart \
|
||||
|| logger -t busrouter "mwan3: restart FAILED (exit $?)"
|
||||
logger -t busrouter "mwan3: config applied (wwan=repeater metric1, cellular=modem_0001 metric2, fiber=wan metric3)"
|
||||
}
|
||||
|
||||
# Update a mwan3 member weight, but only if the change exceeds the hysteresis band (>=10).
|
||||
# Usage: mwan_set_weight <member> <target_weight>
|
||||
# <member>: 'cellular' or 'fiber'
|
||||
# <target_weight>: integer 20..80 (output of wan_weight in lib-decide.sh)
|
||||
# Calls mwan3 restart only when a weight step is committed — avoids conntrack flush on
|
||||
# every scoring cycle when both WANs are stable.
|
||||
mwan_set_weight() {
|
||||
local member="$1"
|
||||
local target="$2"
|
||||
local current
|
||||
|
||||
case "$target" in
|
||||
''|*[!0-9]*) logger -t busrouter "mwan_set_weight: invalid weight '${target}'"; return 1 ;;
|
||||
esac
|
||||
|
||||
current=$(uci -q get mwan3."${member}".weight)
|
||||
if [ -z "$current" ]; then
|
||||
logger -t busrouter "mwan_set_weight: unknown member '${member}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if weight_changed "$current" "$target"; then
|
||||
uci set mwan3."${member}".weight="$target" && \
|
||||
uci commit mwan3 && \
|
||||
mwan3 restart \
|
||||
|| { logger -t busrouter "mwan3: weight update FAILED for ${member}"; return 1; }
|
||||
logger -t busrouter "mwan3: ${member} weight ${current}->${target}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run DNS / ping / HTTP probes on an interface and call gw_verdict (2-of-3 quorum).
|
||||
# Usage: gw_probe_iface <iface>
|
||||
# <iface>: real interface name (rmnet_mhi0 or eth0)
|
||||
# Returns: exit 0 (alive) if >=2 probes pass, exit 1 (dead) otherwise.
|
||||
# DNS is not interface-bound (BusyBox nslookup lacks source-bind) -- tests system resolver only;
|
||||
# ping and HTTP are the authoritative per-interface probes.
|
||||
gw_probe_iface() {
|
||||
local iface="$1"
|
||||
[ -n "$iface" ] || { logger -t busrouter "gw_probe_iface: missing iface arg"; return 1; }
|
||||
local dns_ok=0 ping_ok=0 http_ok=0
|
||||
|
||||
nslookup connectivitycheck.gstatic.com >/dev/null 2>&1 && dns_ok=1
|
||||
ping -I "$iface" -c 2 -W 2 8.8.8.8 >/dev/null 2>&1 && ping_ok=1
|
||||
curl -sf --interface "$iface" --connect-timeout 5 --max-time 8 -o /dev/null 'http://connectivitycheck.gstatic.com/generate_204' 2>/dev/null && http_ok=1
|
||||
|
||||
logger -t busrouter "gw_probe[${iface}]: dns=${dns_ok} ping=${ping_ok} http=${http_ok}"
|
||||
gw_verdict "$dns_ok" "$ping_ok" "$http_ok"
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pioneer Bus Router - Status</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0d1117; color: #e6edf3; min-height: 100vh; }
|
||||
.header { background: linear-gradient(135deg, #0057B8, #003d82);
|
||||
padding: 16px 24px; display: flex; align-items: center; gap: 14px;
|
||||
border-bottom: 2px solid #00C6FF; }
|
||||
.logo { font-size: 22px; font-weight: 700; letter-spacing: 1px; color: #fff; }
|
||||
.logo span { color: #00C6FF; }
|
||||
.sub { font-size: 12px; color: #a0c0e0; letter-spacing: 2px; text-transform: uppercase; }
|
||||
.refresh-badge { margin-left: auto; font-size: 11px; color: #a0c0e0; }
|
||||
.container { max-width: 1000px; margin: 0 auto; padding: 20px 16px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||
.card-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
||||
color: #8b949e; margin-bottom: 12px; }
|
||||
.wan-name { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.ring-wrap { position: relative; width: 64px; height: 64px; float: right;
|
||||
margin: -4px -4px 8px 12px; }
|
||||
.score-num { font-size: 18px; font-weight: 700; text-align: center;
|
||||
line-height: 64px; position: absolute; top: 0; left: 0; width: 64px; }
|
||||
.metric-row { display: flex; justify-content: space-between; font-size: 13px;
|
||||
padding: 3px 0; border-bottom: 1px solid #21262d; }
|
||||
.metric-row:last-child { border: none; }
|
||||
.metric-label { color: #8b949e; }
|
||||
.metric-val { font-weight: 600; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 700; }
|
||||
.badge-ok { background: #1a4a2e; color: #3fb950; border: 1px solid #3fb950; }
|
||||
.badge-warn { background: #3d2b00; color: #d29922; border: 1px solid #d29922; }
|
||||
.badge-bad { background: #4a1a1a; color: #f85149; border: 1px solid #f85149; }
|
||||
.weight-bar { height: 6px; background: #21262d; border-radius: 3px; margin: 8px 0; }
|
||||
.weight-fill { height: 6px; background: linear-gradient(90deg, #0057B8, #00C6FF);
|
||||
border-radius: 3px; transition: width 0.5s; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.info-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 12px; text-align: center; }
|
||||
.info-val { font-size: 20px; font-weight: 700; color: #00C6FF; margin: 4px 0; }
|
||||
.info-lbl { font-size: 11px; color: #8b949e; text-transform: uppercase; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
|
||||
font-size: 13px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-primary { background: #0057B8; color: #fff; }
|
||||
.btn-primary:hover { background: #0069d9; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-danger { background: #8b1a1a; color: #fff; border: 1px solid #f85149; }
|
||||
.section { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 16px; margin-bottom: 16px; }
|
||||
.section h3 { font-size: 12px; color: #8b949e; text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-bottom: 12px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.row label { font-size: 13px; color: #8b949e; min-width: 80px; }
|
||||
select, input[type=password] { background: #21262d; border: 1px solid #30363d;
|
||||
color: #e6edf3; padding: 5px 10px; border-radius: 5px; font-size: 13px; }
|
||||
input[type=password] { width: 140px; }
|
||||
.status-bar { font-size: 11px; color: #8b949e; text-align: center; padding: 8px;
|
||||
border-top: 1px solid #21262d; }
|
||||
.error { color: #f85149; font-size: 12px; text-align: center; padding: 12px; }
|
||||
#speedtest-out { margin-top: 8px; font-size: 13px; padding: 8px 12px;
|
||||
background: #0d1117; border-radius: 5px; display: none; color: #3fb950; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="logo">PIONEER <span>BUS ROUTER</span></div>
|
||||
<div class="sub">Keylink IT Fleet Connectivity</div>
|
||||
</div>
|
||||
<div class="refresh-badge" id="ts">Loading...</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div id="err" class="error" style="display:none"></div>
|
||||
<div class="info-grid">
|
||||
<div class="info-card"><div class="info-val" id="sim-slot">-</div><div class="info-lbl">Active SIM</div></div>
|
||||
<div class="info-card"><div class="info-val" id="gps-coord" style="font-size:13px">-</div><div class="info-lbl">GPS</div></div>
|
||||
<div class="info-card"><div class="info-val" id="uptime">-</div><div class="info-lbl">Uptime</div></div>
|
||||
<div class="info-card"><div class="info-val" id="buf">0</div><div class="info-lbl">Tel. Queued</div></div>
|
||||
<div class="info-card"><div class="info-val" id="sl-status">-</div><div style="font-size:11px;color:#8b949e;margin-top:2px" id="sl-detail"></div><div class="info-lbl">Starlink</div></div>
|
||||
<div class="info-card"><div class="info-val" id="ver">-</div><div class="info-lbl">Version</div></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">Cellular (modem_0001 / rmnet_mhi0)</div>
|
||||
<div class="ring-wrap"><canvas id="cr" width="64" height="64"></canvas><div class="score-num" id="cs">-</div></div>
|
||||
<div class="wan-name">Cellular <span id="cw-badge"></span></div>
|
||||
<div class="weight-bar"><div class="weight-fill" id="cw-bar" style="width:50%"></div></div>
|
||||
<div id="cell-m"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">WAN / Starlink (eth0)</div>
|
||||
<div class="ring-wrap"><canvas id="wr" width="64" height="64"></canvas><div class="score-num" id="ws">-</div></div>
|
||||
<div class="wan-name">Starlink <span id="ww-badge"></span></div>
|
||||
<div class="weight-bar"><div class="weight-fill" id="ww-bar" style="width:50%"></div></div>
|
||||
<div id="wan-m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Manual Speed Test</h3>
|
||||
<div class="row">
|
||||
<label>Interface</label>
|
||||
<select id="st-if"><option value="rmnet_mhi0">Cellular</option><option value="eth0">Starlink</option></select>
|
||||
<button class="btn btn-primary" id="st-btn" onclick="runSpeedTest()">Run Test</button>
|
||||
</div>
|
||||
<div id="speedtest-out"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Emergency Overrides <small style="color:#8b949e;font-weight:normal">(admin PIN required)</small></h3>
|
||||
<div class="row">
|
||||
<label>Force WAN</label>
|
||||
<select id="ov-wan"><option value="">Auto</option><option value="modem_0001">Cellular</option><option value="wan">Starlink</option></select>
|
||||
<label>Force SIM</label>
|
||||
<select id="ov-sim"><option value="">Auto</option><option value="1">SIM 1</option><option value="2">SIM 2</option></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Admin PIN</label>
|
||||
<input type="password" id="ov-pin" placeholder="PIN">
|
||||
<button class="btn btn-danger" onclick="applyOverride()">Apply</button>
|
||||
</div>
|
||||
<div id="ov-res" style="font-size:12px;margin-top:6px;color:#d29922"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-bar" id="sb">Auto-refreshes every 10s</div>
|
||||
<script>
|
||||
var col = function(s) { return s >= 70 ? '#3fb950' : s >= 40 ? '#d29922' : '#f85149'; };
|
||||
var cls = function(s) { return s >= 70 ? 'badge-ok' : s >= 40 ? 'badge-warn' : 'badge-bad'; };
|
||||
var esc = function(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); };
|
||||
|
||||
function ring(id, s) {
|
||||
var c = document.getElementById(id), ctx = c.getContext('2d'), cx = 32, cy = 32, r = 26;
|
||||
ctx.clearRect(0, 0, 64, 64);
|
||||
ctx.lineWidth = 7; ctx.strokeStyle = '#21262d';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.strokeStyle = col(s);
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + (s / 100) * Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
|
||||
function mrows(m, rsrp) {
|
||||
if (!m) return '<div class="metric-row"><span class="metric-label">No data yet</span></div>';
|
||||
var r = [['Latency', m.latency_ms != null ? m.latency_ms + ' ms' : '--'],
|
||||
['Download', m.dl_mbps != null ? m.dl_mbps + ' Mbps' : '--'],
|
||||
['Upload', m.ul_mbps != null ? m.ul_mbps + ' Mbps' : '--'],
|
||||
['Jitter', m.jitter_ms != null ? m.jitter_ms + ' ms' : '--'],
|
||||
['Loss', m.loss_pct != null ? m.loss_pct + '%' : '--']];
|
||||
if (rsrp && m.rsrp_dbm != null) r.push(['RSRP', m.rsrp_dbm + ' dBm']);
|
||||
return r.map(function(x) {
|
||||
return '<div class="metric-row"><span class="metric-label">' + esc(x[0]) + '</span>' +
|
||||
'<span class="metric-val">' + esc(x[1]) + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function upfmt(s) {
|
||||
var d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d) return d + 'd ' + h + 'h'; if (h) return h + 'h ' + m + 'm'; return m + 'm';
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
document.getElementById('ts').textContent = 'Updated ' + new Date(d.timestamp * 1000).toLocaleTimeString();
|
||||
document.getElementById('sim-slot').textContent = d.sim_slot != null ? 'SIM ' + d.sim_slot : '--';
|
||||
var g = d.gps;
|
||||
document.getElementById('gps-coord').textContent =
|
||||
(g && g.lat && g.fix >= 2) ? g.lat.toFixed(4) + ', ' + g.lon.toFixed(4) : 'No Fix';
|
||||
document.getElementById('uptime').textContent = upfmt(d.uptime || 0);
|
||||
document.getElementById('buf').textContent = d.telemetry_buffered || 0;
|
||||
document.getElementById('ver').textContent = d.version || '--';
|
||||
var sl = d.starlink;
|
||||
document.getElementById('sl-status').innerHTML = sl
|
||||
? '<span class="badge ' + (sl.outage ? 'badge-bad' : sl.obstructed ? 'badge-warn' : 'badge-ok') + '">' +
|
||||
(sl.outage ? 'OUTAGE' : sl.obstructed ? 'OBSTR' : 'OK') + '</span>' : '--';
|
||||
document.getElementById('sl-detail').textContent = sl && sl.latency_ms != null
|
||||
? sl.latency_ms + 'ms / ' + (sl.dl_mbps != null ? sl.dl_mbps + 'Mbps' : '--') : '';
|
||||
|
||||
var cs = d.cellular.score || 0, cw = d.cellular.weight;
|
||||
ring('cr', cs);
|
||||
var ce = document.getElementById('cs'); ce.textContent = cs; ce.style.color = col(cs);
|
||||
document.getElementById('cw-badge').innerHTML = cw != null
|
||||
? '<span class="badge ' + cls(cw) + '">W:' + cw + '</span>' : '';
|
||||
document.getElementById('cw-bar').style.width = Math.min(cw || 50, 100) + '%';
|
||||
document.getElementById('cell-m').innerHTML = mrows(d.cellular.metrics, true);
|
||||
|
||||
var ws = d.wan.score || 0, ww = d.wan.weight;
|
||||
ring('wr', ws);
|
||||
var we = document.getElementById('ws'); we.textContent = ws; we.style.color = col(ws);
|
||||
document.getElementById('ww-badge').innerHTML = ww != null
|
||||
? '<span class="badge ' + cls(ww) + '">W:' + ww + '</span>' : '';
|
||||
document.getElementById('ww-bar').style.width = Math.min(ww || 50, 100) + '%';
|
||||
document.getElementById('wan-m').innerHTML = mrows(d.wan.metrics, false);
|
||||
|
||||
document.getElementById('sb').textContent = 'v' + d.version + ' | device: ' + d.device_id + ' | ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/cgi-bin/busrouter-status')
|
||||
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function(d) { document.getElementById('err').style.display = 'none'; render(d); })
|
||||
.catch(function(e) {
|
||||
var el = document.getElementById('err');
|
||||
el.textContent = 'Status unavailable (' + e + ')';
|
||||
el.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function runSpeedTest() {
|
||||
var iface = document.getElementById('st-if').value;
|
||||
var btn = document.getElementById('st-btn');
|
||||
var out = document.getElementById('speedtest-out');
|
||||
btn.disabled = true; btn.textContent = 'Testing...';
|
||||
out.style.display = 'block'; out.textContent = 'Running on ' + iface + '...';
|
||||
fetch('/cgi-bin/busrouter-speedtest?iface=' + encodeURIComponent(iface))
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(t) { out.textContent = t; })
|
||||
.catch(function(e) { out.textContent = 'Error: ' + e; })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = 'Run Test'; });
|
||||
}
|
||||
|
||||
function applyOverride() {
|
||||
var wan = document.getElementById('ov-wan').value;
|
||||
var sim = document.getElementById('ov-sim').value;
|
||||
var pin = document.getElementById('ov-pin').value;
|
||||
fetch('/cgi-bin/busrouter-override', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'wan=' + encodeURIComponent(wan) + '&sim=' + encodeURIComponent(sim) + '&pin=' + encodeURIComponent(pin)
|
||||
}).then(function(r) { return r.text(); })
|
||||
.then(function(t) { document.getElementById('ov-res').textContent = t; })
|
||||
.catch(function(e) { document.getElementById('ov-res').textContent = 'Error: ' + e; });
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# busrouter-status: CGI endpoint that reads state files and returns JSON.
|
||||
STATE=/tmp/busrouter
|
||||
|
||||
printf 'Content-Type: application/json\r\n\r\n'
|
||||
|
||||
_read_file() { cat "$1" 2>/dev/null | head -n 1 || echo "${2:-}"; }
|
||||
|
||||
_cgi_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
|
||||
|
||||
_metric_json() {
|
||||
local file="$1" has_rsrp="$2"
|
||||
[ -f "$file" ] || { echo 'null'; return; }
|
||||
read -r lat dl ul jitter loss rsrp < "$file" 2>/dev/null
|
||||
if [ "$has_rsrp" = "1" ] && [ -n "$rsrp" ]; then
|
||||
printf '{"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s,"rsrp_dbm":%s}' \
|
||||
"${lat:-0}" "${dl:-0}" "${ul:-0}" "${jitter:-0}" "${loss:-100}" "${rsrp:-null}"
|
||||
else
|
||||
printf '{"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}' \
|
||||
"${lat:-0}" "${dl:-0}" "${ul:-0}" "${jitter:-0}" "${loss:-100}"
|
||||
fi
|
||||
}
|
||||
|
||||
now=$(date +%s)
|
||||
ver=$(_read_file /etc/busrouter/version "dev")
|
||||
uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null || echo 0)
|
||||
device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname)
|
||||
|
||||
cell_score=$(_read_file "${STATE}/score_modem_0001" 0)
|
||||
wan_score=$(_read_file "${STATE}/score_wan" 0)
|
||||
cell_weight=$(uci -q get mwan3.modem_0001.weight 2>/dev/null || echo null)
|
||||
wan_weight=$(uci -q get mwan3.wan.weight 2>/dev/null || echo null)
|
||||
sim_slot=$(_read_file "${STATE}/sim_slot" null)
|
||||
|
||||
gps_lat="" gps_lon="" gps_fix=""
|
||||
[ -f "${STATE}/gps_last" ] && read -r gps_lat gps_lon gps_fix < "${STATE}/gps_last" 2>/dev/null
|
||||
|
||||
sl_lat="" sl_dl="" sl_obs="" sl_outage=""
|
||||
[ -f "${STATE}/starlink_last" ] && read -r sl_lat sl_dl sl_obs sl_outage < "${STATE}/starlink_last" 2>/dev/null
|
||||
|
||||
buf_count=$(find "${STATE}/telemetry-buf" -name '*.json' 2>/dev/null | wc -l)
|
||||
|
||||
cell_metrics=$(_metric_json "${STATE}/metric_modem_0001" 1)
|
||||
wan_metrics=$(_metric_json "${STATE}/metric_wan" 0)
|
||||
|
||||
esc_ver=$(_cgi_str "$ver")
|
||||
esc_id=$(_cgi_str "$device_id")
|
||||
|
||||
printf '{
|
||||
"timestamp":%d,
|
||||
"uptime":%d,
|
||||
"version":"%s",
|
||||
"device_id":"%s",
|
||||
"cellular":{
|
||||
"score":%s,
|
||||
"weight":%s,
|
||||
"metrics":%s
|
||||
},
|
||||
"wan":{
|
||||
"score":%s,
|
||||
"weight":%s,
|
||||
"metrics":%s
|
||||
},
|
||||
"sim_slot":%s,
|
||||
"gps":{"lat":%s,"lon":%s,"fix":%s},
|
||||
"starlink":{"latency_ms":%s,"dl_mbps":%s,"obstructed":%s,"outage":%s},
|
||||
"telemetry_buffered":%d
|
||||
}\n' \
|
||||
"${now}" "${uptime_s}" "${esc_ver}" "${esc_id}" \
|
||||
"${cell_score:-0}" "${cell_weight:-null}" "${cell_metrics}" \
|
||||
"${wan_score:-0}" "${wan_weight:-null}" "${wan_metrics}" \
|
||||
"${sim_slot:-null}" \
|
||||
"${gps_lat:-null}" "${gps_lon:-null}" "${gps_fix:-null}" \
|
||||
"${sl_lat:-null}" "${sl_dl:-null}" "${sl_obs:-0}" "${sl_outage:-0}" \
|
||||
"${buf_count:-0}"
|
||||
Reference in New Issue
Block a user