From db04341f7c17725dd042a159b52be4272e286e1d Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 23 Jul 2026 01:47:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20fleet=20telemetry=20=E2=80=94?= =?UTF-8?q?=20canonical=20format,=20GPS/IP=20geo,=20WAN=20health=20dashboa?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- hub/dashboard.html | 117 ++++- hub/ingest.sh | 26 +- hub/server.py | 185 ++++++- kit-connect/INFO | 2 +- kit-connect/build.sh | 2 +- .../usr/lib/busrouter/telemetry-synology.sh | 484 +++++++++++------- 6 files changed, 596 insertions(+), 220 deletions(-) diff --git a/hub/dashboard.html b/hub/dashboard.html index b11b86b..0fa4c51 100644 --- a/hub/dashboard.html +++ b/hub/dashboard.html @@ -23,18 +23,36 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; .stat-card .lbl { font-size: 10px; color: var(--dim); text-transform: uppercase; margin-top: 2px; } .stat-card .num.online { color: var(--green); } .stat-card .num.offline { color: var(--red); } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 16px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 16px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } -.card h2 { font-size: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; } +.card h2 { font-size: 16px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; } .badge-gl { background: rgba(88,166,255,0.15); color: var(--blue); } .badge-synology { background: rgba(210,153,34,0.15); color: var(--amber); } +.badge-ver { background: rgba(139,148,158,0.15); color: var(--dim); font-size: 10px; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } .dot-up { background: var(--green); } .dot-down { background: var(--red); } -.row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.04); } +.row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.04); } .row .label { color: var(--dim); } .row .val { font-family: monospace; font-size: 12px; } +.wan-section { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); } +.wan-section .title { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; } +.wan-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 12px; } +.wan-bar .member { font-family: monospace; min-width: 90px; font-size: 11px; } +.wan-bar .score { font-weight: 700; min-width: 32px; text-align: right; } +.wan-bar .score.good { color: var(--green); } +.wan-bar .score.warn { color: var(--amber); } +.wan-bar .score.bad { color: var(--red); } +.wan-bar .metric { color: var(--dim); font-size: 11px; } +.wan-bar .signal { font-size: 10px; } +.wan-bar .signal.good { color: var(--green); } +.wan-bar .signal.warn { color: var(--amber); } +.wan-bar .signal.bad { color: var(--red); } +.active-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); margin-right: 4px; flex-shrink: 0; } +.gps-line { font-size: 11px; color: var(--dim); margin-top: 4px; } +.gps-line a { color: var(--blue); text-decoration: none; } +.gps-line a:hover { text-decoration: underline; } .access { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .access .title { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } .cmd { background: #0d1117; padding: 6px 10px; border-radius: 4px; font-family: monospace; @@ -43,6 +61,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; .cmd a:hover { text-decoration: underline; } .empty { color: var(--dim); text-align: center; padding: 60px 20px; font-size: 14px; } .refresh { color: var(--dim); font-size: 11px; text-align: right; margin-top: 20px; } +.telem-warn { font-size: 10px; color: var(--amber); margin-left: 8px; } @@ -59,8 +78,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; var HUB = window.location.origin; function loadFleet() { - fetch(HUB + '/api/fleet') - .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); }) + fetch(HUB + '/api/fleet-telemetry') + .then(function(r) { return r.ok ? r.json() : fetch(HUB + '/api/fleet').then(function(r2) { return r2.json(); }); }) .then(function(data) { render(data); }) .catch(function(e) { document.getElementById('fleet').innerHTML = @@ -69,18 +88,55 @@ function loadFleet() { document.getElementById('refresh').textContent = 'Last updated: ' + new Date().toLocaleTimeString(); } +function scoreClass(s) { + if (s == null) return ''; + if (s >= 70) return 'good'; + if (s >= 40) return 'warn'; + return 'bad'; +} + +function signalClass(rsrp) { + if (rsrp == null) return ''; + if (rsrp >= -85) return 'good'; + if (rsrp >= -105) return 'warn'; + return 'bad'; +} + +function signalBars(rsrp) { + if (rsrp == null) return '—'; + if (rsrp >= -85) return '▂▄▆█'; + if (rsrp >= -95) return '▂▄▆_'; + if (rsrp >= -105) return '▂▄__'; + if (rsrp >= -115) return '▂___'; + return '____'; +} + +function fmtLatency(ms) { return ms != null ? ms + ' ms' : '—'; } +function fmtLoss(pct) { return pct != null ? pct + '%' : '—'; } +function fmtScore(s) { return s != null ? s : '—'; } +function fmtUptime(s) { + if (s == null) return '—'; + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + if (h >= 24) { var d = Math.floor(h / 24); return d + 'd ' + (h % 24) + 'h'; } + return h + 'h ' + m + 'm'; +} + function render(data) { var devices = data.devices || []; var online = devices.filter(function(d) { return d.tunnel_up; }).length; + var telemOnline = data.telemetry_online || 0; document.getElementById('stats').innerHTML = '
' + devices.length + '
Registered
' + '
' + online + - '
Online
' + + '
Tunnel Online
' + '
' + (devices.length - online) + - '
Offline
' + - '
' + (data.hub || '') + + '
Tunnel Offline
' + + '
' + telemOnline + + '
Telemetry Active
' + + '
' + (data.hub || '') + '
Hub
'; var el = document.getElementById('fleet'); @@ -90,21 +146,62 @@ function render(data) { } el.innerHTML = devices.map(function(d) { - var isSynology = d.device_id.startsWith('x'); + var isSynology = d.platform === 'synology' || d.device_id.startsWith('x'); var badge = isSynology ? 'Synology' : 'GL-XE3000'; + var verBadge = d.version ? 'v' + esc(d.version) + '' : ''; var status = d.tunnel_up ? 'ONLINE' : 'OFFLINE'; + var telemWarn = d.tunnel_up && !d.online_telemetry + ? '(no telemetry)' : ''; var ssh = 'ssh -p ' + d.tunnel_port + ' kitadmin@' + data.hub; var web = d.dashboard_url; + // WAN health bars + var wanHtml = ''; + var wans = d.wan_summary || []; + if (wans.length) { + wanHtml = '
WAN Health
'; + for (var i = 0; i < wans.length; i++) { + var w = wans[i]; + var activeMarker = w.active ? '' : ''; + var bars = signalBars(w.rsrp_dbm); + wanHtml += '
' + + activeMarker + + '' + esc(w.member) + '' + + '' + fmtScore(w.score) + '' + + '' + fmtLatency(w.latency_ms) + ' / ' + fmtLoss(w.loss_pct) + ' loss' + + (w.rsrp_dbm != null + ? '' + bars + ' ' + w.rsrp_dbm + ' dBm' + : '') + + (w.carrier ? '' + esc(w.carrier) + '' : '') + + (w.technology && w.technology !== 'unknown' ? '' + esc(w.technology) + '' : '') + + '
'; + } + wanHtml += '
'; + } + + // GPS line + var gpsHtml = ''; + if (d.gps && d.gps.lat != null && d.gps.lon != null) { + var fixLabel = d.gps.fix === 1 ? ' (IP approx)' : (d.gps.fix >= 2 ? ' (GPS)' : ''); + gpsHtml = '
📍 ' + + Number(d.gps.lat).toFixed(4) + ', ' + Number(d.gps.lon).toFixed(4) + fixLabel + + ' — Google Maps' + + '
'; + } + return '
' + - '

' + esc(d.device_id) + ' ' + badge + ' ' + status + '

' + + '

' + esc(d.device_id) + ' ' + badge + verBadge + ' ' + status + telemWarn + '

' + '
Tunnel Port' + d.tunnel_port + '
' + '
Assigned' + (d.assigned || '--') + '
' + + (d.uptime_s ? '
Uptime' + fmtUptime(d.uptime_s) + '
' : '') + + (d.autonomous != null ? '
Autonomous' + (d.autonomous ? '✅ Enabled' : '❌ Disabled') + '
' : '') + + wanHtml + + gpsHtml + (d.tunnel_up ? '
' + '
Direct Access
' + diff --git a/hub/ingest.sh b/hub/ingest.sh index 3291d32..a591560 100644 --- a/hub/ingest.sh +++ b/hub/ingest.sh @@ -18,23 +18,31 @@ mkdir -p "$TELEM_DIR" "$BACKUP_DIR" # ── Normalise telemetry ──────────────────────────────────────────── # Both platforms POST to /api/telemetry. -# GL uses: modem_0001/wan keys, no "platform" field -# Synology uses: wan1/wan2 keys, "platform":"synology" -# Output: unified schema with platform field + wan1/wan2 keys +# Canonical format uses modem_0001/wan keys (matching GL standard). +# Handles three variants: +# - GL (no platform field): add platform="openwrt", pass through +# - GL (platform="openwrt"): already canonical, pass through +# - Old Synology (platform="synology" + wan1/wan2): remap to modem_0001/wan +# - New unified Synology (platform="synology" + modem_0001/wan): pass through normalise_telemetry() { - local json="$1" - local platform="" + json="$1" + platform="" platform=$(echo "$json" | grep -o '"platform":"[^"]*"' | cut -d'"' -f4) if [ -z "$platform" ]; then - # Pre-unified GL telemetry — add platform, remap keys + # Pre-unified GL telemetry — add platform field, keys already canonical platform="openwrt" json=$(echo "$json" | sed \ - -e 's/"modem_0001"/"wan1"/g' \ - -e 's/"platform":"openwrt"/"platform":"'"$platform"'"/') + -e 's/^{/{"platform":"'"$platform"'",/') + fi + + # Remap old Synology wan1/wan2 keys to canonical modem_0001/wan + if echo "$json" | grep -q '"wan1"'; then + json=$(echo "$json" | sed \ + -e 's/"wan1"/"modem_0001"/g' \ + -e 's/"wan2"/"wan"/g') fi - # Ensure both wan1 and wan2 keys exist (Synology uses wan1/wan2 natively) echo "$json" } diff --git a/hub/server.py b/hub/server.py index e98d0a8..c9a58e1 100644 --- a/hub/server.py +++ b/hub/server.py @@ -7,6 +7,8 @@ Listens on :8080. Routes: GET /api/register/ — assign tunnel port, return JSON GET /api/fleet — all registered devices with tunnel status + access links + GET /api/fleet-telemetry — fleet status enriched with telemetry + from fleet Postgres (WAN health, GPS) POST /api/authorize-key — authorize device tunnel key """ @@ -22,6 +24,13 @@ REGISTRY = "/opt/busfleet-hub/port-registry.json" DASHBOARD = "/opt/busfleet-hub/dashboard.html" HUB_IP = "162.243.83.36" +# Fleet Postgres connection (for telemetry enrichment) +FLEET_DB_HOST = os.environ.get("FLEET_DB_HOST", "167.172.237.162") +FLEET_DB_PORT = os.environ.get("FLEET_DB_PORT", "5432") +FLEET_DB_NAME = os.environ.get("FLEET_DB_NAME", "fleet") +FLEET_DB_USER = os.environ.get("FLEET_DB_USER", "fleet") +FLEET_DB_PASS = os.environ.get("FLEET_DB_PASS", "") + # ── Helper: check if a TCP port is open (tunnel active) ────────── def _port_is_open(port): @@ -33,6 +42,84 @@ def _port_is_open(port): return False +# ── Helper: query fleet Postgres for latest device_status ───────── +def _query_fleet_telemetry(): + """Query fleet Postgres for the latest telemetry data for all devices. + Returns a dict keyed by device_id, or empty dict if DB unreachable.""" + try: + import psycopg2 + conn = psycopg2.connect( + host=FLEET_DB_HOST, + port=FLEET_DB_PORT, + dbname=FLEET_DB_NAME, + user=FLEET_DB_USER, + password=FLEET_DB_PASS, + connect_timeout=5, + ) + cur = conn.cursor() + cur.execute(""" + SELECT d.device_id, d.vendor, d.model, + st.online, st.ts, st.version, st.uptime_s, + st.primary_member, st.active_wan, + st.gps_lat, st.gps_lon, st.gps_fix, + st.wan, st.starlink, st.state + FROM devices d + LEFT JOIN device_status st ON st.device_id = d.device_id + ORDER BY d.device_id + """) + rows = cur.fetchall() + cur.close() + conn.close() + + result = {} + for row in rows: + device_id = row[0] + wan_data = row[12] if row[12] else {} + # psycopg2 returns JSONB as dict + if isinstance(wan_data, str): + try: + wan_data = json.loads(wan_data) + except (json.JSONDecodeError, TypeError): + wan_data = {} + + starlink_data = row[13] if row[13] else {} + if isinstance(starlink_data, str): + try: + starlink_data = json.loads(starlink_data) + except (json.JSONDecodeError, TypeError): + starlink_data = {} + + state_data = row[14] if row[14] else {} + if isinstance(state_data, str): + try: + state_data = json.loads(state_data) + except (json.JSONDecodeError, TypeError): + state_data = {} + + result[device_id] = { + "vendor": row[1], + "model": row[2], + "online": row[3] if row[3] is not None else False, + "ts": str(row[4]) if row[4] else None, + "version": row[5], + "uptime_s": row[6], + "primary_member": row[7], + "active_wan": row[8], + "gps": { + "lat": float(row[9]) if row[9] is not None else None, + "lon": float(row[10]) if row[10] is not None else None, + "fix": row[11], + }, + "wan": wan_data, + "starlink": starlink_data, + "state": state_data, + } + return result + except Exception as e: + print(f"[hub] fleet telemetry query failed: {e}", file=sys.stderr) + return {} + + class HubHandler(BaseHTTPRequestHandler): """Handle hub registration, fleet status, and dashboard requests.""" @@ -56,11 +143,16 @@ class HubHandler(BaseHTTPRequestHandler): self.call_register_script(device_id) return - # Fleet status — all registered devices + # Fleet status — basic tunnel-only (original endpoint) if self.path == "/api/fleet": self.serve_fleet_status() return + # Fleet status — enriched with telemetry from fleet DB + if self.path == "/api/fleet-telemetry": + self.serve_fleet_telemetry() + return + self.send_json_error(404, "Not found") def do_POST(self): @@ -161,6 +253,73 @@ class HubHandler(BaseHTTPRequestHandler): "devices": devices, })) + def serve_fleet_telemetry(self): + """Build enriched fleet status: tunnel status + telemetry from fleet DB.""" + # Read port registry (tunnel info) + try: + with open(REGISTRY) as f: + registry = json.load(f) + except Exception: + registry = {} + + # Query fleet Postgres for telemetry + fleet_data = _query_fleet_telemetry() + + devices = [] + for dev_id, entry in registry.items(): + port = entry.get("tunnel_port", 0) + tunnel_up = _port_is_open(port) if port else False + + # Enrich with fleet telemetry + telem = fleet_data.get(dev_id, {}) + + # Derive platform from vendor or device_id prefix + vendor = telem.get("vendor", "") + if vendor == "synology" or dev_id.startswith("x"): + platform = "synology" + elif vendor == "glinet" or dev_id.startswith(("B", "C")): + platform = "openwrt" + else: + platform = telem.get("vendor", "unknown") + + # Format WAN summary for dashboard + wan_summary = _format_wan_summary(telem.get("wan", {}), telem.get("primary_member")) + + devices.append({ + "device_id": dev_id, + "tunnel_port": port, + "assigned": entry.get("assigned", ""), + "tunnel_up": tunnel_up, + "ssh_command": f"ssh -p {port} kitadmin@{HUB_IP}", + "dashboard_url": f"http://{HUB_IP}:{port}" if tunnel_up else None, + # Telemetry enrichment + "platform": platform, + "version": telem.get("version"), + "uptime_s": telem.get("uptime_s"), + "online_telemetry": telem.get("online", False), + "primary_member": telem.get("primary_member"), + "active_wan": telem.get("active_wan"), + "gps": telem.get("gps", {}), + "wan_summary": wan_summary, + "starlink": telem.get("starlink"), + "autonomous": telem.get("state", {}).get("autonomous"), + }) + + # Sort: tunnel_up first, then by device_id + devices.sort(key=lambda d: (not d["tunnel_up"], d["device_id"])) + + # Summary stats + online = sum(1 for d in devices if d["tunnel_up"]) + telemetry_online = sum(1 for d in devices if d.get("online_telemetry")) + + self.send_json(200, json.dumps({ + "hub": HUB_IP, + "device_count": len(devices), + "online": online, + "telemetry_online": telemetry_online, + "devices": devices, + })) + def send_json(self, status, body): """Send a JSON response.""" self.send_response(status) @@ -177,6 +336,30 @@ class HubHandler(BaseHTTPRequestHandler): print(f"[hub] {args[0]}", file=sys.stderr) +def _format_wan_summary(wan, primary_member): + """Format WAN data into a concise summary for the dashboard.""" + if not wan or not isinstance(wan, dict): + return [] + summary = [] + for member, metrics in wan.items(): + if not isinstance(metrics, dict): + continue + entry = { + "member": member, + "active": member == primary_member, + "score": metrics.get("score"), + "latency_ms": metrics.get("latency_ms"), + "loss_pct": metrics.get("loss_pct"), + "rsrp_dbm": metrics.get("rsrp_dbm"), + "carrier": metrics.get("carrier"), + "technology": metrics.get("technology"), + } + summary.append(entry) + # Sort: active first, then by score descending + summary.sort(key=lambda x: (not x["active"], -(x["score"] or 0))) + return summary + + def main(): port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 server = HTTPServer(("0.0.0.0", port), HubHandler) diff --git a/kit-connect/INFO b/kit-connect/INFO index 4e0328b..3adb6aa 100644 --- a/kit-connect/INFO +++ b/kit-connect/INFO @@ -1,5 +1,5 @@ package="kit-connect" -version="0.1-0001" +version="0.5.0-0001" description="Unified management connectivity for Pioneer bus fleet routers. Single SPK — deploys Tailscale + reverse SSH tunnel. Hub auto-assigns ports on install. One package, all routers. Compatible: RT2600ac, RT6600ax." displayname="KIT Bus Router Connect" maintainer="Keylink IT" diff --git a/kit-connect/build.sh b/kit-connect/build.sh index ec111ae..d1187eb 100755 --- a/kit-connect/build.sh +++ b/kit-connect/build.sh @@ -19,7 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" PKG_NAME="kit-connect" -VERSION="0.1-0001" +VERSION="0.5.0-0001" OUTPUT="${PKG_NAME}-${VERSION}.spk" BUILD_DIR="/tmp/kit-connect-build-$$" 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 db91799..96c8647 100755 --- a/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh +++ b/package/kit-busrouter/files/usr/lib/busrouter/telemetry-synology.sh @@ -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 ) +# - 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) -# 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 [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 0–100 -# 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 -_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 → "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//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 +_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 0–100, 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