feat: unified fleet telemetry — canonical format, GPS/IP geo, WAN health dashboard

- Rewrite telemetry-synology.sh: canonical GL format (modem_0001/wan keys),
  self-contained metrics (no aiwanbal), Eyeride GPS with IP geo fallback
- Add /api/fleet-telemetry to hub: joins tunnel status with fleet Postgres
- Update dashboard.html: per-device WAN health bars, GPS, signal strength
- Fix hub/ingest.sh normalisation: remap old wan1/wan2 → modem_0001/wan
- Bump SPK version: 0.1-0001 → 0.5.0-0001 for GL parity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 01:47:02 +00:00
parent d86a7e49b5
commit db04341f7c
6 changed files with 596 additions and 220 deletions
@@ -1,64 +1,77 @@
#!/bin/sh
# telemetry-synology.sh — Synology SRM telemetry payload builder for fleet hub.
# telemetry-synology.sh — Self-contained telemetry for Synology SRM routers.
#
# 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.
# 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.
#
# Config (environment variables):
# 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 <iface>)
# - Throughput from /sys/class/net/<iface>/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)
# AIWANBAL_STATE_DIR aiwanbal runtime state (default: /tmp/aiwanbal)
# EYERIDE_IP Eyeride OpenWrt IP for GPS (default: 192.168.10.1)
# EYERIDE_IP Eyeride OpenWrt IP (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)
# 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 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
# 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_synology_send when executed directly.
# Cron mode: runs telemetry_send when executed directly.
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
# ── Configuration ──────────────────────────────────────────────────────────
: "${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}"
: "${EYERIDE_USER:=benmashborn}"
: "${WAN1_IFACE:=eth0}"
: "${WAN2_IFACE:=eth2}"
: "${PING_TARGET:=8.8.8.8}"
: "${STATE_DIR:=/tmp/busrouter}"
# Source per-device overrides if present
[ -f /etc/busrouter/telemetry.conf ] && . /etc/busrouter/telemetry.conf
_TELEM_SEQ=0
# ── Helpers ─────────────────────────────────────────────────────────────────────
# ── Helpers ────────────────────────────────────────────────────────────────
# Escape a string value for JSON (backslash and double-quote only).
# Escape a string for JSON (backslash + 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
}
# BusyBox-compatible float arithmetic via awk.
_awk_float() { awk "BEGIN { printf \"%.1f\", $1 }" 2>/dev/null || echo "null"; }
# ── 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.
# ── GPS: Eyeride ubus JSON-RPC ─────────────────────────────────────────────
# Authenticate to Eyeride ubus, return session token on stdout.
_eyeride_login() {
local _pass="${EYERIDE_PASSWORD:-}"
_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' \
@@ -76,10 +89,8 @@ _eyeride_login() {
}
# 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
_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' \
@@ -87,37 +98,36 @@ _eyeride_ubus_call() {
"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
# 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 (common OpenWrt gpsd ubus binding)
# 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
# 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=""
# 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 if we got coordinates
[ -n "$_fix" ] || _fix=2
_fix=2 # assume 2D fix from Eyeride
logger -t busrouter -p daemon.debug "eyeride_gps: ${_lat} ${_lon} fix=${_fix}"
printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
return 0
@@ -127,154 +137,234 @@ eyeride_gps() {
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
# ── GPS: IP Geolocation Fallback ───────────────────────────────────────────
# 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
# 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) || return 1
[ -n "$_resp" ] || return 1
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")
_lat=$(_json_get_num "$_resp" "lat")
_lon=$(_json_get_num "$_resp" "lon")
# 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
if [ -n "$_lat" ] && [ -n "$_lon" ]; then
_fix=1 # approximate (IP-based)
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
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}"
logger -t busrouter "ipgeo_gps: geolocation failed"
return 1
}
# 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
# GPS with fallback: try Eyeride first, then IP geolocation.
gps_fix() {
if eyeride_gps 2>/dev/null; then
return 0
fi
ipgeo_gps
}
# ── Eyeride Cellular Signal ────────────────────────────────────────────────
# 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
# 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
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")
# Also try signal-specific fields
[ -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}"
return 0
}
# ── 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.
# Usage: _ping_iface <iface> → "lat_ms loss_pct"
_ping_iface() {
_iface="$1"
# Try sudo ping (for Synology where ping needs root), fall back to direct ping
_out=$(sudo ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null || ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null)
if [ -z "$_out" ]; then
echo "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
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"
_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"
_loss=$(echo "$_out" | sed -n 's/.* \([0-9]*\)% packet loss.*/\1/p')
[ -z "$_loss" ] && _loss="null"
echo "${_avg} ${_loss}"
}
# Compute simple WAN score from latency and loss (0-100, higher = better).
# Score = max(0, 100 - latency_ms/2 - loss_pct*5)
_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}"
}
# Read bytes transferred from /sys/class/net/<iface>/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 <iface> → JSON object fragment
_collect_wan() {
_iface="$1"
# 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"
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 ───────────────────────────────────────────────────
# Build the full telemetry JSON payload (canonical GL format).
# Emits JSON to stdout; never fails — uses defaults for missing fields.
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
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
# GPS (Eyeride → IP geolocation fallback)
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
# WAN 1 + WAN 2
local wan1_json wan2_json
wan1_json=$(_synology_wan_json 1)
wan2_json=$(_synology_wan_json 2)
# WAN1 (primary uplink, typically Ethernet → modem_0001 in canonical format)
wan1_json=$(_collect_wan "$WAN1_IFACE")
# 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
# WAN2 (Eyeride cellular → wan in canonical format)
wan2_json=$(_collect_wan "$WAN2_IFACE")
# 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")
# Eyeride cellular signal for WAN2 (try to enrich with rsrp/rsrq/sinr)
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}')
_carrier=$(echo "$eyeride_sig" | awk '{print $5}')
# 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}"
# Build WAN2 JSON with cellular signal if available
if [ "$_rsrp" != "null" ] && [ -n "$_rsrp" ]; then
# Replace closing brace with signal fields
wan2_json=$(printf '%s' "$wan2_json" | sed 's/}$//')
_esc_tech=$(_json_str "${_tech:-unknown}")
_esc_carrier=$(_json_str "${_carrier:-Eyeride}")
wan2_json="${wan2_json},\"rsrp_dbm\":${_rsrp},\"rsrq_db\":${_rsrq},\"sinr_db\":${_sinr},\"technology\":\"${_esc_tech}\",\"carrier\":\"${_esc_carrier}\"}"
fi
# Active WAN: determine from routing table (default route interface)
sel_primary="modem_0001"
default_iface=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)
if [ "$default_iface" = "$WAN2_IFACE" ]; then
sel_primary="wan"
fi
# Starlink — detect if present
sl_lat="null" sl_dl="null" sl_obs="0" sl_outage="0"
# Starlink is typically on a USB ethernet adapter or separate VLAN
if ip link show 2>/dev/null | grep -qi starlink; then
# Basic Starlink check — if interface exists, try to get metrics
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='{"autonomous":true,"qos":{},"wanhealth":{}}'
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}" \
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}" \
"${wan1_json}" "${wan2_json}" \
"${sl_quality:-0}" "${sl_lat:-null}" "${sl_obs:-0}" "${sl_outage:-0}"
"${sl_lat}" "${sl_dl}" "${sl_obs}" "${sl_outage}" \
"${state}"
}
# ── Hub POST + Buffering ─────────────────────────────────────────────────────────
# ── 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
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)
@@ -283,16 +373,15 @@ _telemetry_try_post() {
# Retry all buffered payloads in timestamp order.
# Stops on first failure to avoid hammering a down hub.
telemetry_synology_flush() {
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_synology_flush: sent $(basename "$f")"
logger -t busrouter -p daemon.debug "telemetry_flush: sent $(basename "$f")"
else
logger -t busrouter "telemetry_synology_flush: hub unreachable, stopping"
logger -t busrouter "telemetry_flush: hub unreachable, stopping"
return 1
fi
done
@@ -301,20 +390,20 @@ telemetry_synology_flush() {
# 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_send() {
mkdir -p "$STATE_DIR" 2>/dev/null
telemetry_synology_flush 2>/dev/null || true
payload=$(telemetry_collect)
mkdir -p "$TELEMETRY_BUFFER_DIR"
telemetry_flush 2>/dev/null || true
mkdir -p "$TELEMETRY_BUFFER_DIR" 2>/dev/null
_TELEM_SEQ=$(( _TELEM_SEQ + 1 ))
local tmpfile="${TELEMETRY_BUFFER_DIR}/$(date +%s)_$$_${_TELEM_SEQ}.json"
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
@@ -322,16 +411,15 @@ telemetry_synology_send() {
if _telemetry_try_post "$tmpfile"; then
rm -f "$tmpfile"
logger -t busrouter -p daemon.debug "telemetry_synology_send: posted ok"
logger -t busrouter -p daemon.debug "telemetry_send: posted ok"
else
logger -t busrouter "telemetry_synology_send: hub unreachable, buffered $(basename "$tmpfile")"
logger -t busrouter "telemetry_send: hub unreachable, buffered $(basename "$tmpfile")"
fi
}
# ── Entry Point ──────────────────────────────────────────────────────────────────
# When executed directly (e.g. from cron), run a single telemetry cycle.
# ── Entry Point ────────────────────────────────────────────────────────────
case "${0##*/}" in
telemetry-synology.sh|telemetry-synology)
telemetry_synology_send
telemetry_send
;;
esac