Files
kit-busrouter/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh
T
kitadmin bbbc873bdb fix: HTTP fallback probe when ICMP reports 100% loss (Eyeride NAT blocks ICMP)
Eyeride 5G device passes HTTP traffic but drops ICMP, causing false 100% loss.
When ping gives 100% loss, try curl --interface to confirm connectivity; if
HTTP probe succeeds, correct loss to 0% and derive latency from connect time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 03:31:47 +00:00

595 lines
22 KiB
Bash
Executable File

#!/bin/sh
# telemetry-synology.sh — Self-contained telemetry for Synology SRM routers.
#
# Synology RT2600ac routers have NO built-in 5G modem or GPS. Cellular comes
# 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)
#
# Config (/etc/busrouter/telemetry.conf, sourced as shell):
# 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}"
: "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}"
: "${EYERIDE_IP:=192.168.10.1}"
: "${EYERIDE_PORT:=8080}"
: "${EYERIDE_USER:=benmashborn}"
: "${WAN1_IFACE:=eth0}"
: "${WAN2_IFACE:=eth2}"
: "${PING_TARGET:=8.8.8.8}"
: "${STATE_DIR:=/tmp/busrouter}"
: "${AIWANBAL_DIR:=/tmp/aiwanbal}"
[ -f /etc/busrouter/telemetry.conf ] && . /etc/busrouter/telemetry.conf
_TELEM_SEQ=0
# ── Helpers ────────────────────────────────────────────────────────────────
_json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
# 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 ─────────────────────────────────────────────
_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
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"
}
_eyeride_ubus_call() {
_session="$1" _svc="$2" _method="$3" _params="${4:-{}}"
_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
}
eyeride_gps() {
_session=$(_eyeride_login) || return 1
_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
_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
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
}
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
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
return 1
}
gps_fix() {
if eyeride_gps 2>/dev/null; then return 0; fi
ipgeo_gps
}
# ── 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
# 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
}
# 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
# 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
# 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
# 4. Default: WAN2 is cellular (most common Synology bus router layout)
echo "$WAN2_IFACE $WAN1_IFACE"
}
# ── WAN Metric Collection ──────────────────────────────────────────────────
# 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)
_mtime=$(printf '%s' "$_mtime" | tr -cd '0-9')
[ -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"
_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 null"; return
fi
if echo "$_out" | grep -qE "permission denied|not permitted|unknown host|Network is unreachable"; then
echo "null null null"; return
fi
# 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 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"
# If ICMP reports 100% loss, try HTTP probe — Eyeride NAT blocks ICMP but passes HTTP.
if [ "$_loss" = "100" ]; then
_t0=$(date +%s%3N 2>/dev/null || date +%s)
_hcode=$(curl -s --interface "$_iface" --connect-timeout 5 --max-time 8 \
-o /dev/null -w "%{http_code}" http://1.1.1.1 2>/dev/null)
_t1=$(date +%s%3N 2>/dev/null || date +%s)
if [ -n "$_hcode" ] && [ "$_hcode" != "000" ]; then
_loss="0"
[ -z "$_avg" ] || [ "$_avg" = "null" ] && \
_avg=$(awk "BEGIN { printf \"%.0f\", ${_t1} - ${_t0} }" 2>/dev/null)
fi
fi
printf '%s %s %s\n' "${_avg}" "${_jitter}" "${_loss}" | tr -d '\n\r'
}
# Compute score from latency + loss (0-100, higher = better).
_compute_score() {
_lat="$1" _loss="$2"
if [ "$_lat" = "null" ] || [ "$_loss" = "null" ]; then
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}"
}
# Collect metrics for one WAN interface.
# Uses aiwanbal state if available, falls back to self-collected ping.
# Usage: _collect_wan <iface> <wan_num> → JSON object fragment
_collect_wan() {
_iface="$1" _num="$2"
_score="0" _lat="null" _dl="null" _ul="null" _jitter="null" _loss="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")
# Use cached speedtest result if available and fresh
_st=$(_cached_speedtest "$_iface" 2>/dev/null)
if [ -n "$_st" ]; then
_dl=$(echo "$_st" | awk '{print $1}')
_ul=$(echo "$_st" | awk '{print $2}')
fi
[ -n "$_dl" ] || _dl="0.0"
[ -n "$_ul" ] || _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}"
}
# ── Carrier Detection ──────────────────────────────────────────────────────
# Derive carrier name for an interface.
_detect_carrier() {
_iface="$1"
# 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
echo "$_sig_carrier"; return
fi
echo "Eyeride"; return
fi
# 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
if echo "$_gw" | grep -qE '^192\.168\.[01]\.'; 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
echo ""
}
# Add carrier and identity to a WAN JSON object.
_add_wan_identity() {
_json="$1" _iface="$2"
_carrier=$(_detect_carrier "$_iface")
_prefix=$(printf '%s' "$_json" | sed 's/}$//')
if [ -n "$_carrier" ]; then
_esc_carrier=$(_json_str "${_carrier}")
printf '%s,"carrier":"%s"}' "$_prefix" "$_esc_carrier"
else
printf '%s}' "$_prefix"
fi
}
# ── 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. Caches result per-interface.
run_speedtest() {
_iface="$1"
_result_file="${STATE_DIR}/speedtest-result-${_iface}"
# 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)
printf '%s %s\n' "${_dl_mbps}" "${_ul_mbps}" > "$_result_file"
echo "${_dl_mbps} ${_ul_mbps}"
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 15 -o "$_tmp" http://speedtest.tele2.net/10MB.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)
printf '%s 0\n' "${_dl_mbps:-0}" > "$_result_file"
echo "${_dl_mbps:-0} 0"
return 0
fi
fi
rm -f "$_tmp"
echo "0 0"
}
# Read cached speedtest result for an interface if < 35 minutes old.
_cached_speedtest() {
_iface="$1"
_f="${STATE_DIR}/speedtest-result-${_iface}"
[ -f "$_f" ] || return 1
_mtime=$(stat -c %Y "$_f" 2>/dev/null || stat -f %m "$_f" 2>/dev/null)
_mtime=$(printf '%s' "$_mtime" | tr -cd '0-9')
[ -n "$_mtime" ] || return 1
_st_now=$(date +%s)
_age=$(( _st_now - _mtime ))
[ "$_age" -lt 2100 ] || return 1
cat "$_f"
}
# ── 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
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}')
gps_lon=$(echo "$gps_out" | awk '{print $2}')
gps_fix=$(echo "$gps_out" | awk '{print $3}')
fi
# 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}')
else
_cell_iface="$WAN2_IFACE"
_eth_iface="$WAN1_IFACE"
fi
# 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
# Collect WAN metrics
_modem_json=$(_collect_wan "$_cell_iface" "$_cell_num")
_ether_json=$(_collect_wan "$_eth_iface" "$_eth_num")
# 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}')
_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
# Fallback: use default route interface
if [ "$default_iface" = "$_cell_iface" ]; then
sel_primary="modem_0001"
fi
# 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}')
fi
fi
# State
state='{"autonomous":true,"qos":{},"wanhealth":{}}'
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","active_wan":"%s","gps":{"lat":%s,"lon":%s,"fix":%s},"wan":{"modem_0001":%s,"wan":%s},"sim_slot":null,"starlink":{"latency_ms":%s,"dl_bps":%s,"obstructed":%s,"outage":%s},"state":%s}\n' \
"${esc_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" "${esc_sel}" \
"${gps_lat}" "${gps_lon}" "${gps_fix}" \
"${_modem_json}" "${_ether_json}" \
"${sl_lat}" "${sl_dl}" "${sl_obs}" "${sl_outage}" \
"${state}"
}
# ── Hub POST + Buffering ───────────────────────────────────────────────────
_telemetry_try_post() {
file="$1"
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
}
telemetry_flush() {
[ -d "$TELEMETRY_BUFFER_DIR" ] || return 0
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
}
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"
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
}
# ── 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