mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-06 03:27:23 +00:00
1.1 logs a survey to CSV and notes in its own docs that there is no on-device viewer for it, so checking coverage meant pulling the card and opening a laptop. 1.2 adds a second screen that reads the run's own log back and plots it on the firmware's basemap via wada.map, with each stop coloured by whichever direction of the link you ask about. Colour cycles through how well we heard them, how well they heard us, and the worst imbalance. Design notes a reviewer may want up front. - A sweep writes one row per responder against a single GPS fix, so a corner where ten nodes answered is ten rows sharing one coordinate. The loader aggregates by STOP, not by row, keeping the best link in each direction and the largest-magnitude single-reply asymmetry at that stop. Drawing a raw row would have coloured the marker by whichever node answered first, which is arrival order and none of the three metrics the button offers. - The log is read in 4 KB windows, one per tick, and finishes only on a read that yields nothing. A callback gets 100,000 VM opcodes and parsing a whole log in one would exceed it; and since queued rows keep draining while the screen is open, treating a short read as end-of-file silently dropped everything appended mid-walk. - Stops are capped. Past the cap the reservoir halves in place and widens its stride, and it carries the newest stop across each halving and appends the final position when the walk ends, so the drawn route ends where the run did. Simulated: a 5,000-stop run keeps 315 stops spread 79/78/79/79 across the four quarters of the route and ends on stop 5,000. A prefix cap would have drawn the first 400 and nothing after. - Drawing is strided to at most ~150 stops. Each marker and segment is an LVGL object created synchronously and the host puts no ceiling on an app's view (k_map_markers_max belongs to the Map tab), so the bound has to come from the app, on a loop the radio shares. - The map view is closed before any ui.clear(), routed through one helper so the order cannot be broken by a later edit. See the note in drop_map: MapUd is the only userdata type without a generation field, so clearing with a view open strands the one-view counter and leaves a stale pointer. Reported separately. - ui.canvas argchecks 1..480 on both dimensions where map.view allows 1..800, so the bare-plot fallback asked for 792 px on an 800 px board and would have raised inside the very branch that exists to keep the app standing. Clamped, and the call is guarded. - Probing pauses while the review screen is open, per the store's guidance that a survey should probe on a cadence its user chose. Entering banks the in-flight probe only if it is still inside its harvest window, and otherwise drops it, on the same terms the tick path uses; both call one shared predicate so they cannot drift apart. Either way the discovery set is cleared so nothing crosses the pause. - Zoom and centre are fitted in Web Mercator units, matching latLonToWorldPx: latitude costs 1/cos(lat) times as many pixels as longitude, and the visual centre is the inverse of the mean projected y rather than the mean latitude. Both matter on a long north-south run. A run straddling the antimeridian is fitted on latitude alone, with the centre longitude unwrapped onto 0..360 before averaging so it lands near the line rather than on the far side of the world, and the card says it happened. - Layout reserves every fixed element and gives the plot the remainder, because on_open is handed the body height rather than the panel height, and a half-height plot pushed the control row off the bottom. The two-line label reservations were checked against the shipped glyph tables at the narrowest label width, which is what shortened two of the strings. - The manifest gains "icon": "map". 1.1 shipped without one, so the drawer tile falls back to the generic app symbol; an app cannot ship artwork, and of the sixteen glyph names map is the one that describes what 1.2 now does. Drop it if you would rather the tile stayed as it is. TWO FIXES HERE BELONG TO 1.1, NOT TO THE REVIEW SCREEN. Both corrupt the file the new screen reads, which is why they are here, but say the word and I will pull them out and send 1.2 as the viewer alone. - Ticks pause while the display sleeps; millis() does not. On waking, the harvest deadline is long past, the radio still holds replies heard at the old position, and sys.gps() reports the new one, so those replies were logged in the wrong place. Pocket the device, walk 400 m, wake it. Sweeps dropped this way are now counted on the status line, alongside a sweep discarded because the survey was stopped mid-listen - two causes, one counter, and the label says dropped rather than naming either. - Renaming a run wrote the previous run's queued rows into the new file, and wrote_header being process-local appended a second header mid-file whenever an existing log was reopened. The on-device reader skips those lines; a spreadsheet does not. TESTED ON: nothing. I have a T-Deck Plus but have not run this, so treat every runtime claim above as read from the firmware source rather than observed. Some specifics are worth naming rather than hiding. The body-height figure and the capability, argcheck and projection details above come from reading src/ui-touch/, not from the SDK page, which is out of date in at least two places (it documents an input event type the firmware never emits, which is why this version has no on_input, and it omits both argcheck ranges). The riskiest unverified behaviour is whether a map view can be created again after being closed on a screen switch; that is guarded by pcall with a canvas fallback. Label wrapping at the narrowest width is calculated, not seen. Written with AI assistance, then reviewed by nine independent passes and two cross-vendor panels. Every finding that survived verification is fixed. The one that mattered most was the arrival-order colour above, which the first panel missed and the second caught. ONE QUESTION, not decided here. 1.2 is 34,159 bytes against 1.1's 8,477, and wardrive already requires sdk_ext, so boards that cannot run it still pay for it in the baked catalog. "seed": false looks right to me, but the README calls that your knob, so the row is left exactly as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: tmetz1987 <tylerzmetzger@gmail.com>
766 lines
33 KiB
Lua
766 lines
33 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.
|
|
--
|
|
-- 1.2 adds a REVIEW screen, because until now the run could only be read on a
|
|
-- computer after pulling the card, and a survey you cannot check in the field is
|
|
-- a survey you repeat. It reads the run's own CSV back and plots it on the
|
|
-- firmware's basemap, colouring each stop by whichever direction of the link
|
|
-- you are asking about. Probing pauses while you are
|
|
-- looking at it: a survey is expected to spend other people's airtime only on a
|
|
-- cadence its user chose, and nobody chose to keep probing while reading a map.
|
|
local ui, sys, mesh, fs, timer = wada.ui, wada.sys, wada.mesh, wada.fs, wada.timer
|
|
local C = ui.colors
|
|
local app = {}
|
|
|
|
-- Older firmware has neither of these; the SDK's own advice is to degrade rather
|
|
-- than to require a floor.
|
|
local clear = ui.clear or function() end
|
|
local tr = sys.tr or function(x) return x end
|
|
|
|
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
|
|
-- The outside edge of a harvest. Past this the replies in hand were heard somewhere the current GPS
|
|
-- fix no longer describes, so they are dropped rather than mislocated. Three tick periods of slack
|
|
-- past the harvest window: a slow tick stays inside it, a slept display does not.
|
|
local STALE_MS = HARVEST_MS + 3000
|
|
local TYPE = { [1]="chat", [2]="repeater", [3]="room", [4]="sensor" }
|
|
|
|
local run, running, samples, sweeps, last_err = "run", false, 0, 0, nil
|
|
-- In-flight sweeps discarded rather than mislocated. Two causes, not one: the display slept through
|
|
-- the harvest window, or the survey was stopped mid-listen and the replies in hand were never banked.
|
|
local dropped_sweeps = 0
|
|
local node_count = 0
|
|
local phase, phase_at = "idle", 0
|
|
-- One predicate, used by the tick path and the Review button alike, so the two cannot drift apart -
|
|
-- which is exactly what happened when only one of them had the check.
|
|
local function harvest_fresh(now) return (now - phase_at) <= STALE_MS end
|
|
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
|
|
|
|
-- ---- review state -----------------------------------------------------------
|
|
-- view is "survey" or "review". Every screen-building path clears first and
|
|
-- re-creates, which is the documented way to hold more than one screen: handles
|
|
-- taken before a clear are inert afterwards, so nothing may be reused across it.
|
|
local W, H = 0, 0
|
|
local view = "survey"
|
|
local was_running = false -- so leaving review restores what the user chose
|
|
local MODES = { "heard them", "heard us", "asymmetry" }
|
|
local mode = 1
|
|
local map, map_note, rv_stat, rv_legend, rv_plot = nil, nil, nil, nil, nil
|
|
local plot_px = 0 -- the height build_review actually gave the plot
|
|
-- The loader is a state machine on purpose. A 32 KB log is several hundred rows
|
|
-- and parsing it in one callback would sail past the 100,000 instruction budget
|
|
-- and close the app; a window per tick stays nowhere near it.
|
|
local ld = nil
|
|
local READ_WIN = 4096
|
|
-- How many samples are actually drawn. Every marker and every segment is an LVGL object created
|
|
-- synchronously, and the screen shares its loop with the radio, so a long drive must not turn into a
|
|
-- long callback. The run is drawn at a stride instead, which keeps the route's shape.
|
|
local MAX_DRAW = 150
|
|
|
|
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"
|
|
-- nil means "not yet known": the file may already carry a header from an earlier session, and
|
|
-- wrote_header is process-local, so assuming false appended a second header mid-file on every
|
|
-- reopen. The device's own reader skips those lines; a spreadsheet on a laptop does not.
|
|
local wrote_header = nil
|
|
|
|
-- 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 wrote_header == nil then
|
|
local d = fs.read(logname(), 0, 8)
|
|
wrote_header = (d ~= nil and #d > 0) -- a non-empty file already has one
|
|
end
|
|
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() or 0, 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(tr("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%s",
|
|
run, state, sweeps, samples, node_count,
|
|
dropped_sweeps > 0 and (" · " .. dropped_sweeps .. " dropped") or "",
|
|
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 tr("nothing has answered a probe yet") or "")
|
|
rows[i]:color(C.sub)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- review: reading the run back ------------------------------------------
|
|
|
|
-- Which link direction a sample is judged on. "asymmetry" is the one the app
|
|
-- exists to show: a positive value means we hear them better than they hear us,
|
|
-- which is where a repeater earns its place.
|
|
-- A stop is one GPS fix, and a sweep writes one row per responder at that same fix, so a street
|
|
-- corner where ten nodes answered is ten rows sharing one coordinate. Drawing a row therefore drew
|
|
-- whichever responder happened to come first in that sweep, and mesh.discovered() is in arrival
|
|
-- order - not strongest, not worst. The marker now answers the question the button actually asks,
|
|
-- taken over every responder heard at that stop.
|
|
local function metric_of(st)
|
|
if mode == 2 then return st.them_max end -- the best anyone heard US from here
|
|
if mode == 3 then return st.asym end -- the most one-sided link here, signed
|
|
return st.snr_max -- the best WE heard anything from here
|
|
end
|
|
|
|
local function colour_for(v)
|
|
if mode == 3 then
|
|
-- asymmetry: near zero is a balanced link, and that is the good case
|
|
if v > 6 or v < -6 then return C.bad end
|
|
if v > 3 or v < -3 then return C.accent end
|
|
return C.good
|
|
end
|
|
if v >= 0 then return C.good end
|
|
if v >= -10 then return C.accent end
|
|
return C.bad
|
|
end
|
|
|
|
local function ld_reset()
|
|
-- every field the rest of the code reads is created here, including plotted: relying on a
|
|
-- nil read would work in Lua but leaves the reader guessing whether it was meant to exist
|
|
ld = { off = 0, total = nil, tail = "", stops = {}, n = 0, skipped = 0, done = false, plotted = false,
|
|
minlat = nil, maxlat = nil, minlon = nil, maxlon = nil, metres = 0, wrapped = false,
|
|
prev_lat = nil, prev_lon = nil, by_node = {}, node_n = 0, step = 1,
|
|
stop_n = 0, pos_i = 0, keep_every = 1, last_key = nil, cur = nil, tailstop = nil }
|
|
end
|
|
|
|
local MAX_STOPS = 400
|
|
local function stop_push(lat, lon)
|
|
ld.pos_i = ld.pos_i + 1
|
|
ld.stop_n = ld.stop_n + 1
|
|
if #ld.stops >= MAX_STOPS then
|
|
-- Halving keeps the odd indices, which drops the newest stop every time. Carry it over, or the
|
|
-- drawn route walks backwards from the truth a little at each collapse.
|
|
local last = ld.stops[#ld.stops]
|
|
local out, j = {}, 0
|
|
for i = 1, #ld.stops, 2 do j = j + 1; out[j] = ld.stops[i] end
|
|
if out[j] ~= last then j = j + 1; out[j] = last end
|
|
ld.stops = out
|
|
ld.keep_every = ld.keep_every * 2
|
|
end
|
|
-- Always build the stop and always aggregate into it, whether or not it is kept for drawing: the
|
|
-- last position seen has to be available at the end of the walk even if the stride passed over it.
|
|
local st = { lat = lat, lon = lon, n = 0 }
|
|
ld.tailstop = st
|
|
if (ld.pos_i % ld.keep_every) == 0 then
|
|
ld.stops[#ld.stops + 1] = st
|
|
st.kept = true
|
|
end
|
|
return st
|
|
end
|
|
|
|
-- The route ends where the run ended. Called once, when the walk completes.
|
|
local function stops_finalise()
|
|
local t = ld and ld.tailstop
|
|
if t and not t.kept and #ld.stops > 0 then
|
|
ld.stops[#ld.stops + 1] = t
|
|
t.kept = true
|
|
end
|
|
end
|
|
|
|
local function ld_row(line)
|
|
-- epoch,lat_e6,lon_e6,alt_m,pubkey,name,type,rssi,snr,their_snr,hops
|
|
-- Split on the sentinel rather than with "([^,]*)", which matches the empty
|
|
-- string between every pair of characters and walks the line twice.
|
|
local f, n = {}, 0
|
|
for part in (line .. ","):gmatch("([^,]*),") do
|
|
n = n + 1; f[n] = part
|
|
if n >= 11 then break end
|
|
end
|
|
if n < 11 then ld.skipped = ld.skipped + 1; return end
|
|
local lat_e6, lon_e6 = tonumber(f[2]), tonumber(f[3])
|
|
local snr, their = tonumber(f[9]), tonumber(f[10])
|
|
if not (lat_e6 and lon_e6 and snr and their) then ld.skipped = ld.skipped + 1; return end
|
|
local lat, lon = lat_e6 / 1000000, lon_e6 / 1000000
|
|
-- A row at exactly 0,0 is the Null Island shape the writer refuses to create;
|
|
-- if one appears it came from somewhere else, so it is counted, not plotted.
|
|
if lat_e6 == 0 and lon_e6 == 0 then ld.skipped = ld.skipped + 1; return end
|
|
|
|
ld.n = ld.n + 1
|
|
if not ld.minlat or lat < ld.minlat then ld.minlat = lat end
|
|
if not ld.maxlat or lat > ld.maxlat then ld.maxlat = lat end
|
|
if not ld.minlon or lon < ld.minlon then ld.minlon = lon end
|
|
if not ld.maxlon or lon > ld.maxlon then ld.maxlon = lon end
|
|
|
|
if ld.prev_lat and wada.geo then
|
|
local ok, d = pcall(wada.geo.distance, ld.prev_lat, ld.prev_lon, lat, lon)
|
|
if ok and d then ld.metres = ld.metres + d end
|
|
end
|
|
ld.prev_lat, ld.prev_lon = lat, lon
|
|
|
|
local key = f[5]
|
|
local nd = ld.by_node[key]
|
|
if not nd then
|
|
nd = { name = (f[6] ~= "" and f[6]) or key, type = tonumber(f[7]) or 0,
|
|
best = snr, worst = snr, best_them = their, asym = snr - their, seen = 0 }
|
|
ld.by_node[key] = nd
|
|
ld.node_n = ld.node_n + 1
|
|
end
|
|
if snr > nd.best then nd.best = snr end
|
|
if snr < nd.worst then nd.worst = snr end
|
|
if their > nd.best_them then nd.best_them = their end
|
|
-- The imbalance has to come from a SINGLE reply. Peak-heard-them minus peak-heard-us can pair two
|
|
-- different moments and cancel a genuinely one-sided link to zero (10/-5 then -5/10 reads as 0).
|
|
do
|
|
local a = snr - their
|
|
if (a < 0 and -a or a) > (nd.asym < 0 and -nd.asym or nd.asym) then nd.asym = a end
|
|
end
|
|
nd.seen = nd.seen + 1
|
|
|
|
-- Rows sharing a coordinate are one stop. They arrive contiguously, because a sweep harvests
|
|
-- every reply against a single fix, so comparing with the previous row's raw integers is enough
|
|
-- and costs no table lookup.
|
|
local ekey = f[2] .. "," .. f[3]
|
|
if ekey ~= ld.last_key then
|
|
ld.last_key = ekey
|
|
ld.cur = stop_push(lat, lon)
|
|
end
|
|
local st = ld.cur
|
|
if st then
|
|
st.n = st.n + 1
|
|
if not st.snr_max or snr > st.snr_max then st.snr_max = snr end
|
|
if not st.snr_min or snr < st.snr_min then st.snr_min = snr end
|
|
if not st.them_max or their > st.them_max then st.them_max = their end
|
|
if not st.them_min or their < st.them_min then st.them_min = their end
|
|
local a = snr - their
|
|
if not st.asym or (a < 0 and -a or a) > (st.asym < 0 and -st.asym or st.asym) then st.asym = a end
|
|
end
|
|
end
|
|
|
|
-- One window per tick. Returns true while there is more to do.
|
|
local function ld_step()
|
|
if not ld or ld.done then return false end
|
|
local data, total = fs.read(logname(), ld.off, READ_WIN)
|
|
if not data then
|
|
-- no such file yet, or nothing left to hand back: either way the walk is over, but a tail
|
|
-- held from the previous window is still a real row and must not be thrown away
|
|
if ld.tail ~= "" and not ld.tail:find("^epoch,") then ld_row(ld.tail); ld.tail = "" end
|
|
stops_finalise()
|
|
ld.done = true
|
|
return false
|
|
end
|
|
ld.total = total or (ld.off + #data)
|
|
ld.off = ld.off + #data
|
|
local buf = ld.tail .. data
|
|
local last = 0
|
|
for line, pos in buf:gmatch("([^\n]*)\n()") do
|
|
last = pos - 1
|
|
if line ~= "" and not line:find("^epoch,") then ld_row(line) end
|
|
end
|
|
ld.tail = buf:sub(last + 1)
|
|
-- A short read is NOT the end: flush() still drains queued rows while the review screen is up,
|
|
-- so the file can grow between two windows. Only a read that yields nothing means finished, and
|
|
-- the leftover tail is parsed at that point because it can only then be a complete final line.
|
|
if #data == 0 then
|
|
if ld.tail ~= "" and not ld.tail:find("^epoch,") then ld_row(ld.tail) end
|
|
ld.tail = ""
|
|
stops_finalise()
|
|
ld.done = true
|
|
end
|
|
return not ld.done
|
|
end
|
|
|
|
-- Pick the zoom that just fits the run's own bounding box.
|
|
--
|
|
-- Both axes are measured in Web Mercator's own normalised units, where the whole
|
|
-- world is 1.0 and a tile is 1/2^z of it, so a span fits when
|
|
-- span_norm <= pixels / (256 * 2^z). Longitude is linear in that space, but
|
|
-- LATITUDE IS NOT: the projection stretches by 1/cos(latitude), so treating a
|
|
-- degree of latitude as a degree of longitude picks a zoom that is too close and
|
|
-- lets the route run off the view - and by more the further from the equator.
|
|
-- Hence the real y transform rather than a shared 360.
|
|
local function merc_y(lat)
|
|
-- clamp before the tangent: the projection is asymptotic at the poles
|
|
if lat > 85 then lat = 85 end
|
|
if lat < -85 then lat = -85 end
|
|
local r = lat * math.pi / 180
|
|
return 0.5 - math.log(math.tan(math.pi / 4 + r / 2)) / (2 * math.pi)
|
|
end
|
|
|
|
-- The inverse, needed for the centre. Fitting the span in Mercator units and then centring on the
|
|
-- arithmetic mean LATITUDE re-introduces the same error one level down: the visual middle of a
|
|
-- north-south box is the inverse of the mean y, and on a long run away from the equator the
|
|
-- geographic midpoint sits south of it, so the northern end clips off a correctly fitted view.
|
|
local function merc_lat(y)
|
|
local r = 2 * (math.atan(math.exp((0.5 - y) * 2 * math.pi)) - math.pi / 4)
|
|
return r * 180 / math.pi
|
|
end
|
|
|
|
local function fit_zoom(px, py)
|
|
if not ld or not ld.minlat then return 14 end
|
|
local sy = math.max(math.abs(merc_y(ld.minlat) - merc_y(ld.maxlat)), 1e-9)
|
|
-- A run either side of the antimeridian has a min near -180 and a max near +180, so the naive
|
|
-- span is ~358 degrees and the fit collapses to zoom 1 - the whole world, to show one street.
|
|
-- Rather than pretend to handle a wrap, fit on latitude alone and say so on the card.
|
|
local sx = (ld.maxlon - ld.minlon) / 360
|
|
if sx > 0.5 then ld.wrapped = true end
|
|
sx = math.max(sx, 1e-9)
|
|
local function z_for(span_norm, pixels)
|
|
if not (pixels > 0) then return 1 end
|
|
local v = pixels / (256 * span_norm)
|
|
if not (v > 1) then return 1 end
|
|
return math.floor(math.log(v) / math.log(2))
|
|
end
|
|
local z = ld.wrapped and z_for(sy, py) or math.min(z_for(sx, px), z_for(sy, py))
|
|
-- A single-point run gives a 1e-9 span and therefore the maximum zoom, which is
|
|
-- right: there is nothing to fit, so show the tightest view of where it was.
|
|
if not (z == z) then z = 14 end -- nan guard; z ~= z is only true for nan
|
|
if z < 1 then z = 1 end
|
|
if z > 19 then z = 19 end
|
|
return z
|
|
end
|
|
|
|
local function review_plot()
|
|
if not ld then return end
|
|
local drawn = 0
|
|
local step = math.max(1, math.ceil(#ld.stops / MAX_DRAW))
|
|
ld.step = step
|
|
if map then
|
|
map:clear()
|
|
if ld.minlat then
|
|
-- The centre is the inverse of the mean Mercator y, not the mean latitude: on a long
|
|
-- north-south run the two differ, and centring on the latter clips the northern end off a
|
|
-- span that fit_zoom sized correctly.
|
|
local clat = merc_lat((merc_y(ld.minlat) + merc_y(ld.maxlat)) / 2)
|
|
local clon
|
|
if ld.wrapped then
|
|
-- Unwrap onto 0..360 before averaging, then fold back, so the centre lands near the
|
|
-- antimeridian the run actually straddles instead of on the far side of the world.
|
|
local a = ld.minlon < 0 and ld.minlon + 360 or ld.minlon
|
|
local b = ld.maxlon < 0 and ld.maxlon + 360 or ld.maxlon
|
|
clon = (a + b) / 2
|
|
if clon > 180 then clon = clon - 360 end
|
|
else
|
|
clon = (ld.minlon + ld.maxlon) / 2
|
|
end
|
|
map:center(clat, clon, fit_zoom(W - 8, plot_px))
|
|
-- One marker and one segment are each an LVGL object created synchronously, and the host puts
|
|
-- no ceiling on an app's view (k_map_markers_max = 256 belongs to the firmware's own Map tab),
|
|
-- so the bound comes from here. The stride runs over STOPS, each already the aggregate of
|
|
-- every responder heard at that fix.
|
|
local prev = nil
|
|
for i = 1, #ld.stops, step do
|
|
local st = ld.stops[i]
|
|
if prev then map:line(prev.lat, prev.lon, st.lat, st.lon, C.sub, 1) end
|
|
if map:marker(st.lat, st.lon, colour_for(metric_of(st)), 3) then drawn = drawn + 1 end
|
|
prev = st
|
|
end
|
|
end
|
|
local tiles = 0
|
|
local ok, t = pcall(function() return map:tiles() end)
|
|
if ok and t then tiles = t end
|
|
if not ld.minlat then
|
|
-- Nothing was plotted, so map:center() was never called and no render was ever requested:
|
|
-- tiles is 0 because of that, not because the area is uncached. Say nothing about the cache.
|
|
map_note:set("")
|
|
elseif tiles == 0 then
|
|
-- Short enough to wrap to two lines at the narrowest label width (230 px), which is what the
|
|
-- layout below reserves for it. The longer wording ran to three and overdrew the legend.
|
|
map_note:set(tr("no tiles cached here - blank ground, not open water"))
|
|
map_note:color(C.bad)
|
|
else
|
|
map_note:set(string.format("%d tiles · %d of %d stops drawn%s%s", tiles, drawn, ld.stop_n,
|
|
step > 1 and (" · 1 in " .. step) or "",
|
|
ld.keep_every > 1 and (" · sampled 1 in " .. ld.keep_every .. " while reading") or ""))
|
|
map_note:color(C.sub)
|
|
end
|
|
elseif rv_plot then
|
|
-- No map view. caps().map and caps().discover are the same flag today, so this is a view that
|
|
-- failed to open rather than a board without a basemap. The geography is drawn bare and
|
|
-- self-scaled; without tiles there is no way to say WHERE this is, so it is labelled a shape
|
|
-- rather than a map. The same stride
|
|
-- applies: these are host draw calls on the loop the radio shares, and an uncapped run would
|
|
-- put hundreds of them in one callback.
|
|
local pw, ph = math.min(W - 8, 480), math.min(plot_px, 480) -- must match the ui.canvas clamp below
|
|
rv_plot:fill(C.bg)
|
|
if ld.minlat then
|
|
local dlat = math.max(ld.maxlat - ld.minlat, 1e-6)
|
|
local dlon = math.max(ld.maxlon - ld.minlon, 1e-6)
|
|
local function sx(lon) return math.floor(4 + (lon - ld.minlon) / dlon * (pw - 8)) end
|
|
local function sy(lat) return math.floor(4 + (ld.maxlat - lat) / dlat * (ph - 8)) end
|
|
local prev = nil
|
|
for i = 1, #ld.stops, step do
|
|
local st = ld.stops[i]
|
|
local x, y = sx(st.lon), sy(st.lat)
|
|
if prev then rv_plot:line(prev[1], prev[2], x, y, C.sub) end
|
|
rv_plot:circle(x, y, 2, colour_for(metric_of(st)))
|
|
drawn = drawn + 1
|
|
prev = { x, y }
|
|
end
|
|
end
|
|
rv_plot:text(4, 2, string.format(tr("route shape, not to scale - %d of %d stops"), drawn, ld.stop_n), C.sub, 12)
|
|
end
|
|
end
|
|
|
|
local function review_stat()
|
|
if not ld then return end
|
|
if not ld.done then
|
|
rv_stat:set(string.format(tr("reading %s ... %d rows"), logname(), ld.n))
|
|
rv_stat:color(C.sub)
|
|
return
|
|
end
|
|
if ld.n == 0 then
|
|
rv_stat:set(string.format(tr("%s holds no plottable rows yet"), logname()))
|
|
rv_stat:color(C.bad)
|
|
return
|
|
end
|
|
local km = ld.metres / 1000
|
|
rv_stat:set(string.format("%s · %d samples at %d stops, %d nodes · %.2f km%s%s",
|
|
run, ld.n, ld.stop_n, ld.node_n, km,
|
|
ld.wrapped and " · crosses the antimeridian, fitted on latitude only" or "",
|
|
ld.skipped > 0 and (" · " .. ld.skipped .. " unusable") or ""))
|
|
rv_stat:color(C.accent)
|
|
-- from a single reply, per node, so a genuinely one-sided link cannot be cancelled out by a
|
|
-- later reply that leaned the other way
|
|
local worst_name, worst_gap = nil, nil
|
|
for _, nd in pairs(ld.by_node) do
|
|
local g = nd.asym < 0 and -nd.asym or nd.asym
|
|
if not worst_gap or g > worst_gap then worst_gap, worst_name = g, nd.name end
|
|
end
|
|
local legend
|
|
if mode == 3 then
|
|
legend = tr("colour: balance, green even, red one-sided")
|
|
if worst_name then
|
|
legend = legend .. string.format(" · worst %s %.1f dB",
|
|
utf8_prefix_bytes(worst_name, 10), worst_gap)
|
|
end
|
|
elseif mode == 2 then
|
|
legend = tr("colour: how well they heard US")
|
|
else
|
|
legend = tr("colour: how well WE heard them")
|
|
end
|
|
rv_legend:set(legend)
|
|
rv_legend:color(C.sub)
|
|
end
|
|
|
|
-- ---- screens ----------------------------------------------------------------
|
|
|
|
local build_survey, build_review
|
|
|
|
-- ⚠️ ORDER IS LOAD-BEARING: a live map view must be closed BEFORE ui.clear().
|
|
-- Every other handle type carries a generation and self-invalidates when clear()
|
|
-- bumps it, so a stale call is a no-op. The map userdata does not: clear() deletes
|
|
-- the view's container as a child of the app body, but leaves the one-view counter
|
|
-- set and the pointer non-nil. Clearing first would therefore (a) leak the tile
|
|
-- pixels, (b) make the next wada.map.view() fail with "only one map view at a
|
|
-- time", and (c) leave a later :close() or __gc freeing memory that is already
|
|
-- gone. So the teardown lives here, and both screen builders call it first rather
|
|
-- than each remembering to.
|
|
local function drop_map()
|
|
if map then pcall(function() map:close() end) end
|
|
map = nil
|
|
end
|
|
|
|
local function enter_review()
|
|
-- Bank the in-flight probe before pausing. Those replies were heard at a position we still hold,
|
|
-- so recording them is better than discarding them, and harvest() clears the set on its way out -
|
|
-- which is the stale-sample protection this needs: without it, replies from before the pause would
|
|
-- later be stamped with a fix taken after it.
|
|
-- Only a probe still inside its own window may be banked. Outside it the set holds replies heard
|
|
-- somewhere else - the survey was stopped mid-probe, or the display slept through the harvest - and
|
|
-- the fix we have now is not where they were heard, so they are dropped on the same terms on_tick
|
|
-- drops them.
|
|
if running and phase == "probe" and harvest_fresh(sys.millis()) then
|
|
harvest()
|
|
else
|
|
if phase == "probe" then dropped_sweeps = dropped_sweeps + 1 end
|
|
mesh.discover_clear()
|
|
end
|
|
flush() -- and land them, so the reader below sees this run complete
|
|
was_running = running
|
|
running = false -- a survey probes on a cadence its user chose,
|
|
phase = "idle" -- and nobody chose to keep probing while reading
|
|
view = "review"
|
|
ld_reset()
|
|
build_review()
|
|
end
|
|
|
|
local function leave_review()
|
|
drop_map()
|
|
view = "survey"
|
|
running = was_running
|
|
if running then phase, phase_at = "idle", 0 end
|
|
mesh.discover_clear() -- same reason on the way back in
|
|
ld = nil
|
|
build_survey()
|
|
end
|
|
|
|
build_review = function()
|
|
drop_map() -- must precede clear(); see drop_map
|
|
clear()
|
|
ui.scroll(true) -- the button row sits below the fold on a 240-tall board
|
|
hdr, gps_lbl, stat_lbl, rows = nil, nil, nil, {}
|
|
local LH = ui.text_h(12)
|
|
local y = 4
|
|
ui.label(tr("Run review"), 4, y, 12, C.accent); y = y + LH + 3
|
|
rv_stat = ui.label("", 4, y, 12, C.text); rv_stat:width(W - 10); y = y + LH * 2 + 3
|
|
|
|
-- Reserve every fixed element first, then let the map have the remainder. H is the BODY height
|
|
-- (display minus the app bar), so on a T-Deck this is 218 rather than 240.
|
|
local BTN_H = 32
|
|
local reserved = 4 + (LH + 3) + (LH * 2 + 3) + 4 + (LH * 2 + 2) + (LH * 2 + 3) + BTN_H + 4
|
|
local plot_h = math.max(40, H - reserved)
|
|
plot_px = plot_h
|
|
rv_plot = nil
|
|
if sys.caps().map then
|
|
-- A view is one per app and a second live one errors, so this is wrapped:
|
|
-- a board or firmware that refuses it falls through to the bare plot rather
|
|
-- than taking the app down.
|
|
local ok, m = pcall(function() return wada.map.view(4, y, W - 8, plot_h) end)
|
|
if ok and m then map = m end
|
|
end
|
|
if not map then
|
|
-- ui.canvas argchecks both dimensions at 1..480 where map.view allows 800, so W - 8 is out of
|
|
-- range on an 800 px board and would raise. This is the recovery path, so a refusal has to leave
|
|
-- the screen standing rather than close the app.
|
|
local okc, cv = pcall(ui.canvas, math.min(W - 8, 480), math.min(plot_h, 480))
|
|
if okc and cv then rv_plot = cv; rv_plot:pos(4, y) end
|
|
end
|
|
y = y + plot_h + 4
|
|
|
|
map_note = ui.label("", 4, y, 12, C.sub); map_note:width(W - 10); y = y + LH * 2 + 2
|
|
rv_legend = ui.label("", 4, y, 12, C.sub); rv_legend:width(W - 10); y = y + LH * 2 + 3
|
|
|
|
local bw = math.min(96, (W - 20) // 3)
|
|
ui.button(tr("Back"), 4, y, bw, 32, function() leave_review() end)
|
|
ui.button(tr("Colour"), 8 + bw, y, bw, 32, function()
|
|
mode = mode % #MODES + 1
|
|
sys.toast(MODES[mode], 1000)
|
|
review_plot(); review_stat()
|
|
end)
|
|
-- Refit re-reads from the start rather than only redrawing: queued rows can land in the file
|
|
-- while this screen is open, and this is the way to see them without leaving and coming back.
|
|
ui.button(tr("Refit"), 12 + bw * 2, y, bw, 32, function()
|
|
ld_reset()
|
|
review_stat()
|
|
end)
|
|
|
|
review_stat()
|
|
end
|
|
|
|
build_survey = function()
|
|
drop_map() -- must precede clear(); see drop_map
|
|
clear()
|
|
rv_plot, rv_stat, rv_legend, map_note = nil, nil, nil, nil
|
|
ui.scroll(true)
|
|
local LH = ui.text_h(12)
|
|
local y = 4
|
|
|
|
hdr = ui.label(tr("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(tr("Start"), 4, y, bw, 32, function()
|
|
running = not running
|
|
if running then phase, phase_at = "idle", 0 end
|
|
sys.toast(running and tr("Survey running") or tr("Survey stopped"), 1200)
|
|
end)
|
|
ui.button(tr("Name"), 8 + bw, y, bw, 32, function()
|
|
ui.input(tr("Name this run"), run, function(text)
|
|
if text then
|
|
-- Land whatever is queued into the file it was surveyed for. Renaming with rows still in
|
|
-- the queue used to write the old sweep into the new run's file.
|
|
flush()
|
|
pending = {} -- anything the rate limit refused belongs to the old run
|
|
run = text:gsub("[^%w%-_]", "_")
|
|
wrote_header = nil -- unknown again: this file may or may not already have one
|
|
sys.toast("Logging to " .. logname(), 1500)
|
|
end
|
|
end)
|
|
end)
|
|
ui.button(tr("Reset"), 12 + bw * 2, y, bw, 32, function()
|
|
nodes, samples, sweeps, pending, node_count = {}, 0, 0, {}, 0
|
|
dropped_sweeps = 0
|
|
mesh.discover_clear()
|
|
fs.remove(logname())
|
|
wrote_header = nil -- the file is gone, so the next flush re-checks and writes one
|
|
sys.toast("Cleared " .. logname(), 1200)
|
|
end)
|
|
y = y + 38
|
|
|
|
-- Its own row rather than a fourth column: the three-button row already lands
|
|
-- at 53 px each on a 240 px board, and a fourth would make every label a
|
|
-- guess. The page scrolls, so a row costs nothing a narrow screen cannot pay.
|
|
ui.button(tr("Review run"), 4, y, math.min(bw * 2 + 4, W - 8), 32, function()
|
|
enter_review() -- harvests and flushes for itself
|
|
end)
|
|
y = y + 38
|
|
|
|
ui.label(tr("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()
|
|
end
|
|
|
|
function app.on_open(w, h)
|
|
W, H = w, h
|
|
-- Say what was actually tested. caps().discover and caps().sdk_ext are separate published flags,
|
|
-- and the old wording asserted the one the "if" never looked at.
|
|
if not sys.caps().discover then
|
|
ui.label(tr("This board cannot send discovery probes,"), 6, 8, 12, C.bad)
|
|
ui.label(tr("so it cannot run a coverage survey."), 6, 26, 12, C.bad)
|
|
return
|
|
end
|
|
build_survey()
|
|
timer.every(1000)
|
|
end
|
|
|
|
function app.on_tick()
|
|
-- flush() runs in every view. Queued rows are only cleared once the write
|
|
-- lands, so a view that stopped draining would grow `pending` without bound.
|
|
if view == "review" then
|
|
local more = ld_step()
|
|
review_stat()
|
|
if not more and ld and ld.done and not ld.plotted then
|
|
ld.plotted = true
|
|
review_plot()
|
|
end
|
|
flush()
|
|
return
|
|
end
|
|
|
|
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
|
|
-- Ticks pause while the display sleeps; millis() does not. Waking finds the deadline long
|
|
-- past, the radio still holding replies heard at the old position, and sys.gps() reporting
|
|
-- the new one - so recording them would place the old doorway's radio on this sidewalk.
|
|
-- Anything far beyond the harvest window is discarded rather than mislocated.
|
|
if harvest_fresh(now) then
|
|
harvest()
|
|
else
|
|
mesh.discover_clear()
|
|
dropped_sweeps = dropped_sweeps + 1
|
|
end
|
|
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
|
|
drop_map()
|
|
end
|
|
|
|
return app
|