Compare commits

..

6 Commits

Author SHA1 Message Date
kitadmin 8ea704f7e3 fix: use ingest HTTP API instead of direct Postgres for VPS telemetry; cache speedtest results per interface
- hub/server.py: replace psycopg2 Postgres connection with urllib HTTP call to
  ingest /api/devices endpoint (fleet Postgres is Docker-internal, not reachable
  from the VPS directly)
- telemetry-synology.sh: run_speedtest() now writes per-interface cache files
  (speedtest-result-<iface>); _cached_speedtest() reads cache if < 35 min old;
  _collect_wan() uses cached result so throughput shows in telemetry without
  blocking every 1-min cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 03:06:08 +00:00
kitadmin 8a648dc637 feat: smart WAN role detection, aiwanbal integration, speedtest support
- Auto-detect cellular vs fiber/Starlink regardless of physical port:
  aiwanbal modem_type → Eyeride subnet (192.168.10.x) → gateway patterns
- Integrate aiwanbal/SmartAiBalancer state as primary data source when
  available (fresh <120s); fall back to self-collected ping metrics
- Add carrier detection: AT&T for fiber gateways, Eyeride for cellular
- Add run_speedtest() with speedtest-cli/iperf3/curl download fallback
- Extract jitter (mdev) from ping output for richer metrics
- Consistent canonical GL format: modem_0001=cellular, wan=ethernet/fiber

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:29:49 +00:00
kitadmin 8dba04a1d0 fix: auto-detect WAN roles — cellular vs fiber
- Detect Eyeride cellular interface by checking 192.168.10.x subnet
- Map correctly: ethernet/fiber → wan, Eyeride 5G → modem_0001
- Add carrier detection: AT&T for fiber gateways, Eyeride for cellular
- Fix sel_primary: fiber is active by default unless cellular route exists
- Fixes console showing 'on cellular' when actually on AT&T fiber

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:19:18 +00:00
kitadmin 58ac96fd3c fix: prevent double-ping fallback corrupting WAN metrics
- sudo ping returns exit 1 on any packet loss, which triggered the
  || fallback to direct ping, causing duplicate output concatenation
