Files
wadamesh/deploy/apps/monitor/1.4/monitor.lua
T
Kaj SchittecatandClaude Opus 5.5 d3a9cf1453 touch: radio tools redesign, Music app, Store update badge, triage fixes
Spectrum: stat tiles (peak, floor, our channel, busy), plot and waterfall drawn
at screen size (the lv_chart and zoomed waterfall were the page's render cost),
dark-to-yellow waterfall, peak hold, our-channel marker, Band/Channel and
Live/1 min/10 min chips. RF Monitor 1.4 (tiles, filled chart, typed feed, packet
detail card; the old type table did not match MeshCore payload types) and
Airtime 1.6 (big figure, tiles, duty line, rx/tx split).

Lua SDK: ui.panel, bold labels and a size-24 figure font, colors.warn/border,
app.on_audio (fires with the screen off), read-only wada.sd.list and sd: audio on
the SD_MMC boards (P4, Tanmatsu, Wio L2), caps panel/audio_events/epaper, a
"music" icon and an "audio" Store requirement. button:set() no longer writes
label fields into the button object (heap corruption, every earlier firmware).
The SDK page's input table described events the firmware never sent.

Music 1.0 (Store app): plays MP3/WAV from the card's Music folder, shuffle,
repeat, keeps going through the list with the screen off. Verified on the
T-Display P4 end to end.

Store: the update check also fetches the app catalog and badges the Store tile
with the number of installed apps that have a newer version. Baked Lua apps are
raw-deflated and inflated by the S3 ROM (14.6 KB back on the V4); the V4 language
table keeps only texts of apps it can run.

Fixes: the SD-card notice no longer fires when the card mounts fine (#667); the
Control Center clock keeps time while open (#666); M9 keyboard brightness (#664);
the battery widget's number no longer sits on a ring too small for it (Discord);
M9 shortcuts doc follows #669's composer order. New texts in all 14 languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:07:11 +02:00

387 lines
15 KiB
Lua

-- RF Monitor (Lua): the channel at a glance. Stat tiles (RSSI, noise, link
-- margin, packets a minute), a filled signal chart with the noise floor dashed
-- under it, and the packets heard most recently with their type and signal.
-- Tap a packet (or select it with the keys) for its details: who sent it, the
-- route, the margin above what the spreading factor can still decode, its size
-- and how long it was on the air.
--
-- Looks best on firmware with wada.ui.panel (beta_95 and later); on older
-- firmware the same screens draw without the framed panels.
local ui, sys, mesh, timer = wada.ui, wada.sys, wada.mesh, wada.timer
local tr = sys.tr or function(x) return x end
local C = ui.colors
local WARN = C.warn or 0xE8A33D
local PANEL = C.panel or 0x15181B
local app = {}
local RSSI_MIN, RSSI_MAX = -130, -30
-- MeshCore payload types: the type field of the frame header.
local TYPE = { [0]="REQ", [1]="RSP", [2]="TXT", [3]="ACK", [4]="ADV", [5]="GRP", [6]="DAT",
[7]="ANO", [8]="PTH", [9]="TRC", [10]="MPT", [11]="CTL", [15]="RAW" }
local TNAME = { [0]=tr("Request"), [1]=tr("Response"), [2]=tr("Direct message"), [3]=tr("Acknowledgement"),
[4]=tr("Advert"), [5]=tr("Channel message"), [6]=tr("Channel data"), [7]=tr("Anonymous request"),
[8]=tr("Path"), [9]=tr("Trace"), [10]=tr("Multipart"), [11]=tr("Control"), [15]=tr("Custom") }
local TCOL = { [0]=0xE8A33D, [1]=0xF2793C, [2]=0x4F9DF7, [3]=0x53C06B, [4]=0xA784E0,
[5]=0x35C9C9, [6]=0x35C9C9, [7]=0xE8A33D, [8]=0x9AA3B2, [9]=0xD4537E }
local ROUTE = { [0]=tr("Flood with a region code"), [1]=tr("Flood"), [2]=tr("Direct"), [3]=tr("Direct with a region code") }
-- The SNR each spreading factor can still decode (Semtech SX126x datasheet).
local SF_LIMIT = { [5]=-2.5, [6]=-5, [7]=-7.5, [8]=-10, [9]=-12.5, [10]=-15, [11]=-17.5, [12]=-20 }
local function mix(a, b, t) -- colour a over b by t (0..1)
local function ch(s)
local x, y = (a >> s) & 0xFF, (b >> s) & 0xFF
return math.floor(y + (x - y) * t + 0.5) << s
end
return ch(16) | ch(8) | ch(0)
end
local AREA = { mix(C.accent, PANEL, 0.50), mix(C.accent, PANEL, 0.30), mix(C.accent, PANEL, 0.14) }
local GRID = mix(C.sub, PANEL, 0.30)
local SEL = mix(C.accent, PANEL, 0.22)
local W, H, narrow, LH12, LH16
local mode = "main"
local tiles = {}
local cv, CW, CH = nil, 0, 0
local NPTS = 2
local info
local rows, row_y0, row_h, rows_n, hl = {}, 0, 0, 0, nil
local sel = 0
local shown = {} -- the rx_log rows as last drawn, for the detail card
local names = {} -- contact pubkey prefix (8 hex) -> name
local rs, nz = {}, {} -- chart history
local rx0, t0
local stats
local function panel(x, y, w, h, col)
if ui.panel then return ui.panel(x, y, w, h, col or PANEL, 6) end
end
local function fmt_ago(ms)
local s = ms // 1000
if s < 60 then return s .. "s" end
if s < 3600 then return (s // 60) .. "m" end
return (s // 3600) .. "h"
end
local function bars_for(rssi, snr) -- 0..4, mostly from SNR: it is what decodes
if snr >= 5 then return 4 elseif snr >= 0 then return 3 elseif snr >= -6 then return 2 end
if rssi > -120 then return 1 end
return 0
end
local function grade(m)
if m >= 20 then return C.good end
if m >= 10 then return C.good end
if m >= 5 then return WARN end
return C.bad
end
-- LoRa time on air (Semtech AN1200.13): explicit header, CRC on, 16-symbol preamble.
local function airtime_ms(len, sf, bw_khz, cr)
if not sf or not bw_khz or bw_khz <= 0 then return nil end
local ts = (1 << sf) / bw_khz
local de = ts > 16 and 1 or 0
local n = math.ceil((8 * len - 4 * sf + 44) / (4 * (sf - 2 * de))) * (cr + 4)
if n < 0 then n = 0 end
return (16 + 4.25) * ts + (8 + n) * ts
end
local function sender(r)
if r.pubkey then return names[r.pubkey] or r.pubkey, r.pubkey end
if r.src then
local hit, key
for pk, nm in pairs(names) do
if pk:sub(1, 2) == r.src then
if hit then return r.src, r.src end -- two contacts share the byte: say so plainly
hit, key = nm, pk
end
end
if hit then return hit, r.src end
return r.src, r.src
end
return nil
end
-- ---- the chart -------------------------------------------------------------
local function ypix(v)
v = math.floor(v)
if v < RSSI_MIN then v = RSSI_MIN elseif v > RSSI_MAX then v = RSSI_MAX end
return (CH - 1) - (v - RSSI_MIN) * (CH - 1) // (RSSI_MAX - RSSI_MIN)
end
local function draw_chart()
if not cv then return end
cv:fill(PANEL)
for g = 0, 2 do
local gy = g * (CH - 1) // 2
cv:line(0, gy, CW - 1, gy, GRID)
end
local n = #rs
local b1, b2 = CH // 3, 2 * CH // 3
local px, py
for i = 1, n do
local x = (NPTS - n + i - 1) * (CW - 1) // (NPTS - 1)
local x2 = (NPTS - n + i) * (CW - 1) // (NPTS - 1)
if x2 <= x then x2 = x + 1 end
local y = ypix(rs[i])
-- the area under the line, in three tones that fade towards the bottom
if y < b1 then cv:rect(x, y, x2 - x, b1 - y, AREA[1]); y = b1 end
if y < b2 then cv:rect(x, y, x2 - x, b2 - y, AREA[2]); y = b2 end
if y < CH then cv:rect(x, y, x2 - x, CH - y, AREA[3]) end
end
for i = 1, n do
local x = (NPTS - n + i - 1) * (CW - 1) // (NPTS - 1)
local y = ypix(rs[i])
if px then cv:line(px, py, x, y, C.accent, 2) end
px, py = x, y
end
px = nil
for i = 1, #nz do
local x = (NPTS - #nz + i - 1) * (CW - 1) // (NPTS - 1)
local y = ypix(nz[i])
if px and i % 2 == 0 then cv:line(px, py, x, y, C.sub, 1) end
px, py = x, y
end
end
-- ---- the feed --------------------------------------------------------------
local function paint_selection()
if not hl then return end
if sel > 0 and sel <= rows_n then hl:pos(6, row_y0 + (sel - 1) * row_h) else hl:pos(-2000, 0) end
end
local function paint_feed(log)
shown = {}
for i = 1, rows_n do
local r, row = log[i], rows[i]
shown[i] = r
if r then
if row.pill and row.hidden then -- an empty row filling up: put its pill and bars back
row.pill:pos(row.px, row.py)
for b = 1, 4 do row.bar[b]:pos(row.bx[b], row.by[b]) end
row.hidden = false
end
local col = TCOL[r.type] or C.sub
row.tag:set(TYPE[r.type] or tostring(r.type)); row.tag:color(col)
if row.pill then row.pill:color(mix(col, PANEL, 0.22)) end
local nb = bars_for(r.rssi, r.snr)
local bc = nb >= 3 and C.good or nb == 2 and WARN or C.bad
for b = 1, 4 do
if row.bar[b] then row.bar[b]:color(b <= nb and bc or mix(C.sub, PANEL, 0.25)) end
end
row.rssi:set(narrow and tostring(r.rssi) or (r.rssi .. " dBm"))
row.snr:set(string.format(narrow and "%.1f" or "%.1f dB", r.snr))
row.hops:set(r.hops == 0 and tr("direct") or (r.hops .. (narrow and "h" or (r.hops == 1 and " hop" or " hops"))))
row.ago:set(fmt_ago(r.ago_ms))
else
row.tag:set(""); row.rssi:set(i == 1 and #log == 0 and tr("listening, nothing heard yet") or "")
row.snr:set(""); row.hops:set(""); row.ago:set("")
if row.pill and not row.hidden then -- an empty row shows nothing at all
row.pill:pos(-2000, 0)
for b = 1, 4 do row.bar[b]:pos(-2000, 0) end
row.hidden = true
end
end
end
end
local function refresh()
if mode ~= "main" then return end
stats = mesh.stats()
local st = stats
rs[#rs + 1] = st.rssi; if #rs > NPTS then table.remove(rs, 1) end
nz[#nz + 1] = st.noise; if #nz > NPTS then table.remove(nz, 1) end
-- Margin: how far the newest packet's SNR sat above what this spreading factor
-- can still decode. The idle channel's RSSI minus its noise is ~0 by definition.
local log = mesh.rx_log()
local newest = log[1]
local limit = SF_LIMIT[st.sf]
local mins = math.max((sys.millis() - t0) / 60000, 1 / 60)
tiles[1]:set(string.format("%.0f", st.rssi))
tiles[2]:set(string.format("%.0f", st.noise))
if newest and limit then
local mg = newest.snr - limit
tiles[3]:set(string.format("%.0f dB", mg)); tiles[3]:color(grade(mg))
else
tiles[3]:set("--"); tiles[3]:color(C.sub)
end
tiles[4]:set(string.format("%.1f", (st.rx_pkts - rx0) / mins))
info:set(string.format(narrow and "%.3f SF%d BW%.0f" or "%.3f MHz SF%d BW %.1f rx %d err %d drop %d",
st.freq, st.sf, st.bw, st.rx_pkts, st.rx_err, st.rx_dropped))
draw_chart()
paint_feed(log)
end
local function build_main()
mode = "main"
if ui.clear then ui.clear() end
local gap, y = 4, 4
local labels = { tr("RSSI"), tr("Noise"), tr("Margin"), tr("RX/min") }
local tw = (W - 8 - gap * 3) // 4
local th = LH12 + LH16 + 8
for i, k in ipairs(labels) do
local x = 4 + (i - 1) * (tw + gap)
panel(x, y, tw, th)
local kl = ui.label(k, x + 6, y + 3, 12, C.sub); kl:width(tw - 8)
tiles[i] = ui.label("--", x + 6, y + 3 + LH12, 16, C.text, true)
end
y = y + th + 6
-- chart: dB labels in the gutter, the canvas inset in a framed panel
local AX = (ui.text_w and ui.text_w("-130", 12) or 28) + 8
-- the chart gives way to the feed on short screens (the T-Deck's 240 px)
CH = H < 260 and 44 or (narrow and 58 or 70)
panel(AX, y, W - AX - 4, CH + 6)
CW = W - AX - 10
cv = ui.canvas(CW, CH); cv:pos(AX + 3, y + 3)
NPTS = math.max(CW // 2, 2)
local gl = { RSSI_MAX, (RSSI_MAX + RSSI_MIN) // 2, RSSI_MIN }
for g = 0, 2 do
local l = ui.label(tostring(gl[g + 1]), 0, y + 3 + g * (CH - 1) // 2 - LH12 // 2, 12, C.sub)
l:width(AX - 6, "right")
end
y = y + CH + 6 + 3
info = ui.label("", 6, y, 12, C.sub); info:width(W - 12)
y = y + LH12 + 6
-- recently heard
row_h = LH12 + 6
local avail = H - y - 6
rows_n = math.max(1, math.min(10, avail // row_h))
panel(4, y, W - 8, rows_n * row_h + 4)
row_y0 = y + 2
hl = ui.panel and ui.panel(-2000, 0, W - 12, row_h, SEL, 4) or nil
local pill_w = (ui.text_w and ui.text_w("ADV", 12) or 26) + 10
local x_bar = 10 + pill_w + 6
local x_rssi = x_bar + 18
local w_rssi = (ui.text_w and ui.text_w(narrow and "-120" or "-120 dBm", 12) or 50) + 8
local x_snr = x_rssi + w_rssi
local w_snr = (ui.text_w and ui.text_w(narrow and "-12.5" or "-12.5 dB", 12) or 50) + 8
local x_hops = x_snr + w_snr
local w_ago = (ui.text_w and ui.text_w("59m", 12) or 24) + 2
for i = 1, rows_n do
local ry = row_y0 + (i - 1) * row_h
local row = { bar = {}, bx = {}, by = {}, px = 10, py = ry + 2 }
row.pill = panel(10, ry + 2, pill_w, row_h - 4, PANEL)
row.tag = ui.label("", 10, ry + 3, 12, C.text); row.tag:width(pill_w, "center")
for b = 1, 4 do
local bh = 2 + b * (row_h - 8) // 4
row.bx[b], row.by[b] = x_bar + (b - 1) * 4, ry + row_h - 3 - bh
row.bar[b] = panel(row.bx[b], row.by[b], 3, bh, PANEL)
end
row.rssi = ui.label("", x_rssi, ry + 3, 12, C.text)
row.snr = ui.label("", x_snr, ry + 3, 12, C.sub)
row.hops = ui.label("", x_hops, ry + 3, 12, C.text)
row.ago = ui.label("", W - 10 - w_ago, ry + 3, 12, C.sub); row.ago:width(w_ago, "right")
rows[i] = row
end
paint_selection()
refresh()
end
local function build_detail(r)
mode = "detail"
if ui.clear then ui.clear() end
local st = stats or mesh.stats()
local col = TCOL[r.type] or C.text
local y = 4
ui.label(TNAME[r.type] or tr("Packet"), 6, y, 16, col, true)
local ago = ui.label(string.format(tr("%s ago"), fmt_ago(r.ago_ms)), W - 96, y + 2, 12, C.sub)
ago:width(90, "right")
y = y + LH16 + 6
local nm, key = sender(r)
local limit = SF_LIMIT[st.sf]
local margin = limit and (r.snr - limit) or nil
local air = airtime_ms(r.len, st.sf, st.bw, 1)
local cells = {
{ tr("RSSI"), r.rssi .. " dBm", C.text },
{ tr("SNR"), string.format("%.1f dB", r.snr), C.text },
{ tr("Margin"), margin and string.format("%.1f dB", margin) or "--", margin and grade(margin) or C.sub },
{ tr("Hops"), r.hops == 0 and tr("direct") or tostring(r.hops), C.text },
{ tr("Size"), r.len .. " B", C.text },
{ tr("Airtime"), air and string.format("%d ms", math.floor(air + 0.5)) or "--", C.text },
}
local gap, cols = 4, 3
local tw = (W - 8 - gap * (cols - 1)) // cols
local th = LH12 + LH16 + 8
for i, c in ipairs(cells) do
local cx = 4 + ((i - 1) % cols) * (tw + gap)
local cy = y + ((i - 1) // cols) * (th + gap)
panel(cx, cy, tw, th)
local k = ui.label(c[1], cx + 6, cy + 3, 12, C.sub); k:width(tw - 8)
ui.label(c[2], cx + 6, cy + 3 + LH12, 16, c[3], true)
end
y = y + 2 * (th + gap) + 4
local lines = {}
if nm then lines[#lines + 1] = { tr("From"), nm ~= key and (nm .. " (" .. key .. ")") or key } end
lines[#lines + 1] = { tr("Route"), ROUTE[r.route] or "?" }
if limit then
lines[#lines + 1] = { tr("Decodes to"), string.format(tr("%.1f dB SNR at SF%d"), limit, st.sf) }
end
local kw = 0
for _, l in ipairs(lines) do
local w = ui.text_w and ui.text_w(l[1], 12) or 70
if w > kw then kw = w end
end
for _, l in ipairs(lines) do
ui.label(l[1], 8, y, 12, C.sub)
local v = ui.label(l[2], 8 + kw + 10, y, 12, C.text); v:width(W - kw - 26)
y = y + LH12 * (ui.text_lines and ui.text_lines(l[2], W - kw - 26, 12) or 1) + 3
end
local back = ui.label(tr("Tap to go back"), 6, H - LH12 - 4, 12, C.sub); back:width(W - 12, "center")
end
function app.on_open(w, h)
W, H, narrow = w, h, w < 280
LH12, LH16 = ui.text_h(12), ui.text_h(16)
local st = mesh.stats()
rx0, t0 = st.rx_pkts, sys.millis()
local ok, list = pcall(mesh.contacts, 0, 250)
if ok and type(list) == "table" then
for _, c in ipairs(list) do if c.pubkey and c.name then names[c.pubkey] = c.name end end
end
build_main()
timer.every(1000)
end
function app.on_tick(dt) refresh() end
local function open_row(i)
if not ui.clear then return end -- older firmware cannot swap screens
local r = shown[i]
if r then build_detail(r) end
end
-- Input: a tap ("up" after "down", no swipe in between) opens the packet under
-- the finger. Swipes, the trackball and the d-pad arrive as "swipe" and move a
-- highlight; Enter, or the select key (a synthetic tap at the page centre),
-- opens the highlighted packet. On the detail card anything goes back.
local swiped = false
function app.on_input(ev)
if ev.type == "down" then swiped = false; return end
if mode == "detail" then
if (ev.type == "up" and not swiped) or ev.type == "swipe"
or (ev.type == "key" and (ev.key == "enter" or ev.key == "backspace" or ev.key == "left")) then
build_main()
end
if ev.type == "swipe" then swiped = true end
return
end
if ev.type == "swipe" then
swiped = true
if ev.dir == "down" then sel = math.min(rows_n, sel + 1); paint_selection()
elseif ev.dir == "up" then sel = math.max(0, sel - 1); paint_selection() end
elseif ev.type == "key" then
if ev.key == "down" then sel = math.min(rows_n, sel + 1); paint_selection()
elseif ev.key == "up" then sel = math.max(0, sel - 1); paint_selection()
elseif ev.key == "enter" and sel > 0 then open_row(sel) end
elseif ev.type == "up" and not swiped then
if sel > 0 and ev.x == W // 2 and ev.y == H // 2 then open_row(sel) -- the select key
elseif ev.y >= row_y0 and ev.y < row_y0 + rows_n * row_h then open_row((ev.y - row_y0) // row_h + 1) end
end
end
return app