#!/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.
#
# 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)
#   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.

# ── 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}"

# 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"; }

# ── 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}]}"
	curl -s --connect-timeout 5 -X POST \
		-H 'Content-Type: application/json' \
		-d "$_body" \
		"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
		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)
		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
	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.
# 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 <iface> → "lat_ms loss_pct"
_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
	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"
	printf '%s %s\n' "${_avg}" "${_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() {
	_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 ───────────────────────────────────────────────────

# 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.
_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
		_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
	# For the primary/ethernet interface, try to detect from gateway
	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
		fi
	fi
	# Fallback
	echo ""
}

# Add carrier and identity fields to a WAN JSON object.
# Usage: _add_wan_identity <wan_json> <iface>
_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/}$//'
	if [ -n "$_carrier" ]; then
		printf ',"carrier":"%s"}' "${_esc_carrier}"
	else
		printf '}'
	fi
}

# 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 (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

	# Auto-detect which interface is cellular vs ethernet/fiber
	_cell_iface="" _eth_iface=""
	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

	# Collect metrics for both WANs
	_eth_json=$(_collect_wan "$_eth_iface")
	_cell_json=$(_collect_wan "$_cell_iface")

	# Add carrier identity to each WAN
	_eth_json=$(_add_wan_identity "$_eth_json" "$_eth_iface")
	_cell_json=$(_add_wan_identity "$_cell_json" "$_cell_iface")

	# Eyeride cellular signal for the cellular interface
	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}}"
		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)
	if [ "$default_iface" = "$_cell_iface" ]; then
		sel_primary="modem_0001"  # cellular is the active path
	fi

	# Starlink — detect if present
	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='{"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 ───────────────────────────────────────────────────

# 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" \
		--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
	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() {
	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"
	else
		logger -t busrouter "telemetry_send: hub unreachable, buffered $(basename "$tmpfile")"
	fi
}

# ── Entry Point ────────────────────────────────────────────────────────────
case "${0##*/}" in
	telemetry-synology.sh|telemetry-synology)
		telemetry_send
		;;
esac
