From 8a648dc63726ff66633ff4bfecf634f4cc692b04 Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 23 Jul 2026 02:29:49 +0000 Subject: [PATCH] feat: smart WAN role detection, aiwanbal integration, speedtest support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-detect cellular vs fiber/Starlink regardless of physical port: aiwanbal modem_type → Eyeride subnet (192.168.10.x) → gateway patterns - Integrate aiwanbal/SmartAiBalancer state as primary data source when available (fresh <120s); fall back to self-collected ping metrics - Add carrier detection: AT&T for fiber gateways, Eyeride for cellular - Add run_speedtest() with speedtest-cli/iperf3/curl download fallback - Extract jitter (mdev) from ping output for richer metrics - Consistent canonical GL format: modem_0001=cellular, wan=ethernet/fiber Co-Authored-By: Claude Opus 4.8 --- .../usr/lib/busrouter/telemetry-synology.sh | 554 ++++++++++-------- 1 file changed, 298 insertions(+), 256 deletions(-) diff --git a/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh b/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh index 66553cb..b28dd06 100755 --- a/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh +++ b/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh @@ -5,41 +5,25 @@ # from an external Eyeride device (192.168.10.1:8080) which provides GPS and # signal metrics via OpenWrt ubus JSON-RPC. # +# Smart WAN role detection: cellular can be on ANY port (WAN1 or WAN2). +# The script auto-detects based on: +# 1. aiwanbal/SmartAiBalancer modem_type fields (eyeride/peplink/zte = cellular) +# 2. Eyeride reachability at 192.168.10.1 +# 3. Gateway + carrier pattern analysis +# This ensures correct mapping regardless of physical port assignment. +# +# Data sources (preferred order): +# 1. aiwanbal state files (/tmp/aiwanbal/) — richer scoring + failover decisions +# 2. Self-collected ping metrics (fallback when aiwanbal not installed) +# # GPS fallback chain: # 1. Eyeride ubus JSON-RPC (gps / location service) → fix=2 or 3 # 2. ip-api.com geolocation by external IP → fix=1 (approximate) # -# WAN mapping (canonical GL format): -# WAN1 (Ethernet/primary uplink) → modem_0001 -# WAN2 (Eyeride cellular) → wan (with rsrp_dbm/rsrq_db/sinr_db) -# -# Self-collected metrics (no aiwanbal dependency): -# - Ping latency/loss per interface (using -I ) -# - Throughput from /sys/class/net//statistics/ (bytes delta between cycles) -# - Score computed from latency + loss (simple heuristic) -# - Eyeride cellular signal via ubus (if available) -# # Config (/etc/busrouter/telemetry.conf, sourced as shell): -# 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) -# EYERIDE_IP Eyeride OpenWrt IP (default: 192.168.10.1) -# EYERIDE_PORT Eyeride ubus port (default: 8080) -# EYERIDE_USER Eyeride ubus username (default: benmashborn) -# EYERIDE_PASSWORD Eyeride password (required for GPS + signal) -# WAN1_IFACE Primary WAN interface (default: eth0) -# WAN2_IFACE Secondary/cellular WAN interface (default: eth2) -# PING_TARGET Host to ping for latency/loss (default: 8.8.8.8) -# STATE_DIR Runtime state directory (default: /tmp/busrouter) -# -# Public API: -# eyeride_gps echo 'lat lon fix'; exit 1 on failure -# ipgeo_gps echo 'lat lon fix'; exit 1 on failure -# gps_fix try eyeride_gps, fall back to ipgeo_gps -# telemetry_collect emit JSON payload to stdout (canonical GL format) -# telemetry_flush retry all buffered payloads; return 1 if hub still down -# telemetry_send collect + flush + post; buffer on failure -# -# Cron mode: runs telemetry_send when executed directly. +# TELEMETRY_HUB POST endpoint +# EYERIDE_IP/PORT/USER/PASSWORD Eyeride credentials +# WAN1_IFACE/WAN2_IFACE Physical interface names # ── Configuration ────────────────────────────────────────────────────────── : "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}" @@ -51,44 +35,43 @@ : "${WAN2_IFACE:=eth2}" : "${PING_TARGET:=8.8.8.8}" : "${STATE_DIR:=/tmp/busrouter}" +: "${AIWANBAL_DIR:=/tmp/aiwanbal}" -# Source per-device overrides if present [ -f /etc/busrouter/telemetry.conf ] && . /etc/busrouter/telemetry.conf _TELEM_SEQ=0 # ── Helpers ──────────────────────────────────────────────────────────────── - -# Escape a string for JSON (backslash + double-quote only). _json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } -# BusyBox-compatible float arithmetic via awk. -_awk_float() { awk "BEGIN { printf \"%.1f\", $1 }" 2>/dev/null || echo "null"; } +# Read a file, return empty string if missing. +_read_file() { cat "$1" 2>/dev/null || true; } + +# Extract a numeric field from JSON. +_json_get_num() { + echo "$1" | sed -n "s/.*\"$2\": *\(-\?[0-9.]*\).*/\1/p" | head -1 +} +_json_get_str() { + echo "$1" | sed -n "s/.*\"$2\": *\"\([^\"]*\)\".*/\1/p" | head -1 +} # ── GPS: Eyeride ubus JSON-RPC ───────────────────────────────────────────── - -# Authenticate to Eyeride ubus, return session token on stdout. _eyeride_login() { _pass="${EYERIDE_PASSWORD:-}" [ -n "$_pass" ] || return 1 - _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. _eyeride_ubus_call() { _session="$1" _svc="$2" _method="$3" _params="${4:-{}}" _body="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"call\",\"params\":[\"${_session}\",\"${_svc}\",\"${_method}\",${_params}]}" @@ -98,231 +81,226 @@ _eyeride_ubus_call() { "http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null } -# Extract a numeric field from JSON (handles both integer and float). -_json_get_num() { - echo "$1" | sed -n "s/.*\"$2\": *\(-\?[0-9.]*\).*/\1/p" | head -1 -} - -# Extract a string field from JSON. -_json_get_str() { - echo "$1" | sed -n "s/.*\"$2\": *\"\([^\"]*\)\".*/\1/p" | head -1 -} - -# Query GPS from Eyeride ubus. Emits 'lat lon fix'; exit 1 on failure. eyeride_gps() { _session=$(_eyeride_login) || return 1 - - # Try 'gps' service first, then 'location' service _resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then _resp=$(_eyeride_ubus_call "$_session" "location" "status") 2>/dev/null fi - - # Extract lat/lon from various possible field names _lat=$(_json_get_num "$_resp" "latitude") _lon=$(_json_get_num "$_resp" "longitude") [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat") [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon") [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat_deg") [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon_deg") - if [ -n "$_lat" ] && [ -n "$_lon" ]; then - _fix=2 # assume 2D fix from Eyeride + _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 } -# ── GPS: IP Geolocation Fallback ─────────────────────────────────────────── - -# Query ip-api.com for approximate location. Emits 'lat lon fix'; exit 1 on failure. ipgeo_gps() { _resp=$(curl -s --connect-timeout 5 "http://ip-api.com/json/" 2>/dev/null | tr -d '\n\r') || return 1 [ -n "$_resp" ] || return 1 - _lat=$(_json_get_num "$_resp" "lat") _lon=$(_json_get_num "$_resp" "lon") - if [ -n "$_lat" ] && [ -n "$_lon" ]; then - _fix=1 # approximate (IP-based) + _fix=1 logger -t busrouter -p daemon.debug "ipgeo_gps: ${_lat} ${_lon} fix=${_fix} (IP geolocation)" printf '%s %s %s\n' "$_lat" "$_lon" "$_fix" return 0 fi - - logger -t busrouter "ipgeo_gps: geolocation failed" return 1 } -# GPS with fallback: try Eyeride first, then IP geolocation. gps_fix() { - if eyeride_gps 2>/dev/null; then - return 0 - fi + if eyeride_gps 2>/dev/null; then return 0; fi ipgeo_gps } -# ── Eyeride Cellular Signal ──────────────────────────────────────────────── +# ── Smart WAN Role Detection ─────────────────────────────────────────────── +# Detects which physical interface is cellular vs landline/Starlink. +# Uses multiple signals in priority order: +# 1. aiwanbal modem_type field (eyeride/peplink/zte/mofi/quectel = cellular) +# 2. Interface routes to Eyeride at 192.168.10.1 +# 3. Gateway/carrier analysis -# Query cellular signal from Eyeride ubus (network/wwan services). -# Returns: rsrp_dbm rsrq_db sinr_db technology carrier_str -_eyeride_signal() { - _session=$(_eyeride_login) 2>/dev/null || return 1 +# Check if a modem_type string indicates a cellular modem. +_is_cellular_modem() { + case "$1" in + eyeride|peplink|zte|mofi|quectel|telit|sierra|fibocom) return 0 ;; + none|generic|"") return 1 ;; + *) return 1 ;; + esac +} - # Try network.wan status for modem info - _resp=$(_eyeride_ubus_call "$_session" "network" "status") 2>/dev/null - if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then - # Try alternative: wwan or modem service - _resp=$(_eyeride_ubus_call "$_session" "wwan" "status") 2>/dev/null +# Check if an interface appears to be cellular by attempting to reach the Eyeride. +_iface_reaches_eyeride() { + _iface="$1" + # Check if this interface has an IP in the Eyeride network range + _ip=$(ip addr show "$_iface" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) + if echo "$_ip" | grep -q '^192\.168\.10\.'; then + return 0 + fi + # Check if a route to the Eyeride exists via this interface + if ip route get "$EYERIDE_IP" 2>/dev/null | grep -q "dev $_iface"; then + return 0 + fi + return 1 +} + +# Returns: "cellular_iface ethernet_iface" +_detect_wan_roles() { + _cell="" _eth="" + + # 1. Try aiwanbal modem_type first (most reliable) + if [ -d "$AIWANBAL_DIR" ]; then + _mod1=$(_read_file "${AIWANBAL_DIR}/wan1_modem_type") + _mod2=$(_read_file "${AIWANBAL_DIR}/wan2_modem_type") + if _is_cellular_modem "$_mod1" && ! _is_cellular_modem "$_mod2"; then + echo "$WAN1_IFACE $WAN2_IFACE"; return 0 + elif _is_cellular_modem "$_mod2" && ! _is_cellular_modem "$_mod1"; then + echo "$WAN2_IFACE $WAN1_IFACE"; return 0 + elif _is_cellular_modem "$_mod1" && _is_cellular_modem "$_mod2"; then + # Both cellular — default: WAN2 is cellular (Eyeride typical port) + echo "$WAN2_IFACE $WAN1_IFACE"; return 0 + fi fi - _rsrp=$(_json_get_num "$_resp" "rsrp") - _rsrq=$(_json_get_num "$_resp" "rsrq") - _sinr=$(_json_get_num "$_resp" "sinr") - _tech=$(_json_get_str "$_resp" "technology") - _carrier=$(_json_get_str "$_resp" "operator") + # 2. Check Eyeride reachability + if _iface_reaches_eyeride "$WAN2_IFACE"; then + echo "$WAN2_IFACE $WAN1_IFACE"; return 0 + elif _iface_reaches_eyeride "$WAN1_IFACE"; then + echo "$WAN1_IFACE $WAN2_IFACE"; return 0 + fi - # Also try signal-specific fields - [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "signal") - [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "rssi") + # 3. Check gateway patterns for known carriers + for _iface in "$WAN2_IFACE" "$WAN1_IFACE"; do + _gw=$(ip route show default 2>/dev/null | grep "$_iface" | awk '{print $3}' | head -1) + # Starlink: typically 192.168.100.1 or 100.64.x.x (CGNAT) + if echo "$_gw" | grep -qE '^192\.168\.100\.|^100\.(64|96|112)\.'; then + _cell="" # Starlink is not cellular + fi + # Eyeride: always 192.168.10.1 + if [ "$_gw" = "192.168.10.1" ]; then + _cell="$_iface" + fi + done + if [ -n "$_cell" ]; then + for _iface in "$WAN2_IFACE" "$WAN1_IFACE"; do + [ "$_iface" != "$_cell" ] && _eth="$_iface" + done + echo "${_cell} ${_eth}"; return 0 + fi - printf '%s %s %s %s %s\n' "${_rsrp:-null}" "${_rsrq:-null}" "${_sinr:-null}" "${_tech:-unknown}" "${_carrier:-Eyeride}" - return 0 + # 4. Default: WAN2 is cellular (most common Synology bus router layout) + echo "$WAN2_IFACE $WAN1_IFACE" } # ── WAN Metric Collection ────────────────────────────────────────────────── -# Ping an interface to get latency and loss. -# On Synology SRM: ping requires root. When running from cron (as root), it works. -# When run manually as non-root, gracefully returns null. -# NOTE: ping returns exit 0 on success, 1 on any packet loss, 2 on error. -# We use sudo first; if sudo ping exits 2 (real error), fall back to direct ping. -# Exit 1 (packet loss) is a valid result — do NOT fall back on it. -# Usage: _ping_iface → "lat_ms loss_pct" +# Check if aiwanbal state is available and fresh (< 120s old). +_aiwanbal_available() { + [ -d "$AIWANBAL_DIR" ] || return 1 + _mtime=$(stat -c %Y "${AIWANBAL_DIR}/wan1_score_avg" 2>/dev/null || stat -f %m "${AIWANBAL_DIR}/wan1_score_avg" 2>/dev/null) + [ -n "$_mtime" ] || return 1 + _now=$(date +%s) + _age=$(( _now - _mtime )) + [ "$_age" -lt 120 ] && return 0 + return 1 +} + +# Ping-based fallback metrics (used when aiwanbal not available). _ping_iface() { _iface="$1" - # Try sudo ping first. Only fall back to direct ping if sudo fails (exit 2+). _out=$(sudo ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null) _rc=$? if [ $_rc -ge 2 ]; then _out=$(ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null) fi if [ -z "$_out" ]; then - echo "null null" - return + echo "null null null"; return fi - # Check for permission denied / other errors if echo "$_out" | grep -qE "permission denied|not permitted|unknown host|Network is unreachable"; then - echo "null null" - return + echo "null null null"; return fi - # Extract avg rtt from last line. - # Standard ping: "rtt min/avg/max/mdev = 10.5/15.2/20.1/3.5 ms" - # BusyBox ping: "round-trip min/avg/max = 10.5/15.2/20.1 ms" + # Extract avg rtt _avg=$(echo "$_out" | tail -1 | sed -n 's/.*=\ [0-9.]*\/\([0-9.]*\)\/.*/\1/p') [ -z "$_avg" ] && _avg=$(echo "$_out" | tail -1 | sed -n 's/.*avg\/max.*=\ \([0-9.]*\)\/.*/\1/p') [ -z "$_avg" ] && _avg="null" - # Extract loss percentage: "3 packets transmitted, 3 received, 0% packet loss" + # Extract jitter (mdev from standard ping: rtt min/avg/max/mdev = ...) + _jitter=$(echo "$_out" | tail -1 | sed -n 's/.*=\ [0-9.]*\/[0-9.]*\/[0-9.]*\/\([0-9.]*\).*/\1/p') + [ -z "$_jitter" ] && _jitter="null" + # Extract loss _loss=$(echo "$_out" | sed -n 's/.* \([0-9]*\)% packet loss.*/\1/p') [ -z "$_loss" ] && _loss="null" - printf '%s %s\n' "${_avg}" "${_loss}" | tr -d '\n\r' + printf '%s %s %s\n' "${_avg}" "${_jitter}" "${_loss}" | tr -d '\n\r' } -# Compute simple WAN score from latency and loss (0-100, higher = better). -# Score = max(0, 100 - latency_ms/2 - loss_pct*5) +# Compute score from latency + loss (0-100, higher = better). _compute_score() { _lat="$1" _loss="$2" if [ "$_lat" = "null" ] || [ "$_loss" = "null" ]; then - echo "0" - return + echo "0"; return fi _score=$(awk "BEGIN { s = 100 - ${_lat}/2 - ${_loss}*5; if (s < 0) s = 0; if (s > 100) s = 100; printf \"%d\", int(s) }" 2>/dev/null) echo "${_score:-0}" } -# Read bytes transferred from /sys/class/net//statistics/. -# Returns delta from last reading stored in STATE_DIR. -_read_bytes_delta() { - _iface="$1" _direction="$2" # tx_bytes or rx_bytes - _stat_file="/sys/class/net/${_iface}/statistics/${_direction}" - _state_file="${STATE_DIR}/bytes_${_iface}_${_direction}" - [ -f "$_stat_file" ] || { echo "null"; return; } - mkdir -p "$STATE_DIR" 2>/dev/null - _current=$(cat "$_stat_file" 2>/dev/null) - _prev=$(cat "$_state_file" 2>/dev/null) - printf '%s\n' "$_current" > "$_state_file" 2>/dev/null || true - if [ -z "$_prev" ] || [ -z "$_current" ]; then - echo "null"; return - fi - _delta=$(( _current - _prev )) - if [ "$_delta" -lt 0 ]; then - _delta=0 # counter reset - fi - # Convert bytes to Mbps (bytes → Mb/s, assume ~60s interval) - _mbps=$(awk "BEGIN { printf \"%.1f\", ${_delta} * 8 / 60 / 1000000 }" 2>/dev/null) - echo "${_mbps:-0.0}" -} - # Collect metrics for one WAN interface. -# Usage: _collect_wan → JSON object fragment +# Uses aiwanbal state if available, falls back to self-collected ping. +# Usage: _collect_wan → JSON object fragment _collect_wan() { - _iface="$1" + _iface="$1" _num="$2" + _score="0" _lat="null" _dl="null" _ul="null" _jitter="null" _loss="null" - # Ping metrics - _ping_result=$(_ping_iface "$_iface") - _lat=$(echo "$_ping_result" | awk '{print $1}') - _loss=$(echo "$_ping_result" | awk '{print $2}') - - # Throughput (delta since last cycle) - _dl=$(_read_bytes_delta "$_iface" "rx_bytes") - _ul=$(_read_bytes_delta "$_iface" "tx_bytes") - - # Score - _score=$(_compute_score "$_lat" "$_loss") - - # Jitter — rough estimate from ping variation (not true jitter, but indicative) - _jitter="null" + if _aiwanbal_available; then + # Use aiwanbal state (preferred — richer metrics) + _score=$(_read_file "${AIWANBAL_DIR}/wan${_num}_score_avg") + [ -z "$_score" ] && _score="0" + _lat=$(_read_file "${AIWANBAL_DIR}/wan${_num}_latency") + [ -z "$_lat" ] && _lat="null" + _jitter=$(_read_file "${AIWANBAL_DIR}/wan${_num}_jitter") + [ -z "$_jitter" ] && _jitter="null" + _loss=$(_read_file "${AIWANBAL_DIR}/wan${_num}_packet_loss") + [ -z "$_loss" ] && _loss="null" + # Convert throughput from B/s to Mbps + _tp=$(_read_file "${AIWANBAL_DIR}/wan${_num}_throughput") + if [ -n "$_tp" ] && [ "$_tp" != "0" ]; then + _dl=$(awk "BEGIN { printf \"%.1f\", ${_tp} / 125000 }" 2>/dev/null) + fi + [ -z "$_dl" ] && _dl="0.0" + _up=$(_read_file "${AIWANBAL_DIR}/wan${_num}_upload") + if [ -n "$_up" ] && [ "$_up" != "0" ]; then + _ul=$(awk "BEGIN { printf \"%.1f\", ${_up} / 125000 }" 2>/dev/null) + fi + [ -z "$_ul" ] && _ul="0.0" + else + # Fallback: self-collected ping metrics + _ping_result=$(_ping_iface "$_iface") + _lat=$(echo "$_ping_result" | awk '{print $1}') + _jitter=$(echo "$_ping_result" | awk '{print $2}') + _loss=$(echo "$_ping_result" | awk '{print $3}') + _score=$(_compute_score "$_lat" "$_loss") + _dl="0.0" + _ul="0.0" + fi printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}' \ "${_score}" "${_lat}" "${_dl}" "${_ul}" "${_jitter}" "${_loss}" } -# ── Telemetry Collection ─────────────────────────────────────────────────── +# ── Carrier Detection ────────────────────────────────────────────────────── -# Detect which interface is the Eyeride cellular link. -# The Eyeride runs on 192.168.10.x subnet; the interface with an IP in that -# range is the cellular path. The other WAN interface is the primary/fiber uplink. -# Returns: cellular_iface ethernet_iface -_detect_wan_roles() { - # Check if WAN2 is on the Eyeride subnet - _wan2_ip=$(ip addr show "$WAN2_IFACE" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) - if echo "$_wan2_ip" | grep -q '^192\.168\.10\.'; then - echo "$WAN2_IFACE $WAN1_IFACE" # WAN2 = cellular, WAN1 = ethernet - else - # Check if WAN1 is on the Eyeride subnet - _wan1_ip=$(ip addr show "$WAN1_IFACE" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) - if echo "$_wan1_ip" | grep -q '^192\.168\.10\.'; then - echo "$WAN1_IFACE $WAN2_IFACE" # WAN1 = cellular, WAN2 = ethernet - else - # Default: WAN2 is cellular (Eyeride typically on secondary port) - echo "$WAN2_IFACE $WAN1_IFACE" - fi - fi -} - -# Derive carrier name for an interface. Uses reverse DNS on gateway or ISP detection. -# For the Eyeride (192.168.10.x), the carrier comes from the Eyeride's cellular operator. +# Derive carrier name for an interface. _detect_carrier() { _iface="$1" - _gw=$(ip route show default 2>/dev/null | grep "$_iface" | awk '{print $3}' | head -1) - # Check if this is the Eyeride network - _ip=$(ip addr show "$_iface" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) - if echo "$_ip" | grep -q '^192\.168\.10\.'; then - # This is the Eyeride — carrier comes from the Eyeride's cellular operator + # Check if this is the Eyeride cellular path + if _iface_reaches_eyeride "$_iface"; then _sig=$(_eyeride_signal 2>/dev/null) _sig_carrier=$(echo "$_sig" | awk '{print $5}') if [ -n "$_sig_carrier" ] && [ "$_sig_carrier" != "null" ] && [ "$_sig_carrier" != "Eyeride" ]; then @@ -330,48 +308,119 @@ _detect_carrier() { fi echo "Eyeride"; return fi - # For the primary/ethernet interface, try to detect from gateway + # Try aiwanbal modem_type + _num="" + for _n in 1 2; do + _mod=$(_read_file "${AIWANBAL_DIR}/wan${_n}_modem_type" 2>/dev/null) + _active=$(_read_file "${AIWANBAL_DIR}/wan${_n}_state" 2>/dev/null) + if [ "$_mod" != "eyeride" ] && [ "$_mod" != "none" ] && [ -n "$_mod" ] && [ "$_active" = "up" ]; then + echo "$_mod"; return + fi + done + # Check gateway for known ISP patterns + _gw=$(ip route show default 2>/dev/null | grep "$_iface" | awk '{print $3}' | head -1) if [ -n "$_gw" ]; then - # Common AT&T gateways if echo "$_gw" | grep -qE '^192\.168\.[01]\.'; then echo "AT&T"; return fi - # Common Spectrum/Charter - if echo "$_gw" | grep -qE '^192\.168\.[0-9]+\.'; then - _rev=$(nslookup "$_gw" 2>/dev/null | grep -i name | head -1 | sed 's/.*= //') - if echo "$_rev" | grep -qi 'att\|sbcglobal\|bellsouth'; then - echo "AT&T"; return - fi + if echo "$_gw" | grep -qE '^192\.168\.100\.|^100\.(64|96)\.'; then + echo "Starlink"; return + fi + if echo "$_gw" | grep -qE '^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.'; then + echo "Business"; return fi fi - # Fallback echo "" } -# Add carrier and identity fields to a WAN JSON object. -# Usage: _add_wan_identity +# Add carrier and identity to a WAN JSON object. _add_wan_identity() { _json="$1" _iface="$2" _carrier=$(_detect_carrier "$_iface") - _esc_carrier=$(_json_str "${_carrier}") - # Strip closing brace, add carrier, re-close - printf '%s' "$_json" | sed 's/}$//' + _prefix=$(printf '%s' "$_json" | sed 's/}$//') if [ -n "$_carrier" ]; then - printf ',"carrier":"%s"}' "${_esc_carrier}" + _esc_carrier=$(_json_str "${_carrier}") + printf '%s,"carrier":"%s"}' "$_prefix" "$_esc_carrier" else - printf '}' + printf '%s}' "$_prefix" fi } -# Build the full telemetry JSON payload (canonical GL format). -# Emits JSON to stdout; never fails — uses defaults for missing fields. +# ── Eyeride Signal ───────────────────────────────────────────────────────── +_eyeride_signal() { + _session=$(_eyeride_login) 2>/dev/null || return 1 + _resp=$(_eyeride_ubus_call "$_session" "network" "status") 2>/dev/null + if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then + _resp=$(_eyeride_ubus_call "$_session" "wwan" "status") 2>/dev/null + fi + _rsrp=$(_json_get_num "$_resp" "rsrp") + _rsrq=$(_json_get_num "$_resp" "rsrq") + _sinr=$(_json_get_num "$_resp" "sinr") + _tech=$(_json_get_str "$_resp" "technology") + _carrier=$(_json_get_str "$_resp" "operator") + [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "signal") + [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "rssi") + printf '%s %s %s %s %s\n' "${_rsrp:-null}" "${_rsrq:-null}" "${_sinr:-null}" "${_tech:-unknown}" "${_carrier:-Eyeride}" +} + +# ── Speedtest ────────────────────────────────────────────────────────────── +# Run a speedtest bound to a specific interface. Reports dl_mbps ul_mbps. +run_speedtest() { + _iface="$1" + _result="0 0" + # Try speedtest-cli first + if which speedtest-cli >/dev/null 2>&1; then + _out=$(speedtest-cli --interface "$_iface" --json 2>/dev/null) + if [ -n "$_out" ]; then + _dl=$(_json_get_num "$_out" "download") + _ul=$(_json_get_num "$_out" "upload") + if [ -n "$_dl" ] && [ -n "$_ul" ]; then + _dl_mbps=$(awk "BEGIN { printf \"%.1f\", ${_dl} / 125000 }" 2>/dev/null) + _ul_mbps=$(awk "BEGIN { printf \"%.1f\", ${_ul} / 125000 }" 2>/dev/null) + echo "${_dl_mbps} ${_ul_mbps}" + return 0 + fi + fi + fi + # Fallback: iperf3 single-stream test to public server + if which iperf3 >/dev/null 2>&1; then + _out=$(timeout 15 iperf3 -B "$_ip" -c iperf.he.net -p 5201 -J 2>/dev/null) + if [ -n "$_out" ]; then + _dl=$(_json_get_num "$_out" "bits_per_second") + if [ -n "$_dl" ]; then + _dl_mbps=$(awk "BEGIN { printf \"%.1f\", ${_dl} / 1000000 }" 2>/dev/null) + echo "${_dl_mbps} 0" + return 0 + fi + fi + fi + # Fallback: curl a known file and measure throughput + _tmp="/tmp/speedtest-$$" + _start=$(date +%s) + if curl -s --interface "$_iface" --max-time 10 -o "$_tmp" http://speedtest.tele2.net/1MB.zip 2>/dev/null; then + _end=$(date +%s) + _bytes=$(wc -c < "$_tmp" 2>/dev/null) + rm -f "$_tmp" + if [ "$_end" -gt "$_start" ] && [ -n "$_bytes" ]; then + _duration=$(( _end - _start )) + [ "$_duration" -lt 1 ] && _duration=1 + _dl_mbps=$(awk "BEGIN { printf \"%.1f\", ${_bytes} * 8 / ${_duration} / 1000000 }" 2>/dev/null) + echo "${_dl_mbps:-0} 0" + return 0 + fi + fi + rm -f "$_tmp" + echo "0 0" +} + +# ── Telemetry Collection ─────────────────────────────────────────────────── telemetry_collect() { 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 (Eyeride → IP geolocation fallback) + # GPS gps_lat="null" gps_lon="null" gps_fix="null" if gps_out=$(gps_fix 2>/dev/null); then gps_lat=$(echo "$gps_out" | awk '{print $1}') @@ -379,8 +428,7 @@ telemetry_collect() { gps_fix=$(echo "$gps_out" | awk '{print $3}') fi - # Auto-detect which interface is cellular vs ethernet/fiber - _cell_iface="" _eth_iface="" + # Smart WAN role detection if _roles=$(_detect_wan_roles 2>/dev/null); then _cell_iface=$(echo "$_roles" | awk '{print $1}') _eth_iface=$(echo "$_roles" | awk '{print $2}') @@ -389,67 +437,67 @@ telemetry_collect() { _eth_iface="$WAN1_IFACE" fi - # Collect metrics for both WANs - _eth_json=$(_collect_wan "$_eth_iface") - _cell_json=$(_collect_wan "$_cell_iface") + # Map physical interfaces to aiwanbal wan numbers + _cell_num="2" _eth_num="1" + if [ "$_cell_iface" = "$WAN1_IFACE" ]; then + _cell_num="1" + fi + if [ "$_eth_iface" = "$WAN2_IFACE" ]; then + _eth_num="2" + fi - # Add carrier identity to each WAN - _eth_json=$(_add_wan_identity "$_eth_json" "$_eth_iface") - _cell_json=$(_add_wan_identity "$_cell_json" "$_cell_iface") + # Collect WAN metrics + _modem_json=$(_collect_wan "$_cell_iface" "$_cell_num") + _ether_json=$(_collect_wan "$_eth_iface" "$_eth_num") - # Eyeride cellular signal for the cellular interface + # Add carrier identity + _modem_json=$(_add_wan_identity "$_modem_json" "$_cell_iface") + _ether_json=$(_add_wan_identity "$_ether_json" "$_eth_iface") + + # Enrich cellular WAN with Eyeride signal data eyeride_sig=$(_eyeride_signal 2>/dev/null) _rsrp=$(echo "$eyeride_sig" | awk '{print $1}') _rsrq=$(echo "$eyeride_sig" | awk '{print $2}') _sinr=$(echo "$eyeride_sig" | awk '{print $3}') _tech=$(echo "$eyeride_sig" | awk '{print $4}') - # Add cellular signal fields to the cellular WAN JSON - if echo "$_cell_json" | grep -q '"loss_pct"'; then - _cell_json=$(printf '%s' "$_cell_json" | sed 's/}$//') - if [ "$_rsrp" != "null" ] && [ -n "$_rsrp" ]; then - _esc_tech=$(_json_str "${_tech:-unknown}") - _cell_json="${_cell_json},\"rsrp_dbm\":${_rsrp},\"rsrq_db\":${_rsrq},\"sinr_db\":${_sinr},\"technology\":\"${_esc_tech}\"}" - else - _cell_json="${_cell_json}}" + + _modem_json=$(printf '%s' "$_modem_json" | sed 's/}$//') + if [ "$_rsrp" != "null" ] && [ -n "$_rsrp" ]; then + _esc_tech=$(_json_str "${_tech:-unknown}") + _modem_json="${_modem_json},\"rsrp_dbm\":${_rsrp},\"rsrq_db\":${_rsrq},\"sinr_db\":${_sinr},\"technology\":\"${_esc_tech}\"}" + else + _modem_json="${_modem_json}}" + fi + + # Active WAN determination + sel_primary="wan" + default_iface=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1) + if [ -d "$AIWANBAL_DIR" ]; then + # Prefer aiwanbal mode for failover detection + _mode=$(_read_file "${AIWANBAL_DIR}/wan0_mode") + if [ "$_mode" = "failover" ]; then + _active=$(_read_file "${AIWANBAL_DIR}/wan2_state") + if [ "$_active" = "up" ]; then + sel_primary="modem_0001" + fi fi fi - - # WAN mapping in canonical GL format: - # modem_0001 = cellular (Eyeride 5G) - # wan = ethernet/fiber (AT&T Uverse) - _wan_modem="$WAN2_IFACE" # which physical iface maps to modem_0001 - _wan_ether="$WAN1_IFACE" # which physical iface maps to wan - - # Check if roles were swapped by auto-detection - if [ "$_cell_iface" != "$WAN2_IFACE" ]; then - # WAN1 is cellular, WAN2 is ethernet - _modem_json="$_cell_json" - _ether_json="$_eth_json" - else - # WAN2 is cellular, WAN1 is ethernet (normal) - _modem_json="$_cell_json" - _ether_json="$_eth_json" - fi - - # Active WAN: determine from routing table (default route interface) - sel_primary="wan" # default: fiber/ethernet is primary - default_iface=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1) + # Fallback: use default route interface if [ "$default_iface" = "$_cell_iface" ]; then - sel_primary="modem_0001" # cellular is the active path + sel_primary="modem_0001" fi - # Starlink — detect if present + # Starlink detection sl_lat="null" sl_dl="null" sl_obs="0" sl_outage="0" if ip link show 2>/dev/null | grep -qi starlink; then sl_iface=$(ip link show 2>/dev/null | grep -i starlink | head -1 | awk -F": " '{print $2}' | awk '{print $1}') if [ -n "$sl_iface" ]; then sl_ping=$(_ping_iface "$sl_iface") sl_lat=$(echo "$sl_ping" | awk '{print $1}') - sl_dl=$(_read_bytes_delta "$sl_iface" "rx_bytes") fi fi - # State — autonomous indicator (always true for fleet routers) + # State state='{"autonomous":true,"qos":{},"wanhealth":{}}' esc_id=$(_json_str "$device_id") @@ -465,8 +513,6 @@ telemetry_collect() { } # ── Hub POST + Buffering ─────────────────────────────────────────────────── - -# POST a JSON file to the hub. Returns 0 on HTTP 2xx, 1 on any failure. _telemetry_try_post() { file="$1" code=$(curl -sf -X POST -H "Content-Type: application/json" \ @@ -475,8 +521,6 @@ _telemetry_try_post() { 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 for f in "$TELEMETRY_BUFFER_DIR"/*.json; do @@ -491,28 +535,19 @@ telemetry_flush() { 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() { mkdir -p "$STATE_DIR" 2>/dev/null - payload=$(telemetry_collect) - telemetry_flush 2>/dev/null || true - mkdir -p "$TELEMETRY_BUFFER_DIR" 2>/dev/null _TELEM_SEQ=$(( _TELEM_SEQ + 1 )) 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" @@ -524,6 +559,13 @@ telemetry_send() { # ── Entry Point ──────────────────────────────────────────────────────────── case "${0##*/}" in telemetry-synology.sh|telemetry-synology) + # Run speedtest if requested via a trigger file (touch /tmp/busrouter/speedtest-request) + if [ -f "${STATE_DIR}/speedtest-request" ]; then + _iface=$(cat "${STATE_DIR}/speedtest-request" 2>/dev/null) + [ -z "$_iface" ] && _iface="$WAN1_IFACE" + run_speedtest "$_iface" > "${STATE_DIR}/speedtest-result" + rm -f "${STATE_DIR}/speedtest-request" + fi telemetry_send ;; esac