- Now check exit code explicitly: only fall back on exit >= 2 (real error)
- Fixes loss_pct showing 100100 instead of 100 on failed WANs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:10:35 +00:00
kitadmin 91373df27d fix: sanitize newlines in telemetry output for valid JSON
- ipgeo_gps: pipe curl output through tr -d to strip newlines
- _ping_iface: pipe printf output through tr -d for newline safety

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:57:56 +00:00
kitadmin db04341f7c 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>
2026-07-23 01:47:02 +00:00
6 changed files with 745 additions and 253 deletions
+107 -10
View File
@@ -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 .lbl { font-size: 10px; color: var(--dim); text-transform: uppercase; margin-top: 2px; }
.stat-card .num.online { color: var(--green); } .stat-card .num.online { color: var(--green); }
.stat-card .num.offline { color: var(--red); } .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 { 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 { 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-gl { background: rgba(88,166,255,0.15); color: var(--blue); }
.badge-synology { background: rgba(210,153,34,0.15); color: var(--amber); } .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 { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.dot-up { background: var(--green); } .dot-up { background: var(--green); }
.dot-down { background: var(--red); } .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 .label { color: var(--dim); }
.row .val { font-family: monospace; font-size: 12px; } .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 { 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; } .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; .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; } .cmd a:hover { text-decoration: underline; }
.empty { color: var(--dim); text-align: center; padding: 60px 20px; font-size: 14px; } .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; } .refresh { color: var(--dim); font-size: 11px; text-align: right; margin-top: 20px; }
.telem-warn { font-size: 10px; color: var(--amber); margin-left: 8px; }
</style> </style>
</head> </head>
<body> <body>
@@ -59,8 +78,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
var HUB = window.location.origin; var HUB = window.location.origin;
function loadFleet() { function loadFleet() {
fetch(HUB + '/api/fleet') fetch(HUB + '/api/fleet-telemetry')
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function(r) { return r.ok ? r.json() : fetch(HUB + '/api/fleet').then(function(r2) { return r2.json(); }); })
.then(function(data) { render(data); }) .then(function(data) { render(data); })
.catch(function(e) { .catch(function(e) {
document.getElementById('fleet').innerHTML = document.getElementById('fleet').innerHTML =
@@ -69,18 +88,55 @@ function loadFleet() {
document.getElementById('refresh').textContent = 'Last updated: ' + new Date().toLocaleTimeString(); 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) { function render(data) {
var devices = data.devices || []; var devices = data.devices || [];
var online = devices.filter(function(d) { return d.tunnel_up; }).length; var online = devices.filter(function(d) { return d.tunnel_up; }).length;
var telemOnline = data.telemetry_online || 0;
document.getElementById('stats').innerHTML = document.getElementById('stats').innerHTML =
'<div class="stat-card"><div class="num">' + devices.length + '<div class="stat-card"><div class="num">' + devices.length +
'</div><div class="lbl">Registered</div></div>' + '</div><div class="lbl">Registered</div></div>' +
'<div class="stat-card"><div class="num online">' + online + '<div class="stat-card"><div class="num online">' + online +
'</div><div class="lbl">Online</div></div>' + '</div><div class="lbl">Tunnel Online</div></div>' +
'<div class="stat-card"><div class="num offline">' + (devices.length - online) + '<div class="stat-card"><div class="num offline">' + (devices.length - online) +
'</div><div class="lbl">Offline</div></div>' + '</div><div class="lbl">Tunnel Offline</div></div>' +
'<div class="stat-card"><div class="num">' + (data.hub || '') + '<div class="stat-card"><div class="num" style="font-size:20px">' + telemOnline +
'</div><div class="lbl">Telemetry Active</div></div>' +
'<div class="stat-card"><div class="num" style="font-size:18px">' + (data.hub || '') +
'</div><div class="lbl">Hub</div></div>'; '</div><div class="lbl">Hub</div></div>';
var el = document.getElementById('fleet'); var el = document.getElementById('fleet');
@@ -90,21 +146,62 @@ function render(data) {
} }
el.innerHTML = devices.map(function(d) { 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 var badge = isSynology
? '<span class="badge badge-synology">Synology</span>' ? '<span class="badge badge-synology">Synology</span>'
: '<span class="badge badge-gl">GL-XE3000</span>'; : '<span class="badge badge-gl">GL-XE3000</span>';
var verBadge = d.version ? '<span class="badge badge-ver">v' + esc(d.version) + '</span>' : '';
var status = d.tunnel_up var status = d.tunnel_up
? '<span><span class="dot dot-up"></span>ONLINE</span>' ? '<span><span class="dot dot-up"></span>ONLINE</span>'
: '<span><span class="dot dot-down"></span>OFFLINE</span>'; : '<span><span class="dot dot-down"></span>OFFLINE</span>';
var telemWarn = d.tunnel_up && !d.online_telemetry
? '<span class="telem-warn">(no telemetry)</span>' : '';
var ssh = 'ssh -p ' + d.tunnel_port + ' kitadmin@' + data.hub; var ssh = 'ssh -p ' + d.tunnel_port + ' kitadmin@' + data.hub;
var web = d.dashboard_url; var web = d.dashboard_url;
// WAN health bars
var wanHtml = '';
var wans = d.wan_summary || [];
if (wans.length) {
wanHtml = '<div class="wan-section"><div class="title">WAN Health</div>';
for (var i = 0; i < wans.length; i++) {
var w = wans[i];
var activeMarker = w.active ? '<span class="active-dot"></span>' : '<span style="display:inline-block;width:6px;margin-right:4px"></span>';
var bars = signalBars(w.rsrp_dbm);
wanHtml += '<div class="wan-bar">' +
activeMarker +
'<span class="member">' + esc(w.member) + '</span>' +
'<span class="score ' + scoreClass(w.score) + '">' + fmtScore(w.score) + '</span>' +
'<span class="metric">' + fmtLatency(w.latency_ms) + ' / ' + fmtLoss(w.loss_pct) + ' loss</span>' +
(w.rsrp_dbm != null
? '<span class="signal ' + signalClass(w.rsrp_dbm) + '" title="RSRP: ' + w.rsrp_dbm + ' dBm">' + bars + ' ' + w.rsrp_dbm + ' dBm</span>'
: '') +
(w.carrier ? '<span class="metric" style="margin-left:4px">' + esc(w.carrier) + '</span>' : '') +
(w.technology && w.technology !== 'unknown' ? '<span class="metric">' + esc(w.technology) + '</span>' : '') +
'</div>';
}
wanHtml += '</div>';
}
// 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 = '<div class="gps-line">📍 ' +
Number(d.gps.lat).toFixed(4) + ', ' + Number(d.gps.lon).toFixed(4) + fixLabel +
' — <a href="https://maps.google.com/?q=' + d.gps.lat + ',' + d.gps.lon + '" target="_blank">Google Maps</a>' +
'</div>';
}
return '<div class="card">' + return '<div class="card">' +
'<h2>' + esc(d.device_id) + ' ' + badge + ' <span style="font-size:13px">' + status + '</span></h2>' + '<h2>' + esc(d.device_id) + ' ' + badge + verBadge + ' <span style="font-size:13px">' + status + telemWarn + '</span></h2>' +
'<div class="row"><span class="label">Tunnel Port</span><span class="val">' + d.tunnel_port + '</span></div>' + '<div class="row"><span class="label">Tunnel Port</span><span class="val">' + d.tunnel_port + '</span></div>' +
'<div class="row"><span class="label">Assigned</span><span class="val">' + (d.assigned || '--') + '</span></div>' + '<div class="row"><span class="label">Assigned</span><span class="val">' + (d.assigned || '--') + '</span></div>' +
(d.uptime_s ? '<div class="row"><span class="label">Uptime</span><span class="val">' + fmtUptime(d.uptime_s) + '</span></div>' : '') +
(d.autonomous != null ? '<div class="row"><span class="label">Autonomous</span><span class="val">' + (d.autonomous ? '✅ Enabled' : '❌ Disabled') + '</span></div>' : '') +
wanHtml +
gpsHtml +
(d.tunnel_up (d.tunnel_up
? '<div class="access">' + ? '<div class="access">' +
'<div class="title">Direct Access</div>' + '<div class="title">Direct Access</div>' +
+17 -9
View File
@@ -18,23 +18,31 @@ mkdir -p "$TELEM_DIR" "$BACKUP_DIR"
# ── Normalise telemetry ──────────────────────────────────────────── # ── Normalise telemetry ────────────────────────────────────────────
# Both platforms POST to /api/telemetry. # Both platforms POST to /api/telemetry.
# GL uses: modem_0001/wan keys, no "platform" field # Canonical format uses modem_0001/wan keys (matching GL standard).
# Synology uses: wan1/wan2 keys, "platform":"synology" # Handles three variants:
# Output: unified schema with platform field + wan1/wan2 keys # - 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() { normalise_telemetry() {
local json="$1" json="$1"
local platform="" platform=""
platform=$(echo "$json" | grep -o '"platform":"[^"]*"' | cut -d'"' -f4) platform=$(echo "$json" | grep -o '"platform":"[^"]*"' | cut -d'"' -f4)
if [ -z "$platform" ]; then if [ -z "$platform" ]; then
# Pre-unified GL telemetry — add platform, remap keys # Pre-unified GL telemetry — add platform field, keys already canonical
platform="openwrt" platform="openwrt"
json=$(echo "$json" | sed \ json=$(echo "$json" | sed \
-e 's/"modem_0001"/"wan1"/g' \ -e 's/^{/{"platform":"'"$platform"'",/')
-e 's/"platform":"openwrt"/"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 fi
# Ensure both wan1 and wan2 keys exist (Synology uses wan1/wan2 natively)
echo "$json" echo "$json"
} }
+146 -1
View File
@@ -7,6 +7,8 @@ Listens on :8080. Routes:
GET /api/register/<DEVICE_ID> — assign tunnel port, return JSON GET /api/register/<DEVICE_ID> — assign tunnel port, return JSON
GET /api/fleet — all registered devices with GET /api/fleet — all registered devices with
tunnel status + access links 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 POST /api/authorize-key — authorize device tunnel key
""" """
@@ -15,12 +17,14 @@ import os
import socket import socket
import subprocess import subprocess
import sys import sys
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
REGISTER_SH = "/opt/busfleet-hub/register.sh" REGISTER_SH = "/opt/busfleet-hub/register.sh"
REGISTRY = "/opt/busfleet-hub/port-registry.json" REGISTRY = "/opt/busfleet-hub/port-registry.json"
DASHBOARD = "/opt/busfleet-hub/dashboard.html" DASHBOARD = "/opt/busfleet-hub/dashboard.html"
HUB_IP = "162.243.83.36" HUB_IP = "162.243.83.36"
FLEET_API = os.environ.get("FLEET_API", "http://167.172.237.162:8080/api/devices")
# ── Helper: check if a TCP port is open (tunnel active) ────────── # ── Helper: check if a TCP port is open (tunnel active) ──────────
@@ -33,6 +37,51 @@ def _port_is_open(port):
return False return False
# ── Helper: query fleet ingest API for latest device_status ──────
def _query_fleet_telemetry():
"""Fetch device status from the fleet ingest API.
Returns a dict keyed by device_id, or empty dict on error."""
try:
with urllib.request.urlopen(FLEET_API, timeout=5) as resp:
rows = json.loads(resp.read())
result = {}
for row in rows:
dev = row.get("device_id")
if not dev:
continue
wan = row.get("wan") or {}
if isinstance(wan, str):
wan = json.loads(wan) if wan else {}
starlink = row.get("starlink") or {}
if isinstance(starlink, str):
starlink = json.loads(starlink) if starlink else {}
state = row.get("state") or {}
if isinstance(state, str):
state = json.loads(state) if state else {}
result[dev] = {
"vendor": row.get("vendor"),
"model": row.get("model"),
"online": row.get("online") or False,
"ts": row.get("ts"),
"version": row.get("version"),
"uptime_s": row.get("uptime_s"),
"primary_member": row.get("primary_member"),
"active_wan": row.get("active_wan"),
"gps": {
"lat": row.get("gps_lat"),
"lon": row.get("gps_lon"),
"fix": row.get("gps_fix"),
},
"wan": wan,
"starlink": starlink,
"state": state,
}
return result
except Exception as e:
print(f"[hub] fleet telemetry query failed: {e}", file=sys.stderr)
return {}
class HubHandler(BaseHTTPRequestHandler): class HubHandler(BaseHTTPRequestHandler):
"""Handle hub registration, fleet status, and dashboard requests.""" """Handle hub registration, fleet status, and dashboard requests."""
@@ -56,11 +105,16 @@ class HubHandler(BaseHTTPRequestHandler):
self.call_register_script(device_id) self.call_register_script(device_id)
return return
# Fleet status — all registered devices # Fleet status — basic tunnel-only (original endpoint)
if self.path == "/api/fleet": if self.path == "/api/fleet":
self.serve_fleet_status() self.serve_fleet_status()
return 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") self.send_json_error(404, "Not found")
def do_POST(self): def do_POST(self):
@@ -161,6 +215,73 @@ class HubHandler(BaseHTTPRequestHandler):
"devices": devices, "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): def send_json(self, status, body):
"""Send a JSON response.""" """Send a JSON response."""
self.send_response(status) self.send_response(status)
@@ -177,6 +298,30 @@ class HubHandler(BaseHTTPRequestHandler):
print(f"[hub] {args[0]}", file=sys.stderr) 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(): def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
server = HTTPServer(("0.0.0.0", port), HubHandler) server = HTTPServer(("0.0.0.0", port), HubHandler)
+1 -1
View File
@@ -1,5 +1,5 @@
package="kit-connect" 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." 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" displayname="KIT Bus Router Connect"
maintainer="Keylink IT" maintainer="Keylink IT"
+1 -1
View File
@@ -19,7 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
PKG_NAME="kit-connect" PKG_NAME="kit-connect"
VERSION="0.1-0001" VERSION="0.5.0-0001"
OUTPUT="${PKG_NAME}-${VERSION}.spk" OUTPUT="${PKG_NAME}-${VERSION}.spk"
BUILD_DIR="/tmp/kit-connect-build-$$" BUILD_DIR="/tmp/kit-connect-build-$$"
@@ -1,85 +1,79 @@
#!/bin/sh #!/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 # Synology RT2600ac routers have NO built-in 5G modem or GPS. Cellular comes
# Eyeride Eyenet (OpenWrt ubus) for GPS position. Posts JSON to the fleet hub # from an external Eyeride device (192.168.10.1:8080) which provides GPS and
# over Tailscale with disk-buffered retry on failure. # signal metrics via OpenWrt ubus JSON-RPC.
# #
# Config (environment variables): # Smart WAN role detection: cellular can be on ANY port (WAN1 or WAN2).
# TELEMETRY_HUB POST endpoint (default: http://10.88.0.1:8080/api/telemetry) # The script auto-detects based on:
# TELEMETRY_BUFFER_DIR disk buffer for failed POSTs (default: /tmp/busrouter/telemetry-buf) # 1. aiwanbal/SmartAiBalancer modem_type fields (eyeride/peplink/zte = cellular)
# AIWANBAL_STATE_DIR aiwanbal runtime state (default: /tmp/aiwanbal) # 2. Eyeride reachability at 192.168.10.1
# EYERIDE_IP Eyeride OpenWrt IP for GPS (default: 192.168.10.1) # 3. Gateway + carrier pattern analysis
# EYERIDE_PORT Eyeride ubus port (default: 8080) # This ensures correct mapping regardless of physical port assignment.
# EYERIDE_USER Eyeride SSH/ubus username (default: root)
# EYERIDE_PASSWORD Eyeride password (required for GPS)
# #
# Public API: # Data sources (preferred order):
# eyeride_gps query GPS from Eyeride ubus; echo 'lat lon fix'; # 1. aiwanbal state files (/tmp/aiwanbal/) — richer scoring + failover decisions
# exit 1 if unavailable # 2. Self-collected ping metrics (fallback when aiwanbal not installed)
# 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
# #
# Cron mode: runs telemetry_synology_send when executed directly. # 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
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}" # ── Configuration ──────────────────────────────────────────────────────────
: "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}" : "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}"
: "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}" : "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}"
: "${AIWANBAL_STATE_DIR:=/tmp/aiwanbal}"
: "${EYERIDE_IP:=192.168.10.1}" : "${EYERIDE_IP:=192.168.10.1}"
: "${EYERIDE_PORT:=8080}" : "${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}"
: "${AIWANBAL_DIR:=/tmp/aiwanbal}"
[ -f /etc/busrouter/telemetry.conf ] && . /etc/busrouter/telemetry.conf
_TELEM_SEQ=0 _TELEM_SEQ=0
# ── Helpers ───────────────────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────────────
# Escape a string value for JSON (backslash and double-quote only).
_json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } _json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
# Read an aiwanbal state file, return empty string if missing. # Read a file, return empty string if missing.
_aiwanbal_val() { _read_file() { cat "$1" 2>/dev/null || true; }
local key="$1"
cat "${AIWANBAL_STATE_DIR}/${key}" 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
} }
# ── Eyeride GPS (ubus JSON-RPC) ───────────────────────────────────────────────── # ── GPS: Eyeride 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.
# Authenticate to Eyeride ubus, return session token on stdout.
_eyeride_login() { _eyeride_login() {
local _pass="${EYERIDE_PASSWORD:-}" _pass="${EYERIDE_PASSWORD:-}"
[ -n "$_pass" ] || return 1 [ -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_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 \ _login_resp=$(curl -s --connect-timeout 5 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$_login_body" \ -d "$_login_body" \
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null) || return 1 "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 if [ -z "$_login_resp" ] || echo "$_login_resp" | grep -q '"result":\[6\]'; then
return 1 return 1
fi fi
_session=$(echo "$_login_resp" | sed 's/.*"ubus_rpc_session":"\([^"]*\)".*/\1/') _session=$(echo "$_login_resp" | sed 's/.*"ubus_rpc_session":"\([^"]*\)".*/\1/')
[ -n "$_session" ] && [ "$_session" != "$_login_resp" ] || return 1 [ -n "$_session" ] && [ "$_session" != "$_login_resp" ] || return 1
printf '%s\n' "$_session" printf '%s\n' "$_session"
} }
# Call a ubus method on the Eyeride with an active session token.
# Usage: _eyeride_ubus_call <session> <service> <method> [params_json]
_eyeride_ubus_call() { _eyeride_ubus_call() {
local _session="$1" _svc="$2" _method="$3" _params="${4:-{}}" _session="$1" _svc="$2" _method="$3" _params="${4:-{}}"
local _body
_body="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"call\",\"params\":[\"${_session}\",\"${_svc}\",\"${_method}\",${_params}]}" _body="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"call\",\"params\":[\"${_session}\",\"${_svc}\",\"${_method}\",${_params}]}"
curl -s --connect-timeout 5 -X POST \ curl -s --connect-timeout 5 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
@@ -87,251 +81,499 @@ _eyeride_ubus_call() {
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null "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() { eyeride_gps() {
local _session _resp _lat _lon _fix
_session=$(_eyeride_login) || return 1 _session=$(_eyeride_login) || return 1
# Try 'gps' service first (common OpenWrt gpsd ubus binding)
_resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null _resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null
if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then 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 _resp=$(_eyeride_ubus_call "$_session" "location" "status") 2>/dev/null
fi fi
_lat=$(_json_get_num "$_resp" "latitude")
# Try to extract lat/lon from ubus response JSON _lon=$(_json_get_num "$_resp" "longitude")
_lat=$(echo "$_resp" | sed 's/.*"latitude":\([0-9.-]*\).*/\1/' 2>/dev/null) [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat")
_lon=$(echo "$_resp" | sed 's/.*"longitude":\([0-9.-]*\).*/\1/' 2>/dev/null) [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon")
# Also try snake_case variants [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat_deg")
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat":\([0-9.-]*\).*/\1/' 2>/dev/null) [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon_deg")
[ "$_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=""
if [ -n "$_lat" ] && [ -n "$_lon" ]; then if [ -n "$_lat" ] && [ -n "$_lon" ]; then
_fix=2 # assume 2D fix if we got coordinates _fix=2
[ -n "$_fix" ] || _fix=2
logger -t busrouter -p daemon.debug "eyeride_gps: ${_lat} ${_lon} fix=${_fix}" logger -t busrouter -p daemon.debug "eyeride_gps: ${_lat} ${_lon} fix=${_fix}"
printf '%s %s %s\n' "$_lat" "$_lon" "$_fix" printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
return 0 return 0
fi fi
logger -t busrouter "eyeride_gps: no fix from Eyeride" logger -t busrouter "eyeride_gps: no fix from Eyeride"
return 1 return 1
} }
# ── Telemetry Collection ───────────────────────────────────────────────────────── ipgeo_gps() {
# _resp=$(curl -s --connect-timeout 5 "http://ip-api.com/json/" 2>/dev/null | tr -d '\n\r') || return 1
# State files read from AIWANBAL_STATE_DIR (written by aiwanbal daemon each cycle): [ -n "$_resp" ] || return 1
# wan1_score_avg, wan2_score_avg integer 0100 _lat=$(_json_get_num "$_resp" "lat")
# wan1_latency, wan2_latency ms _lon=$(_json_get_num "$_resp" "lon")
# wan1_throughput, wan2_throughput B/s (converted to Mbps below) if [ -n "$_lat" ] && [ -n "$_lon" ]; then
# wan1_upload, wan2_upload B/s _fix=1
# wan1_jitter, wan2_jitter ms logger -t busrouter -p daemon.debug "ipgeo_gps: ${_lat} ${_lon} fix=${_fix} (IP geolocation)"
# wan1_packet_loss, wan2_packet_loss % printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
# wan1_rsrp, wan2_rsrp dBm (cellular signal) return 0
# wan1_rsrq, wan2_rsrq dB fi
# wan1_sinr, wan2_sinr dB return 1
# 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
# 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
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")
# 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
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}"
} }
# Build the full telemetry JSON payload from aiwanbal state files. gps_fix() {
# Emits JSON on stdout; never fails — uses defaults for all missing fields. if eyeride_gps 2>/dev/null; then return 0; fi
telemetry_synology_collect() { ipgeo_gps
local now device_id uptime_s version }
# ── 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)
[ -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"
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
[ -z "$_dl" ] || [ "$_dl" = "0" ] && _dl="0.0"
[ -z "$_ul" ] || [ "$_ul" = "0" ] && _ul="0.0"
fi
printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}' \
"${_score}" "${_lat}" "${_dl}" "${_ul}" "${_jitter}" "${_loss}"
}
# ── 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)
[ -n "$_mtime" ] || return 1
_age=$(( $(date +%s) - _mtime ))
[ "$_age" -lt 2100 ] || return 1
cat "$_f"
}
# ── Telemetry Collection ───────────────────────────────────────────────────
telemetry_collect() {
now=$(date +%s) now=$(date +%s)
device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname) device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname)
uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null) uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null)
version=$(cat /etc/busrouter/version 2>/dev/null || echo "dev") version=$(cat /etc/busrouter/version 2>/dev/null || echo "dev")
# GPS # GPS
local gps_lat gps_lon gps_fix gps_lat="null" gps_lon="null" gps_fix="null"
if gps_out=$(eyeride_gps 2>/dev/null); then if gps_out=$(gps_fix 2>/dev/null); then
read -r gps_lat gps_lon gps_fix <<-EOF gps_lat=$(echo "$gps_out" | awk '{print $1}')
$gps_out gps_lon=$(echo "$gps_out" | awk '{print $2}')
EOF gps_fix=$(echo "$gps_out" | awk '{print $3}')
fi fi
# WAN 1 + WAN 2 # Smart WAN role detection
local wan1_json wan2_json if _roles=$(_detect_wan_roles 2>/dev/null); then
wan1_json=$(_synology_wan_json 1) _cell_iface=$(echo "$_roles" | awk '{print $1}')
wan2_json=$(_synology_wan_json 2) _eth_iface=$(echo "$_roles" | awk '{print $2}')
# 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 else
sel_primary="wan1" _cell_iface="$WAN2_IFACE"
_eth_iface="$WAN1_IFACE"
fi fi
;;
*)
# Load-balance mode: both are active; wan1 is primary
sel_primary="wan1"
;;
esac
# Starlink (may be attached to one of the WANs) # Map physical interfaces to aiwanbal wan numbers
local sl_lat sl_dl sl_obs sl_outage _cell_num="2" _eth_num="1"
sl_lat=$(_aiwanbal_val "wan0_starlink_latency") if [ "$_cell_iface" = "$WAN1_IFACE" ]; then
sl_dl=$(_aiwanbal_val "wan0_starlink_quality") _cell_num="1"
sl_obs=$(_aiwanbal_val "wan0_starlink_obstruction") fi
sl_outage=$(_aiwanbal_val "wan0_starlink_outage") if [ "$_eth_iface" = "$WAN2_IFACE" ]; then
_eth_num="2"
fi
# Convert Starlink quality to bps (quality is a score 0100, not bps) # Collect WAN metrics
# Store quality as-is; the hub can interpret it _modem_json=$(_collect_wan "$_cell_iface" "$_cell_num")
local sl_quality _ether_json=$(_collect_wan "$_eth_iface" "$_eth_num")
sl_quality="${sl_dl:-0}"
# 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":{}}'
local esc_id esc_ver esc_sel
esc_id=$(_json_str "$device_id") esc_id=$(_json_str "$device_id")
esc_ver=$(_json_str "$version") esc_ver=$(_json_str "$version")
esc_sel=$(_json_str "$sel_primary") 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' \ 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_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" "${esc_sel}" \
"${gps_lat:-null}" "${gps_lon:-null}" "${gps_fix:-null}" \ "${gps_lat}" "${gps_lon}" "${gps_fix}" \
"${wan1_json}" "${wan2_json}" \ "${_modem_json}" "${_ether_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() { _telemetry_try_post() {
local file="$1" file="$1"
local code
code=$(curl -sf -X POST -H "Content-Type: application/json" \ code=$(curl -sf -X POST -H "Content-Type: application/json" \
--data-binary @"$file" -o /dev/null -w "%{http_code}" \ --data-binary @"$file" -o /dev/null -w "%{http_code}" \
--max-time 10 "$TELEMETRY_HUB" 2>/dev/null) --max-time 10 "$TELEMETRY_HUB" 2>/dev/null)
case "$code" in 2*) return 0 ;; *) return 1 ;; esac case "$code" in 2*) return 0 ;; *) return 1 ;; esac
} }
# Retry all buffered payloads in timestamp order. telemetry_flush() {
# Stops on first failure to avoid hammering a down hub.
telemetry_synology_flush() {
[ -d "$TELEMETRY_BUFFER_DIR" ] || return 0 [ -d "$TELEMETRY_BUFFER_DIR" ] || return 0
local f
for f in "$TELEMETRY_BUFFER_DIR"/*.json; do for f in "$TELEMETRY_BUFFER_DIR"/*.json; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
if _telemetry_try_post "$f"; then if _telemetry_try_post "$f"; then
rm -f "$f" 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 else
logger -t busrouter "telemetry_synology_flush: hub unreachable, stopping" logger -t busrouter "telemetry_flush: hub unreachable, stopping"
return 1 return 1
fi fi
done done
} }
# Collect metrics, flush old buffer, POST current payload. telemetry_send() {
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR. mkdir -p "$STATE_DIR" 2>/dev/null
# Never blocks the caller on hub downtime. payload=$(telemetry_collect)
telemetry_synology_send() { telemetry_flush 2>/dev/null || true
local payload mkdir -p "$TELEMETRY_BUFFER_DIR" 2>/dev/null
payload=$(telemetry_synology_collect)
telemetry_synology_flush 2>/dev/null || true
mkdir -p "$TELEMETRY_BUFFER_DIR"
_TELEM_SEQ=$(( _TELEM_SEQ + 1 )) _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) buf_count=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | wc -l)
if [ "${buf_count:-0}" -ge 60 ]; then if [ "${buf_count:-0}" -ge 60 ]; then
local oldest
oldest=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | sort | head -n 1) oldest=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | sort | head -n 1)
[ -f "$oldest" ] && rm -f "$oldest" [ -f "$oldest" ] && rm -f "$oldest"
fi fi
printf '%s\n' "$payload" > "$tmpfile" printf '%s\n' "$payload" > "$tmpfile"
if _telemetry_try_post "$tmpfile"; then if _telemetry_try_post "$tmpfile"; then
rm -f "$tmpfile" 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 else
logger -t busrouter "telemetry_synology_send: hub unreachable, buffered $(basename "$tmpfile")" logger -t busrouter "telemetry_send: hub unreachable, buffered $(basename "$tmpfile")"
fi fi
} }
# ── Entry Point ────────────────────────────────────────────────────────────────── # ── Entry Point ────────────────────────────────────────────────────────────
# When executed directly (e.g. from cron), run a single telemetry cycle.
case "${0##*/}" in case "${0##*/}" in
telemetry-synology.sh|telemetry-synology) telemetry-synology.sh|telemetry-synology)
telemetry_synology_send # 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 esac