From 6d0a8227a4dea25e12899ccc59a4b7447ed4afbb Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Tue, 9 Jun 2026 19:40:11 -0400 Subject: [PATCH] AP-role metrics: client telemetry replaces dead STA/campground metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metrics.lua: drop vif-sta0 station + campground-gateway sections (roof is a dumb AP since 2026-06-07); add skyweave_ap_* families — client count, per-client signal/SNR/bitrates/idle/association-time from iwinfo assoclist, noise floor, txpower, channel. Ping targets now chateau_gateway + talos_via_tunnel + internet (DNS). - skyweave-push.sh: PUT instead of POST so departed-client series and retired families don't linger on the pushgateway; skip push when the local metrics fetch fails. Co-Authored-By: Claude Fable 5 --- skyweave-push.sh | 8 +- skyweave/handlers/metrics.lua | 207 +++++++++++++++++++++------------- 2 files changed, 138 insertions(+), 77 deletions(-) diff --git a/skyweave-push.sh b/skyweave-push.sh index d6e0169..de26fd0 100755 --- a/skyweave-push.sh +++ b/skyweave-push.sh @@ -1,9 +1,15 @@ #!/bin/sh # Push skyweave metrics to Prometheus pushgateway every 30 seconds. +# PUT (not POST) so the whole group is replaced each cycle — per-client +# series for departed stations don't linger. Skips the push if the local +# metrics fetch fails, so a CGI hiccup never blanks the group. PUSHGW="http://192.168.88.44:9091/metrics/job/rvlink/instance/rvlink" METRICS_URL="http://127.0.0.1:42069/cgi-bin/skyweave.lua/metrics" while true; do - curl -sf "$METRICS_URL" | curl -sf --data-binary @- "$PUSHGW" >/dev/null 2>&1 + BODY=$(curl -sf "$METRICS_URL") + if [ -n "$BODY" ]; then + printf '%s\n' "$BODY" | curl -sf -X PUT --data-binary @- "$PUSHGW" >/dev/null 2>&1 + fi sleep 30 done diff --git a/skyweave/handlers/metrics.lua b/skyweave/handlers/metrics.lua index 04999eb..994ae2c 100644 --- a/skyweave/handlers/metrics.lua +++ b/skyweave/handlers/metrics.lua @@ -1,6 +1,9 @@ -- skyweave/handlers/metrics.lua -- Prometheus-compatible metrics endpoint for the RVLink roof unit. --- Exposes WiFi signal quality, bitrates, active gateway, system load, memory, uptime, and ping stats. +-- AP-role edition (since 2026-06-07 the roof is a dumb outdoor AP, not a +-- campground-WiFi station): exposes AP client telemetry (count, per-client +-- signal/bitrate), radio noise floor, active gateway, system load, memory, +-- uptime, and connectivity checks toward the Chateau / home tunnel / internet. local utils = require("skyweave.utils") @@ -33,16 +36,28 @@ local function emit(out, help, type_, name, labels, value) table.insert(out, name .. label_str .. " " .. tostring(value)) end --- Parse the leading integer dBm from strings like "-65 dBm" or "-65 [-65, -66] dBm". -local function parse_dbm(s) - if not s then return nil end - return tonumber(s:match("^%s*(-?%d+)")) -end - --- Parse the leading decimal Mbit/s from strings like "54.0 MBit/s" or "6.0 MBit/s MCS 0". -local function parse_mbps(s) - if not s then return nil end - return tonumber(s:match("^%s*(%d+%.?%d*)")) +-- Render a metric family with multiple samples: HELP/TYPE once, then all +-- sample lines. samples is a list of {labels=..., value=...}; skips entries +-- with nil value, skips the family entirely if no samples survive. +local function emit_family(out, help, type_, name, samples) + local lines = {} + for _, s in ipairs(samples) do + if s.value ~= nil then + local label_str = "" + if s.labels and next(s.labels) then + local parts = {} + for k, v in pairs(s.labels) do + table.insert(parts, k .. '="' .. escape_label(v) .. '"') + end + label_str = "{" .. table.concat(parts, ",") .. "}" + end + lines[#lines + 1] = name .. label_str .. " " .. tostring(s.value) + end + end + if #lines == 0 then return end + table.insert(out, "# HELP " .. name .. " " .. help) + table.insert(out, "# TYPE " .. name .. " " .. type_) + for _, l in ipairs(lines) do table.insert(out, l) end end -- Read /proc/uptime and return centiseconds (10ms precision). @@ -103,27 +118,97 @@ local function read_file(path) return content end +-- Parse `iwinfo info` for AP-side radio facts. +local function ap_radio_info(iface) + local fh = io.popen("iwinfo " .. iface .. " info 2>/dev/null") + if not fh then return nil end + local out = fh:read("*a"); fh:close() + if not out or out == "" then return nil end + return { + ssid = out:match('ESSID:%s*"([^"]*)"'), + channel = tonumber(out:match("Channel:%s*(%d+)")), + txpower = tonumber(out:match("Tx%-Power:%s*(%d+)%s*dBm")), + noise = tonumber(out:match("Noise:%s*(-?%d+)%s*dBm")), + } +end + +-- Parse `iwinfo assoclist` into a list of client tables. +-- Block format (Chaos Calmer iwinfo): +-- 28:05:A5:6F:83:30 -83 dBm / -95 dBm (SNR 12) 1420 ms ago +-- RX: 6.0 MBit/s, MCS 0, 20MHz 317 Pkts 22997 Bytes. +-- TX: 19.5 MBit/s, MCS 2, 20MHz 84 Pkts 8594 Bytes. +-- Connected Time: 00:01:54 +local function ap_clients(iface) + local fh = io.popen("iwinfo " .. iface .. " assoclist 2>/dev/null") + if not fh then return {} end + local out = fh:read("*a"); fh:close() + local clients = {} + local cur = nil + for line in out:gmatch("[^\n]+") do + local mac, sig, inact = line:match( + "^(%x%x:%x%x:%x%x:%x%x:%x%x:%x%x)%s+(-?%d+) dBm.-(%d+) ms ago") + if mac then + cur = { + mac = mac, + signal = tonumber(sig), + snr = tonumber(line:match("SNR (%d+)")), + inactive_ms = tonumber(inact), + } + clients[#clients + 1] = cur + elseif cur then + local rx = line:match("RX:%s*([%d%.]+) MBit/s") + if rx then cur.rx_mbps = tonumber(rx) end + local tx = line:match("TX:%s*([%d%.]+) MBit/s") + if tx then cur.tx_mbps = tonumber(tx) end + local h, m, s = line:match("Connected Time:%s*(%d+):(%d+):(%d+)") + if h then + cur.connected_s = tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s) + end + end + end + return clients +end + function metrics_handler.get_metrics(request_context) local out = {} + local iface = "vif-ap0" - -- === WiFi upstream connection === - local ssid = utils.uci_get("wireless.wwan.ssid") or "" - local station = utils.get_station_dump_info("vif-sta0") + -- === AP radio + clients === + local radio = ap_radio_info(iface) + local clients = ap_clients(iface) + local ap_labels = { iface = iface, ssid = (radio and radio.ssid) or "" } - if station then - local wifi_labels = { - ssid = ssid, - iface = "vif-sta0", - bssid = station["bssid"] or "", - } - emit(out, "WiFi signal strength of upstream connection in dBm", - "gauge", "skyweave_wifi_signal_dbm", wifi_labels, parse_dbm(station["signal"])) - emit(out, "WiFi transmit bitrate to upstream AP in Mbit/s", - "gauge", "skyweave_wifi_tx_bitrate_mbps", wifi_labels, parse_mbps(station["tx bitrate"])) - emit(out, "WiFi receive bitrate from upstream AP in Mbit/s", - "gauge", "skyweave_wifi_rx_bitrate_mbps", wifi_labels, parse_mbps(station["rx bitrate"])) + emit(out, "Number of stations associated to the roof AP", + "gauge", "skyweave_ap_clients", ap_labels, #clients) + if radio then + emit(out, "AP radio noise floor in dBm", + "gauge", "skyweave_ap_noise_dbm", ap_labels, radio.noise) + emit(out, "AP transmit power in dBm", + "gauge", "skyweave_ap_txpower_dbm", ap_labels, radio.txpower) + emit(out, "AP operating channel", + "gauge", "skyweave_ap_channel", ap_labels, radio.channel) end + local function client_samples(field) + local s = {} + for _, c in ipairs(clients) do + s[#s + 1] = { labels = { mac = c.mac }, value = c[field] } + end + return s + end + emit_family(out, "Per-client signal strength at the AP in dBm", + "gauge", "skyweave_ap_client_signal_dbm", client_samples("signal")) + emit_family(out, "Per-client signal-to-noise ratio in dB", + "gauge", "skyweave_ap_client_snr_db", client_samples("snr")) + emit_family(out, "Per-client RX bitrate (client to AP) in Mbit/s", + "gauge", "skyweave_ap_client_rx_bitrate_mbps", client_samples("rx_mbps")) + emit_family(out, "Per-client TX bitrate (AP to client) in Mbit/s", + "gauge", "skyweave_ap_client_tx_bitrate_mbps", client_samples("tx_mbps")) + emit_family(out, "Per-client idle time in milliseconds", + "gauge", "skyweave_ap_client_inactive_ms", client_samples("inactive_ms")) + emit_family(out, "Per-client association duration in seconds", + "gauge", "skyweave_ap_client_connected_seconds", client_samples("connected_s")) + -- === Active default gateway (info-style gauge) === local active_gw, active_iface = utils.get_active_default_gateway() if active_iface then @@ -161,71 +246,41 @@ function metrics_handler.get_metrics(request_context) -- MemAvailable not present on this kernel; omit to avoid phantom nil metric end - -- === Ping connectivity === + -- === Connectivity checks === -- Collect results first, then emit each metric family in one block -- (# HELP / # TYPE must appear exactly once per metric name per exposition). local ping_targets = { - { host = "192.168.18.1", name = "campground_gateway" }, - { host = "192.168.88.4", name = "talos_via_tunnel" }, + { host = "192.168.69.1", name = "chateau_gateway" }, + { host = "192.168.88.4", name = "talos_via_tunnel" }, } local ping_results = {} for _, t in ipairs(ping_targets) do ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) } end - -- Internet check via DNS (ICMP/HTTP are blocked by campground) + -- Internet check via DNS (also exercises the resolver path, not just ICMP) ping_results[#ping_results + 1] = { target = { host = "8.8.8.8", name = "internet" }, stats = dns_check("8.8.8.8", "google.com"), } - local function label_str(target, name) - return '{target="' .. escape_label(target) .. '",name="' .. escape_label(name) .. '"}' - end - - -- rtt (only emit samples where rtt is available) - local rtt_samples = {} - for _, r in ipairs(ping_results) do - if r.stats and r.stats.rtt_ms then - rtt_samples[#rtt_samples + 1] = - "skyweave_ping_rtt_ms" .. label_str(r.target.host, r.target.name) .. - " " .. tostring(r.stats.rtt_ms) + local function target_samples(field) + local s = {} + for _, r in ipairs(ping_results) do + if r.stats then + s[#s + 1] = { + labels = { target = r.target.host, name = r.target.name }, + value = r.stats[field], + } + end end + return s end - if #rtt_samples > 0 then - table.insert(out, "# HELP skyweave_ping_rtt_ms ICMP round-trip time in milliseconds") - table.insert(out, "# TYPE skyweave_ping_rtt_ms gauge") - for _, s in ipairs(rtt_samples) do table.insert(out, s) end - end - - -- loss - local loss_samples = {} - for _, r in ipairs(ping_results) do - if r.stats then - loss_samples[#loss_samples + 1] = - "skyweave_ping_loss_pct" .. label_str(r.target.host, r.target.name) .. - " " .. tostring(r.stats.loss_pct) - end - end - if #loss_samples > 0 then - table.insert(out, "# HELP skyweave_ping_loss_pct ICMP packet loss percentage (0-100)") - table.insert(out, "# TYPE skyweave_ping_loss_pct gauge") - for _, s in ipairs(loss_samples) do table.insert(out, s) end - end - - -- reachable - local reach_samples = {} - for _, r in ipairs(ping_results) do - if r.stats then - reach_samples[#reach_samples + 1] = - "skyweave_ping_reachable" .. label_str(r.target.host, r.target.name) .. - " " .. tostring(r.stats.reachable) - end - end - if #reach_samples > 0 then - table.insert(out, "# HELP skyweave_ping_reachable Host reachability via ICMP (1=up, 0=down)") - table.insert(out, "# TYPE skyweave_ping_reachable gauge") - for _, s in ipairs(reach_samples) do table.insert(out, s) end - end + emit_family(out, "ICMP round-trip time in milliseconds", + "gauge", "skyweave_ping_rtt_ms", target_samples("rtt_ms")) + emit_family(out, "ICMP packet loss percentage (0-100)", + "gauge", "skyweave_ping_loss_pct", target_samples("loss_pct")) + emit_family(out, "Host reachability via ICMP (1=up, 0=down)", + "gauge", "skyweave_ping_reachable", target_samples("reachable")) return { prometheus_metrics = true,