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:
2026-07-22 00:07:14 +00:00
commit f15ac69925
41 changed files with 5083 additions and 0 deletions
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 0100
# 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 0100, 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
View File
@@ -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 &nbsp;<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 &nbsp;<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); };
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>
+75
View File
@@ -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}"