feat: unified fleet telemetry — canonical format, GPS/IP geo, WAN health dashboard
- Rewrite telemetry-synology.sh: canonical GL format (modem_0001/wan keys), self-contained metrics (no aiwanbal), Eyeride GPS with IP geo fallback - Add /api/fleet-telemetry to hub: joins tunnel status with fleet Postgres - Update dashboard.html: per-device WAN health bars, GPS, signal strength - Fix hub/ingest.sh normalisation: remap old wan1/wan2 → modem_0001/wan - Bump SPK version: 0.1-0001 → 0.5.0-0001 for GL parity Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+107
-10
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+184
-1
@@ -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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -22,6 +24,13 @@ 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 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) ──────────
|
# ── Helper: check if a TCP port is open (tunnel active) ──────────
|
||||||
def _port_is_open(port):
|
def _port_is_open(port):
|
||||||
@@ -33,6 +42,84 @@ def _port_is_open(port):
|
|||||||
return False
|
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):
|
class HubHandler(BaseHTTPRequestHandler):
|
||||||
"""Handle hub registration, fleet status, and dashboard requests."""
|
"""Handle hub registration, fleet status, and dashboard requests."""
|
||||||
|
|
||||||
@@ -56,11 +143,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 +253,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 +336,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
@@ -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"
|
||||||
|
|||||||
@@ -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,64 +1,77 @@
|
|||||||
#!/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):
|
# GPS fallback chain:
|
||||||
|
# 1. Eyeride ubus JSON-RPC (gps / location service) → fix=2 or 3
|
||||||
|
# 2. ip-api.com geolocation by external IP → fix=1 (approximate)
|
||||||
|
#
|
||||||
|
# WAN mapping (canonical GL format):
|
||||||
|
# WAN1 (Ethernet/primary uplink) → modem_0001
|
||||||
|
# WAN2 (Eyeride cellular) → wan (with rsrp_dbm/rsrq_db/sinr_db)
|
||||||
|
#
|
||||||
|
# Self-collected metrics (no aiwanbal dependency):
|
||||||
|
# - Ping latency/loss per interface (using -I <iface>)
|
||||||
|
# - Throughput from /sys/class/net/<iface>/statistics/ (bytes delta between cycles)
|
||||||
|
# - Score computed from latency + loss (simple heuristic)
|
||||||
|
# - Eyeride cellular signal via ubus (if available)
|
||||||
|
#
|
||||||
|
# Config (/etc/busrouter/telemetry.conf, sourced as shell):
|
||||||
# TELEMETRY_HUB POST endpoint (default: http://10.88.0.1:8080/api/telemetry)
|
# TELEMETRY_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)
|
# 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 (default: 192.168.10.1)
|
||||||
# EYERIDE_IP Eyeride OpenWrt IP for GPS (default: 192.168.10.1)
|
|
||||||
# EYERIDE_PORT Eyeride ubus port (default: 8080)
|
# EYERIDE_PORT Eyeride ubus port (default: 8080)
|
||||||
# EYERIDE_USER Eyeride SSH/ubus username (default: root)
|
# EYERIDE_USER Eyeride ubus username (default: benmashborn)
|
||||||
# EYERIDE_PASSWORD Eyeride password (required for GPS)
|
# 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:
|
# Public API:
|
||||||
# eyeride_gps query GPS from Eyeride ubus; echo 'lat lon fix';
|
# eyeride_gps echo 'lat lon fix'; exit 1 on failure
|
||||||
# exit 1 if unavailable
|
# ipgeo_gps echo 'lat lon fix'; exit 1 on failure
|
||||||
# telemetry_synology_collect emit JSON payload to stdout from aiwanbal state files
|
# gps_fix try eyeride_gps, fall back to ipgeo_gps
|
||||||
# telemetry_synology_flush retry all buffered payloads; return 1 if hub still down
|
# telemetry_collect emit JSON payload to stdout (canonical GL format)
|
||||||
# telemetry_synology_send collect + flush + post; buffer on failure
|
# 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_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}"
|
||||||
|
|
||||||
|
# Source per-device overrides if present
|
||||||
|
[ -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).
|
# Escape a string for JSON (backslash + 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.
|
# BusyBox-compatible float arithmetic via awk.
|
||||||
_aiwanbal_val() {
|
_awk_float() { awk "BEGIN { printf \"%.1f\", $1 }" 2>/dev/null || echo "null"; }
|
||||||
local key="$1"
|
|
||||||
cat "${AIWANBAL_STATE_DIR}/${key}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 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.
|
# 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' \
|
||||||
@@ -76,10 +89,8 @@ _eyeride_login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Call a ubus method on the Eyeride with an active session token.
|
# 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,37 +98,36 @@ _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.
|
# Extract a numeric field from JSON (handles both integer and float).
|
||||||
# Emits 'lat lon fix_quality' on success; exit 1 on failure or no fix.
|
_json_get_num() {
|
||||||
eyeride_gps() {
|
echo "$1" | sed -n "s/.*\"$2\": *\(-\?[0-9.]*\).*/\1/p" | head -1
|
||||||
local _session _resp _lat _lon _fix
|
}
|
||||||
|
|
||||||
|
# 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
|
_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
|
_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
|
||||||
|
|
||||||
# Try to extract lat/lon from ubus response JSON
|
# Extract lat/lon from various possible field names
|
||||||
_lat=$(echo "$_resp" | sed 's/.*"latitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
_lat=$(_json_get_num "$_resp" "latitude")
|
||||||
_lon=$(echo "$_resp" | sed 's/.*"longitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
_lon=$(_json_get_num "$_resp" "longitude")
|
||||||
# Also try snake_case variants
|
[ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat")
|
||||||
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
[ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon")
|
||||||
[ "$_lon" = "$_resp" ] && _lon=$(echo "$_resp" | sed 's/.*"lon":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
[ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat_deg")
|
||||||
# Alt spelling
|
[ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon_deg")
|
||||||
[ "$_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 # assume 2D fix from Eyeride
|
||||||
[ -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
|
||||||
@@ -127,154 +137,234 @@ eyeride_gps() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Telemetry Collection ─────────────────────────────────────────────────────────
|
# ── GPS: IP Geolocation Fallback ───────────────────────────────────────────
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Read one WAN's metrics and emit a JSON object fragment.
|
# Query ip-api.com for approximate location. Emits 'lat lon fix'; exit 1 on failure.
|
||||||
# Usage: _synology_wan_json <wan_num>
|
ipgeo_gps() {
|
||||||
_synology_wan_json() {
|
_resp=$(curl -s --connect-timeout 5 "http://ip-api.com/json/" 2>/dev/null) || return 1
|
||||||
local n="$1"
|
[ -n "$_resp" ] || return 1
|
||||||
local score lat dl ul jitter loss rsrp rsrq sinr tech carrier modem
|
|
||||||
|
|
||||||
score=$(_aiwanbal_val "wan${n}_score_avg")
|
_lat=$(_json_get_num "$_resp" "lat")
|
||||||
lat=$(_aiwanbal_val "wan${n}_latency")
|
_lon=$(_json_get_num "$_resp" "lon")
|
||||||
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
|
if [ -n "$_lat" ] && [ -n "$_lon" ]; then
|
||||||
carrier=""
|
_fix=1 # approximate (IP-based)
|
||||||
case "$modem" in
|
logger -t busrouter -p daemon.debug "ipgeo_gps: ${_lat} ${_lon} fix=${_fix} (IP geolocation)"
|
||||||
eyeride) carrier="Eyeride" ;;
|
printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
|
||||||
peplink) carrier="Peplink" ;;
|
return 0
|
||||||
zte) carrier="ZTE" ;;
|
fi
|
||||||
mofi) carrier="Mofi" ;;
|
|
||||||
none|generic|"") carrier="" ;;
|
|
||||||
*) carrier="$modem" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
local esc_tech esc_carrier
|
logger -t busrouter "ipgeo_gps: geolocation failed"
|
||||||
esc_tech=$(_json_str "${tech:-unknown}")
|
return 1
|
||||||
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 with fallback: try Eyeride first, then IP geolocation.
|
||||||
# Emits JSON on stdout; never fails — uses defaults for all missing fields.
|
gps_fix() {
|
||||||
telemetry_synology_collect() {
|
if eyeride_gps 2>/dev/null; then
|
||||||
local now device_id uptime_s version
|
return 0
|
||||||
|
fi
|
||||||
|
ipgeo_gps
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Eyeride Cellular Signal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Query cellular signal from Eyeride ubus (network/wwan services).
|
||||||
|
# Returns: rsrp_dbm rsrq_db sinr_db technology carrier_str
|
||||||
|
_eyeride_signal() {
|
||||||
|
_session=$(_eyeride_login) 2>/dev/null || return 1
|
||||||
|
|
||||||
|
# Try network.wan status for modem info
|
||||||
|
_resp=$(_eyeride_ubus_call "$_session" "network" "status") 2>/dev/null
|
||||||
|
if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then
|
||||||
|
# Try alternative: wwan or modem service
|
||||||
|
_resp=$(_eyeride_ubus_call "$_session" "wwan" "status") 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
_rsrp=$(_json_get_num "$_resp" "rsrp")
|
||||||
|
_rsrq=$(_json_get_num "$_resp" "rsrq")
|
||||||
|
_sinr=$(_json_get_num "$_resp" "sinr")
|
||||||
|
_tech=$(_json_get_str "$_resp" "technology")
|
||||||
|
_carrier=$(_json_get_str "$_resp" "operator")
|
||||||
|
|
||||||
|
# Also try signal-specific fields
|
||||||
|
[ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "signal")
|
||||||
|
[ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "rssi")
|
||||||
|
|
||||||
|
printf '%s %s %s %s %s\n' "${_rsrp:-null}" "${_rsrq:-null}" "${_sinr:-null}" "${_tech:-unknown}" "${_carrier:-Eyeride}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── WAN Metric Collection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Ping an interface to get latency and loss.
|
||||||
|
# On Synology SRM: ping requires root. When running from cron (as root), it works.
|
||||||
|
# When run manually as non-root, gracefully returns null.
|
||||||
|
# Usage: _ping_iface <iface> → "lat_ms loss_pct"
|
||||||
|
_ping_iface() {
|
||||||
|
_iface="$1"
|
||||||
|
# Try sudo ping (for Synology where ping needs root), fall back to direct ping
|
||||||
|
_out=$(sudo ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null || ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null)
|
||||||
|
if [ -z "$_out" ]; then
|
||||||
|
echo "null null"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Check for permission denied / other errors
|
||||||
|
if echo "$_out" | grep -qE "permission denied|not permitted|unknown host|Network is unreachable"; then
|
||||||
|
echo "null null"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Extract avg rtt from last line.
|
||||||
|
# Standard ping: "rtt min/avg/max/mdev = 10.5/15.2/20.1/3.5 ms"
|
||||||
|
# BusyBox ping: "round-trip min/avg/max = 10.5/15.2/20.1 ms"
|
||||||
|
_avg=$(echo "$_out" | tail -1 | sed -n 's/.*=\ [0-9.]*\/\([0-9.]*\)\/.*/\1/p')
|
||||||
|
[ -z "$_avg" ] && _avg=$(echo "$_out" | tail -1 | sed -n 's/.*avg\/max.*=\ \([0-9.]*\)\/.*/\1/p')
|
||||||
|
[ -z "$_avg" ] && _avg="null"
|
||||||
|
# Extract loss percentage: "3 packets transmitted, 3 received, 0% packet loss"
|
||||||
|
_loss=$(echo "$_out" | sed -n 's/.* \([0-9]*\)% packet loss.*/\1/p')
|
||||||
|
[ -z "$_loss" ] && _loss="null"
|
||||||
|
echo "${_avg} ${_loss}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute simple WAN score from latency and loss (0-100, higher = better).
|
||||||
|
# Score = max(0, 100 - latency_ms/2 - loss_pct*5)
|
||||||
|
_compute_score() {
|
||||||
|
_lat="$1" _loss="$2"
|
||||||
|
if [ "$_lat" = "null" ] || [ "$_loss" = "null" ]; then
|
||||||
|
echo "0"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
_score=$(awk "BEGIN { s = 100 - ${_lat}/2 - ${_loss}*5; if (s < 0) s = 0; if (s > 100) s = 100; printf \"%d\", int(s) }" 2>/dev/null)
|
||||||
|
echo "${_score:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read bytes transferred from /sys/class/net/<iface>/statistics/.
|
||||||
|
# Returns delta from last reading stored in STATE_DIR.
|
||||||
|
_read_bytes_delta() {
|
||||||
|
_iface="$1" _direction="$2" # tx_bytes or rx_bytes
|
||||||
|
_stat_file="/sys/class/net/${_iface}/statistics/${_direction}"
|
||||||
|
_state_file="${STATE_DIR}/bytes_${_iface}_${_direction}"
|
||||||
|
[ -f "$_stat_file" ] || { echo "null"; return; }
|
||||||
|
mkdir -p "$STATE_DIR" 2>/dev/null
|
||||||
|
_current=$(cat "$_stat_file" 2>/dev/null)
|
||||||
|
_prev=$(cat "$_state_file" 2>/dev/null)
|
||||||
|
printf '%s\n' "$_current" > "$_state_file" 2>/dev/null || true
|
||||||
|
if [ -z "$_prev" ] || [ -z "$_current" ]; then
|
||||||
|
echo "null"; return
|
||||||
|
fi
|
||||||
|
_delta=$(( _current - _prev ))
|
||||||
|
if [ "$_delta" -lt 0 ]; then
|
||||||
|
_delta=0 # counter reset
|
||||||
|
fi
|
||||||
|
# Convert bytes to Mbps (bytes → Mb/s, assume ~60s interval)
|
||||||
|
_mbps=$(awk "BEGIN { printf \"%.1f\", ${_delta} * 8 / 60 / 1000000 }" 2>/dev/null)
|
||||||
|
echo "${_mbps:-0.0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect metrics for one WAN interface.
|
||||||
|
# Usage: _collect_wan <iface> → JSON object fragment
|
||||||
|
_collect_wan() {
|
||||||
|
_iface="$1"
|
||||||
|
|
||||||
|
# Ping metrics
|
||||||
|
_ping_result=$(_ping_iface "$_iface")
|
||||||
|
_lat=$(echo "$_ping_result" | awk '{print $1}')
|
||||||
|
_loss=$(echo "$_ping_result" | awk '{print $2}')
|
||||||
|
|
||||||
|
# Throughput (delta since last cycle)
|
||||||
|
_dl=$(_read_bytes_delta "$_iface" "rx_bytes")
|
||||||
|
_ul=$(_read_bytes_delta "$_iface" "tx_bytes")
|
||||||
|
|
||||||
|
# Score
|
||||||
|
_score=$(_compute_score "$_lat" "$_loss")
|
||||||
|
|
||||||
|
# Jitter — rough estimate from ping variation (not true jitter, but indicative)
|
||||||
|
_jitter="null"
|
||||||
|
|
||||||
|
printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}' \
|
||||||
|
"${_score}" "${_lat}" "${_dl}" "${_ul}" "${_jitter}" "${_loss}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Telemetry Collection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Build the full telemetry JSON payload (canonical GL format).
|
||||||
|
# Emits JSON to stdout; never fails — uses defaults for missing fields.
|
||||||
|
telemetry_collect() {
|
||||||
now=$(date +%s)
|
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 (Eyeride → IP geolocation fallback)
|
||||||
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
|
# WAN1 (primary uplink, typically Ethernet → modem_0001 in canonical format)
|
||||||
local wan1_json wan2_json
|
wan1_json=$(_collect_wan "$WAN1_IFACE")
|
||||||
wan1_json=$(_synology_wan_json 1)
|
|
||||||
wan2_json=$(_synology_wan_json 2)
|
|
||||||
|
|
||||||
# Active WAN — which one is currently carrying traffic
|
# WAN2 (Eyeride cellular → wan in canonical format)
|
||||||
local mode sel_primary
|
wan2_json=$(_collect_wan "$WAN2_IFACE")
|
||||||
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
|
|
||||||
|
|
||||||
# Starlink (may be attached to one of the WANs)
|
# Eyeride cellular signal for WAN2 (try to enrich with rsrp/rsrq/sinr)
|
||||||
local sl_lat sl_dl sl_obs sl_outage
|
eyeride_sig=$(_eyeride_signal 2>/dev/null)
|
||||||
sl_lat=$(_aiwanbal_val "wan0_starlink_latency")
|
_rsrp=$(echo "$eyeride_sig" | awk '{print $1}')
|
||||||
sl_dl=$(_aiwanbal_val "wan0_starlink_quality")
|
_rsrq=$(echo "$eyeride_sig" | awk '{print $2}')
|
||||||
sl_obs=$(_aiwanbal_val "wan0_starlink_obstruction")
|
_sinr=$(echo "$eyeride_sig" | awk '{print $3}')
|
||||||
sl_outage=$(_aiwanbal_val "wan0_starlink_outage")
|
_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)
|
# Build WAN2 JSON with cellular signal if available
|
||||||
# Store quality as-is; the hub can interpret it
|
if [ "$_rsrp" != "null" ] && [ -n "$_rsrp" ]; then
|
||||||
local sl_quality
|
# Replace closing brace with signal fields
|
||||||
sl_quality="${sl_dl:-0}"
|
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_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}" \
|
"${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.
|
# 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)
|
||||||
@@ -283,16 +373,15 @@ _telemetry_try_post() {
|
|||||||
|
|
||||||
# Retry all buffered payloads in timestamp order.
|
# Retry all buffered payloads in timestamp order.
|
||||||
# Stops on first failure to avoid hammering a down hub.
|
# Stops on first failure to avoid hammering a down hub.
|
||||||
telemetry_synology_flush() {
|
telemetry_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
|
||||||
@@ -301,20 +390,20 @@ telemetry_synology_flush() {
|
|||||||
# Collect metrics, flush old buffer, POST current payload.
|
# Collect metrics, flush old buffer, POST current payload.
|
||||||
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR.
|
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR.
|
||||||
# Never blocks the caller on hub downtime.
|
# Never blocks the caller on hub downtime.
|
||||||
telemetry_synology_send() {
|
telemetry_send() {
|
||||||
local payload
|
mkdir -p "$STATE_DIR" 2>/dev/null
|
||||||
payload=$(telemetry_synology_collect)
|
|
||||||
|
|
||||||
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 ))
|
_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
|
# 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
|
||||||
@@ -322,16 +411,15 @@ telemetry_synology_send() {
|
|||||||
|
|
||||||
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
|
telemetry_send
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user