mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-09-26 09:28:01 +00:00
217 lines
8.3 KiB
Lua
217 lines
8.3 KiB
Lua
-- Wardrive (Lua) — a LoRa coverage survey that logs to a CSV you can pull off
|
|
-- the device afterwards.
|
|
--
|
|
-- Reference app for the discovery half of the SDK. It works the way a survey
|
|
-- has to work: it PROBES rather than listens. A probe is a zero-hop request
|
|
-- that every node in earshot answers, so a reply proves the link works from
|
|
-- exactly where you are standing. Listening only ever tells you what happened
|
|
-- to transmit while you were there, which is a different and much weaker claim.
|
|
--
|
|
-- Each reply carries both directions of the link: how well we heard them, and
|
|
-- how well they heard us. They are rarely equal, and the asymmetry is the point
|
|
-- -- "I can hear the repeater but it cannot hear me" is not the same fact as
|
|
-- "no coverage", and only a probe reveals it.
|
|
local ui, sys, mesh, fs, timer = wada.ui, wada.sys, wada.mesh, wada.fs, wada.timer
|
|
local C = ui.colors
|
|
local app = {}
|
|
|
|
local SWEEP_MS = 20000 -- above the 15 s floor the firmware enforces on probes
|
|
local HARVEST_MS = 4000 -- replies land over the few seconds after a probe
|
|
local TYPE = { [1]="chat", [2]="repeater", [3]="room", [4]="sensor" }
|
|
|
|
local run, running, samples, sweeps, last_err = "run", false, 0, 0, nil
|
|
local node_count = 0
|
|
local phase, phase_at = "idle", 0
|
|
local hdr, gps_lbl, stat_lbl, rows = nil, nil, nil, {}
|
|
local nodes = {} -- pubkey -> { name, type, best, worst, seen }
|
|
local pending = {} -- lines waiting on the 1 write/sec limit
|
|
|
|
local function logname() return run .. ".csv" end
|
|
|
|
-- Return at most max_bytes without splitting a UTF-8 sequence. The row's
|
|
-- fixed-width columns are byte-budgeted, not character-budgeted; a raw
|
|
-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and
|
|
-- left LVGL unable to advance through the label (#323, same class as #223).
|
|
local function utf8_prefix_bytes(text, max_bytes)
|
|
local offset, last, length = 1, 0, #text
|
|
while offset <= length and offset <= max_bytes do
|
|
local first = text:byte(offset)
|
|
local width = first <= 0x7F and 1
|
|
or (first >= 0xC2 and first <= 0xDF and 2)
|
|
or (first >= 0xE0 and first <= 0xEF and 3)
|
|
or (first >= 0xF0 and first <= 0xF4 and 4) or 0
|
|
if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end
|
|
local valid = true
|
|
for i = 2, width do
|
|
local byte = text:byte(offset + i - 1)
|
|
if byte < 0x80 or byte > 0xBF then valid = false; break end
|
|
end
|
|
local second = width > 1 and text:byte(offset + 1) or 0
|
|
if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
|
|
(first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end
|
|
if not valid then break end
|
|
last = offset + width - 1
|
|
offset = last + 1
|
|
end
|
|
return text:sub(1, last)
|
|
end
|
|
|
|
-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and
|
|
-- no better. fix.lat_e6 is the same reading as an exact integer in
|
|
-- micro-degrees, which is what belongs in a log: a survey you plot months later
|
|
-- should not carry rounding the device never had.
|
|
local HEADER = "epoch,lat_e6,lon_e6,alt_m,pubkey,name,type,rssi,snr,their_snr,hops"
|
|
local wrote_header = false
|
|
|
|
-- The filesystem allows one write a second. Sweeps produce a burst of rows, so
|
|
-- they queue here and drain a chunk per tick instead of being dropped.
|
|
local function flush()
|
|
if #pending == 0 then return end
|
|
local chunk = table.concat(pending, "\n") .. "\n"
|
|
if not wrote_header then chunk = HEADER .. "\n" .. chunk end
|
|
local ok = fs.append(logname(), chunk)
|
|
if ok then pending, wrote_header = {}, true end
|
|
end
|
|
|
|
local function record(fix, hit)
|
|
local key = hit.pubkey
|
|
local n = nodes[key]
|
|
if not n then
|
|
n = { name = hit.name or key, type = hit.type, best = hit.snr, worst = hit.snr, seen = 0 }
|
|
nodes[key] = n
|
|
node_count = node_count + 1
|
|
end
|
|
if hit.snr > n.best then n.best = hit.snr end
|
|
if hit.snr < n.worst then n.worst = hit.snr end
|
|
n.seen = n.seen + 1
|
|
if hit.name then n.name = hit.name end
|
|
|
|
samples = samples + 1
|
|
pending[#pending + 1] = string.format("%d,%d,%d,%d,%s,%s,%d,%d,%.2f,%.2f,%d",
|
|
fix.time or sys.epoch(), fix.lat_e6, fix.lon_e6, fix.alt_m or 0,
|
|
key, (hit.name or ""):gsub(",", " "), hit.type,
|
|
hit.rssi, hit.snr, hit.their_snr, hit.hops)
|
|
end
|
|
|
|
local function sweep()
|
|
local tag, err = mesh.discover() -- every node type
|
|
if not tag then last_err = err; return false end
|
|
last_err = nil
|
|
sweeps = sweeps + 1
|
|
return true
|
|
end
|
|
|
|
local function harvest()
|
|
local fix = sys.gps()
|
|
if not fix then
|
|
-- No fix means the sample cannot be placed, so it is discarded rather than
|
|
-- logged at 0,0. A survey file with phantom points at Null Island is worse
|
|
-- than a shorter one.
|
|
mesh.discover_clear()
|
|
return
|
|
end
|
|
for _, hit in ipairs(mesh.discovered()) do record(fix, hit) end
|
|
mesh.discover_clear() -- next sample must not inherit this one
|
|
end
|
|
|
|
local function redraw()
|
|
local fix = sys.gps()
|
|
if fix then
|
|
gps_lbl:set(string.format("%.5f, %.5f %dm %d sats", fix.lat, fix.lon, fix.alt_m or 0, fix.sats))
|
|
gps_lbl:color(C.good)
|
|
else
|
|
gps_lbl:set("waiting for a GPS fix - samples are discarded until then")
|
|
gps_lbl:color(C.bad)
|
|
end
|
|
|
|
local state = running and (phase == "probe" and "listening..." or "sweeping") or "stopped"
|
|
stat_lbl:set(string.format("%s | %s | %d sweeps, %d samples, %d nodes%s",
|
|
run, state, sweeps, samples, node_count,
|
|
last_err and (" [" .. last_err .. "]") or ""))
|
|
stat_lbl:color(last_err and C.bad or C.accent)
|
|
|
|
local list = {}
|
|
for key, n in pairs(nodes) do list[#list + 1] = { key = key, n = n } end
|
|
table.sort(list, function(a, b) return a.n.best > b.n.best end)
|
|
for i = 1, #rows do
|
|
local e = list[i]
|
|
if e then
|
|
rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
|
|
utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
|
|
rows[i]:color(e.n.best > 0 and C.good or C.text)
|
|
else
|
|
rows[i]:set(i == 1 and "nothing has answered a probe yet" or "")
|
|
rows[i]:color(C.sub)
|
|
end
|
|
end
|
|
end
|
|
|
|
function app.on_open(w, h)
|
|
if not sys.caps().discover then
|
|
ui.label("This board does not carry the extended SDK,", 6, 8, 12, C.bad)
|
|
ui.label("so it cannot send discovery probes.", 6, 26, 12, C.bad)
|
|
return
|
|
end
|
|
ui.scroll(true)
|
|
local LH = ui.text_h(12)
|
|
local y = 4
|
|
|
|
hdr = ui.label("LoRa coverage survey", 4, y, 12, C.accent); hdr:width(w - 10); y = y + LH + 3
|
|
gps_lbl = ui.label("", 4, y, 12, C.sub); gps_lbl:width(w - 10); y = y + LH + 3
|
|
stat_lbl = ui.label("", 4, y, 12, C.text); stat_lbl:width(w - 10); y = y + LH * 2 + 5
|
|
|
|
local bw = math.min(96, (w - 20) // 3)
|
|
ui.button("Start", 4, y, bw, 32, function()
|
|
running = not running
|
|
if running then phase, phase_at = "idle", 0 end
|
|
sys.toast(running and "Survey running" or "Survey stopped", 1200)
|
|
end)
|
|
ui.button("Name", 8 + bw, y, bw, 32, function()
|
|
ui.input("Name this run", run, function(text)
|
|
if text then
|
|
run = text:gsub("[^%w%-_]", "_")
|
|
wrote_header = false -- a new file needs its own header row
|
|
sys.toast("Logging to " .. logname(), 1500)
|
|
end
|
|
end)
|
|
end)
|
|
ui.button("Reset", 12 + bw * 2, y, bw, 32, function()
|
|
nodes, samples, sweeps, pending, node_count = {}, 0, 0, {}, 0
|
|
mesh.discover_clear()
|
|
fs.remove(logname())
|
|
wrote_header = false
|
|
sys.toast("Cleared " .. logname(), 1200)
|
|
end)
|
|
y = y + 38
|
|
|
|
ui.label("strongest first", 4, y, 12, C.sub); y = y + LH + 2
|
|
for i = 1, 12 do
|
|
rows[i] = ui.label("", 4, y, 12, C.text); rows[i]:width(w - 10); y = y + LH + 2
|
|
end
|
|
|
|
redraw()
|
|
timer.every(1000)
|
|
end
|
|
|
|
function app.on_tick()
|
|
if running then
|
|
local now = sys.millis()
|
|
if phase == "idle" or (phase == "wait" and now - phase_at >= SWEEP_MS) then
|
|
-- A refused or rate-limited probe backs off a full sweep interval. Retrying
|
|
-- every tick would re-enter the permission path once a second for nothing.
|
|
phase, phase_at = sweep() and "probe" or "wait", now
|
|
elseif phase == "probe" and now - phase_at >= HARVEST_MS then
|
|
harvest()
|
|
phase = "wait" -- phase_at stays at the probe time, so sweeps stay on cadence
|
|
end
|
|
end
|
|
flush()
|
|
redraw()
|
|
end
|
|
|
|
function app.on_close()
|
|
flush() -- one last drain; anything queued would otherwise be lost
|
|
end
|
|
|
|
return app
|