This commit is contained in:
Kaj Schittecat committed 2026-08-27 10:23:29 +02:00
commit 159d5bb709
14 files changed
+517 -64

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@
{
"id": "wardrive",
"name": "Wardrive",
"ver": "1.0",
"ver": "1.1",
"desc": "LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV. Reference app for wada.mesh.discover."
},
{
+1
View File
@@ -0,0 +1 @@
{"id":"wardrive","name":"Wardrive","ver":"1.1","desc":"LoRa coverage survey. Probes every 20s, logs each reply with GPS position, altitude and BOTH link directions to a CSV you can pull off the device. Reference app for wada.mesh.discover."}
+216
View File
@@ -0,0 +1,216 @@
-- 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
+1 -1
View File
@@ -5,7 +5,7 @@ it ever touches a device. Not part of the firmware build.
```sh
scripts/lua-harness/run.sh # gpscompass, all scenarios
scripts/lua-harness/run.sh deploy/apps/wardrive/1.0/wardrive.lua
scripts/lua-harness/run.sh deploy/apps/wardrive/1.1/wardrive.lua
SCENARIO=declination scripts/lua-harness/run.sh # one scenario
```
+62 -3
View File
@@ -10,6 +10,27 @@ local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not
local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end
local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end
local function valid_utf8(text)
local i, n = 1, #text
while i <= n do
local first = text:byte(i)
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 i + width - 1 > n then return false end
for j = 2, width do
local byte = text:byte(i + j - 1)
if byte < 0x80 or byte > 0xBF then return false end
end
local second = width > 1 and text:byte(i + 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 return false end
i = i + width
end
return true
end
-- instruction budget like guardedCall(): error() out of the hook, pcall catches
local function guarded(budget, fn, ...)
local count = 0
@@ -77,7 +98,7 @@ local function mkbutton(text, x, y, w, h, fn)
end
local function build_wada()
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {} }
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} }
wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B }
wada.ui.canvas = mkcanvas
wada.ui.label = mklabel
@@ -102,7 +123,7 @@ local function build_wada()
wada.sys.beep = function() return false end
wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard,
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
accel = cfg.caps.accel } end
accel = cfg.caps.accel, discover = cfg.caps.discover } end
if cfg.caps.sdk_ext then
wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end
wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end
@@ -118,6 +139,12 @@ local function build_wada()
wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end
wada.mesh.stats = function() return {} end
wada.mesh.rx_log = function() return {} end
wada.mesh.discover = function() return "probe-1" end
wada.mesh.discovered = function() return cfg.discovered or {} end
wada.mesh.discover_clear = function() cfg.discovered = {} end
wada.fs.append = function(name, data) checkstr(name, "fs.append name"); checkstr(data, "fs.append data"); return true end
wada.fs.remove = function(name) checkstr(name, "fs.remove name"); return true end
wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end
wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string")
@@ -204,6 +231,36 @@ local contacts_fixture = {
local scenarios = {}
scenarios.wardrive_utf8 = function()
cfg = {
w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true },
discovered = {
{ pubkey = "01020304", name = "Ouderkerk☀️", type = 2,
rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 }
}
}
cfg.gps = function()
return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000,
sats = 9, alt_m = 3, time = 1787620000 }
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing")
assert(guarded(BUDGET, buttons[1].fn))
tick(app, 1, 100)
tick(app, 1, 4000)
local found = false
for _, label in ipairs(labels) do
assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8")
if label.text:find("Ouderkerk☀", 1, true) then found = true end
end
assert(found, "emoji-bearing repeater name was not rendered")
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- The declination model, as it actually ships. Rather than testing a copy in
-- out/wmm, this pulls the do-block straight out of the app file that gets
-- sideloaded, so an inlining mistake fails here instead of on the device.
@@ -711,7 +768,9 @@ scenarios.cost = function()
assert(worst < BUDGET / 4, "tick too expensive")
end
local order = { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
local order = APP_PATH:find("/wardrive/", 1, true)
and { "wardrive_utf8" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
for _, name in ipairs(order) do
if SCENARIO == "all" or SCENARIO == name then
print("== " .. name)
+8
View File
@@ -183,6 +183,14 @@ bool pagerKeyboardAltHeld() { return s_alt; }
void pagerKeyboardMarkAltUsed() { s_alt_used = true; }
void pagerKeyboardDiscardAlt() {
s_alt = false;
s_alt_used = true;
s_alt_tap_pending = false;
s_alt_shift_chord_pending = false;
s_alt_backspace_chord_pending = false;
}
bool pagerKeyboardConsumeAltTap() {
if (!s_alt_tap_pending) return false;
s_alt_tap_pending = false;
+4
View File
@@ -50,6 +50,10 @@ bool pagerKeyboardAltHeld();
* pagerKeyboardConsumeAltTap(). */
void pagerKeyboardMarkAltUsed();
/** Clear held/tapped Alt and pending Alt chords when keyboard input is
* intentionally discarded during a UI mode transition. */
void pagerKeyboardDiscardAlt();
/** One-shot: true exactly once if Alt was pressed and released without being
* used as a modifier for anything else in between (no key typed, no
* pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a
+4
View File
@@ -66,4 +66,8 @@ int tdeckKeyboardReadKey() {
return key;
}
void tdeckKeyboardDiscardModifiers() {}
void tdeckKeyboardAllowModifiers() {}
#endif
+6
View File
@@ -23,6 +23,12 @@ void tdeckKeyboardPoll();
/** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */
int tdeckKeyboardReadKey();
/** Compatibility hooks for UI mode transitions. Modifier state belongs to
* the keyboard's C3 firmware in this legacy ASCII driver, so there is no
* host-side state to clear or re-enable. */
void tdeckKeyboardDiscardModifiers();
void tdeckKeyboardAllowModifiers();
/** Request a keyboard-backlight level (0 = off, 0xFF = on). Safe from the UI
* thread — the I2C write happens inside tdeckKeyboardPoll() (core 0), which
* owns the bus. Only re-sent when the value changes. */
+54 -16
View File
@@ -18,6 +18,7 @@
#include "AppPage.h"
#include "device_caps.h"
#include "i18n.h" // wada.sys.tr: apps can use the same translation table the UI does
#include "Utf8Text.h"
#include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC
extern "C" {
@@ -246,6 +247,19 @@ uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) {
return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF;
}
// LVGL assumes structurally valid UTF-8 and can stop advancing when an app
// hands it a partial codepoint. Keep one reusable repair buffer: valid strings
// stay zero-copy, while malformed runs become one ASCII '?'. Every call below
// is synchronous and LVGL copies label text before this buffer can be reused.
const char* safeUiText(const char* text, size_t length, size_t* safe_length = nullptr,
unsigned scratch_slot = 0) {
static std::string scratch_slots[2];
std::string& scratch = scratch_slots[scratch_slot & 1U];
const char* safe = Utf8Text::sanitize(text, length, scratch);
if (safe_length) *safe_length = safe == text ? length : scratch.size();
return safe;
}
// ---- canvas userdata ----
struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; };
@@ -298,11 +312,14 @@ int cvCircle(lua_State* L) {
int cvText(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
size_t text_length = 0;
const char* text = luaL_checklstring(L, 4, &text_length);
lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d);
d.color = lv_color_hex(argColor(L, 5));
d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14));
lv_canvas_draw_text(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3),
c->w, &d, luaL_checkstring(L, 4));
lv_canvas_draw_text(c->obj, x, y, c->w, &d, safeUiText(text, text_length));
return 0;
}
int cvPos(lua_State* L) {
@@ -357,7 +374,9 @@ WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wa
int lbSet(lua_State* L) {
WidgetUd* u = checkLabel(L);
if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2));
size_t length = 0;
const char* text = luaL_checklstring(L, 2, &length);
if (u->obj) lv_label_set_text(u->obj, safeUiText(text, length));
return 0;
}
int lbPos(lua_State* L) {
@@ -390,11 +409,17 @@ int lbWidth(lua_State* L) { // label:width(px [, "left"|"center"|"right"]) —
int uiLabel(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
size_t length = 0;
const char* text = luaL_checklstring(L, 1, &length);
const lv_coord_t x = (lv_coord_t)luaL_optinteger(L, 2, 0);
const lv_coord_t y = (lv_coord_t)luaL_optinteger(L, 3, 0);
const lv_font_t* font = luaHostFontForSize((int)luaL_optinteger(L, 4, 14));
const uint32_t color = argColor(L, 5, 0xE6E9ED);
lv_obj_t* l = lv_label_create(s_h->body);
lv_label_set_text(l, luaL_checkstring(L, 1));
lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0));
lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN);
lv_label_set_text(l, safeUiText(text, length));
lv_obj_set_pos(l, x, y);
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN);
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
ud->obj = l;
luaL_setmetatable(L, "wada.label");
@@ -497,12 +522,17 @@ void btnEventCb(lv_event_t* e) {
int uiButton(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
const char* txt = luaL_checkstring(L, 1);
size_t text_length = 0;
const char* text = luaL_checklstring(L, 1, &text_length);
const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
const lv_coord_t w = (lv_coord_t)luaL_optinteger(L, 4, 90);
const lv_coord_t h = (lv_coord_t)luaL_optinteger(L, 5, 34);
lv_obj_t* b = lv_btn_create(s_h->body);
lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34));
lv_obj_set_pos(b, x, y);
lv_obj_set_size(b, w, h);
lv_obj_t* bl = lv_label_create(b);
lv_label_set_text(bl, txt);
lv_label_set_text(bl, safeUiText(text, text_length));
lv_obj_center(bl);
if (lua_isfunction(L, 6)) {
lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb);
@@ -554,6 +584,7 @@ int uiTextW(lua_State* L) {
size_t len = 0;
const char* txt = luaL_checklstring(L, 1, &len);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14));
txt = safeUiText(txt, len, &len);
lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE));
return 1;
}
@@ -566,6 +597,7 @@ int uiTextLines(lua_State* L) {
const char* txt = luaL_checklstring(L, 1, &len);
const int w = (int)luaL_checkinteger(L, 2);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14));
txt = safeUiText(txt, len, &len);
if (w <= 0) { lua_pushinteger(L, 1); return 1; }
int lines = 0;
const char* p = txt;
@@ -605,7 +637,8 @@ int uiList(lua_State* L) {
// list:add(text [, fn]) -> index of the new row
int lsAdd(lua_State* L) {
ListUd* u = checkList(L);
const char* txt = luaL_checkstring(L, 2);
size_t text_length = 0;
const char* text = luaL_checklstring(L, 2, &text_length);
if (!u->obj || !s_h) return 0;
lv_obj_t* row = lv_btn_create(u->obj);
lv_obj_set_width(row, LV_PCT(100));
@@ -614,7 +647,7 @@ int lsAdd(lua_State* L) {
lv_obj_set_style_pad_all(row, 6, LV_PART_MAIN);
listPaintRow(row, false);
lv_obj_t* lb = lv_label_create(row);
lv_label_set_text(lb, txt);
lv_label_set_text(lb, safeUiText(text, text_length));
lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); // a long name truncates instead of reflowing the row
lv_obj_set_width(lb, LV_PCT(100));
lv_obj_set_style_text_font(lb, luaHostFontForSize(12), LV_PART_MAIN);
@@ -633,9 +666,11 @@ int lsAdd(lua_State* L) {
int lsSet(lua_State* L) {
ListUd* u = checkList(L);
lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2));
size_t text_length = 0;
const char* text = luaL_checklstring(L, 3, &text_length);
if (!row) return 0;
lv_obj_t* lb = lv_obj_get_child(row, 0);
if (lb) lv_label_set_text(lb, luaL_checkstring(L, 3));
if (lb) lv_label_set_text(lb, safeUiText(text, text_length));
return 0;
}
int lsColor(lua_State* L) {
@@ -1939,11 +1974,14 @@ void promptDeliver(const char* text) {
}
int uiInput(lua_State* L) {
const char* title = luaL_optstring(L, 1, "");
const char* initial = luaL_optstring(L, 2, "");
size_t title_length = 0, initial_length = 0;
const char* title_raw = luaL_optlstring(L, 1, "", &title_length);
const char* initial_raw = luaL_optlstring(L, 2, "", &initial_length);
luaL_checktype(L, 3, LUA_TFUNCTION);
if (!s_h) return luaL_error(L, "no app");
if (s_h->prompt_cb != LUA_NOREF) return luaL_error(L, "an input prompt is already open");
const char* title = safeUiText(title_raw, title_length, nullptr, 0);
const char* initial = safeUiText(initial_raw, initial_length, nullptr, 1);
lua_pushvalue(L, 3);
s_h->prompt_cb = luaL_ref(L, LUA_REGISTRYINDEX);
luaHostTextPrompt(title, initial, promptDeliver);
+20 -41
View File
@@ -119,7 +119,7 @@
#include <helpers/input/TDeckTrackball.h>
#endif
#if defined(HAS_TDECK_KEYBOARD)
#include <helpers/input/TDeckKeyboard.h>
#include "../helpers/input/TDeckKeyboard.h"
#elif defined(HAS_M9_KEYBOARD)
#include <M9Keyboard.h>
#endif
@@ -130,7 +130,7 @@
#include <M9Imu.h> // wada.sys.accel() source (luaHostAccel)
#endif
#if defined(HAS_PAGER_KEYBOARD)
#include <helpers/input/PagerKeyboard.h>
#include "../helpers/input/PagerKeyboard.h"
#endif
#if defined(HAS_PAGER_ENCODER)
#include <helpers/input/PagerEncoder.h>
@@ -36216,30 +36216,6 @@ static void updatePagerBackspaceHold(unsigned long now) {
s_was_held = held;
}
// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the
// encoder's Alt+turn). In an open chat it takes over the old Backspace role:
// jump to the latest message (when scrolled up in history) and drop focus in
// the composer, ready to type — the natural "done reading, reply now" motion,
// and the deliberate way OUT of the message list now that plain encoder turns
// hold focus inside it while history loads (pagerEncoderChatEdgeScroll).
// Everywhere else it stays the same "next field" as one rotary NEXT detent.
// Call once per loop tick while the screen is on; screen-off handling discards
// any pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray
// tap picked up while idle-dimmed can't fire the moment the screen wakes.
static void updatePagerAltTapNext() {
if (!pagerKeyboardConsumeAltTap()) return;
LvChatPanel* cp = navOpenChatPanel();
if (cp && cp->msgs && cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) {
if (chatVirtAwayFromBottom(cp)) chatVirtJumpToLatest(cp);
lv_group_focus_obj(cp->composer_ta);
s_nav_show = true;
if (g_lv.task) g_lv.task->noteUserInput();
return;
}
navPushTap(LV_KEY_NEXT);
if (g_lv.task) g_lv.task->noteUserInput();
}
// Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has
// no UI visibility): toggles Caps Lock while actually editing a text field
// (the field is where "Caps Lock" means anything) and is a deliberate no-op
@@ -36454,9 +36430,7 @@ static bool pagerEncoderScrollOversizedFocused(bool up) {
// the list instead (same navScrollFocused the Alt+turn branch uses): the
// scroll fires the virtualization render, the neighbor bubble joins the nav
// group via the tree-signature rebuild, and the next detent walks onto it.
// Focus only leaves the list at the TRUE oldest/newest message. The orange-key
// solo tap (updatePagerAltTapNext) pushes LV_KEY_NEXT directly and never comes
// through here, so it stays the deliberate "leave the messages now" exit.
// Focus only leaves the list at the TRUE oldest/newest message.
// Returns true when the detent was consumed as a scroll.
static bool pagerEncoderChatEdgeScroll(bool up) {
LvChatPanel* cp = navOpenChatPanel();
@@ -36627,8 +36601,8 @@ static void updatePagerEncoder(unsigned long now) {
if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput();
if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode
// Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a
// release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT.
// Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing
// Alt afterward does not also arm the one-shot symbol layer.
if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed();
if (s_mentionnav_active) {
@@ -38479,9 +38453,9 @@ if (g_lv.task && g_lv.task->isManualLock()) {
// below the "NEW ----" unread divider) and focus it, so the user reads the
// new messages chronologically with plain encoder turns from there; with
// no divider (nothing unread) it jumps to the NEWEST message instead.
// (The old Backspace role -- jump to latest + focus the composer -- moved
// to the solo orange/Alt tap, see updatePagerAltTapNext().) The divider
// jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
// The former solo-Alt shortcut now latches the symbol layer; at the true
// newest message, the next encoder detent leaves the list for the composer.
// The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
// path uses; both cases use the pending-focus re-aim so the recreated row
// for the target message ends up focused after the virtualization render.
// Outside a chat, Backspace keeps its normal no-op / hold-to-back
@@ -52959,6 +52933,8 @@ void UITask::loop() {
// started above.
bool con_activity = false;
#if defined(HAS_TDECK_KEYBOARD)
if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers();
else tdeckKeyboardAllowModifiers();
for (int kbi = 0; kbi < 12; ++kbi) {
int key = tdeckKeyboardReadKey();
if (key <= 0) break;
@@ -52967,7 +52943,7 @@ void UITask::loop() {
// A key pressed on a dark screen WAKES rather than types, the same as a
// touch does in the UI. Otherwise the character you used to see the screen
// ends up in the command you are typing.
if (_screen_off) { wakeScreen(); continue; }
if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; }
consoleKey(key);
}
#endif
@@ -53571,12 +53547,15 @@ void UITask::loop() {
updatePagerEncoder(now);
#endif
#if defined(HAS_TDECK_KEYBOARD)
if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers();
else tdeckKeyboardAllowModifiers();
// Drain physical-keyboard presses buffered by the touch task into the field.
for (int kbi = 0; kbi < 12; ++kbi) {
int key = tdeckKeyboardReadKey();
if (key <= 0) break;
if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb
if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
if (_screen_off) tdeckKeyboardDiscardModifiers();
handleHwKey(key);
}
// Focusing a text field (cursor starts blinking — tapped or auto-focused)
@@ -53618,6 +53597,7 @@ void UITask::loop() {
// working while the screen is dark. updatePagerKbBacklight() follows the
// same rule. Remote Mode pauses both because it owns the lit placeholder.
pagerKeyboardPoll();
if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt();
// Remote Mode keeps the placeholder lit and reserves all keys for its local
// escape path, so normal lock/backlight state machines stay paused there.
if (!s_remote_mode) {
@@ -53633,7 +53613,7 @@ void UITask::loop() {
if (key <= 0) break;
remotePhysicalKey(key);
}
pagerKeyboardConsumeAltTap();
pagerKeyboardDiscardAlt();
pagerKeyboardConsumeAltShiftChord();
pagerKeyboardConsumeAltBackspaceChord();
} else if (g_lv.task && g_lv.task->isScreenOff()) {
@@ -53652,10 +53632,10 @@ void UITask::loop() {
any = true;
if (k == 0x08) saw_backspace = true;
}
// Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while
// idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home)
// Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while
// idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home)
// the instant the screen wakes.
pagerKeyboardConsumeAltTap();
pagerKeyboardDiscardAlt();
pagerKeyboardConsumeAltShiftChord();
pagerKeyboardConsumeAltBackspaceChord();
// Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding
@@ -53680,7 +53660,6 @@ void UITask::loop() {
if (key <= 0) break;
handleHwKey(key);
}
updatePagerAltTapNext();
updatePagerAltShiftChord();
updatePagerAltBackspaceChord();
updatePagerBackspaceHold(now);
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string>
namespace Utf8Text {
inline bool continuation(uint8_t byte) {
return (byte & 0xC0U) == 0x80U;
}
inline size_t sequenceLength(const uint8_t* text, size_t remaining) {
if (!remaining) return 0;
const uint8_t first = text[0];
if (first <= 0x7F) return 1;
if (first >= 0xC2 && first <= 0xDF) {
return remaining >= 2 && continuation(text[1]) ? 2 : 0;
}
if (first == 0xE0) {
return remaining >= 3 && text[1] >= 0xA0 && text[1] <= 0xBF && continuation(text[2]) ? 3 : 0;
}
if ((first >= 0xE1 && first <= 0xEC) || (first >= 0xEE && first <= 0xEF)) {
return remaining >= 3 && continuation(text[1]) && continuation(text[2]) ? 3 : 0;
}
if (first == 0xED) {
return remaining >= 3 && text[1] >= 0x80 && text[1] <= 0x9F && continuation(text[2]) ? 3 : 0;
}
if (first == 0xF0) {
return remaining >= 4 && text[1] >= 0x90 && text[1] <= 0xBF &&
continuation(text[2]) && continuation(text[3]) ? 4 : 0;
}
if (first >= 0xF1 && first <= 0xF3) {
return remaining >= 4 && continuation(text[1]) && continuation(text[2]) && continuation(text[3]) ? 4 : 0;
}
if (first == 0xF4) {
return remaining >= 4 && text[1] >= 0x80 && text[1] <= 0x8F &&
continuation(text[2]) && continuation(text[3]) ? 4 : 0;
}
return 0;
}
inline bool valid(const char* text, size_t length) {
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(text);
size_t offset = 0;
while (offset < length) {
const size_t sequence = sequenceLength(bytes + offset, length - offset);
if (!sequence) return false;
offset += sequence;
}
return true;
}
// Returns the original pointer when it is already valid. Malformed runs are
// collapsed to one ASCII '?' in scratch so LVGL never receives a partial codepoint.
inline const char* sanitize(const char* text, size_t length, std::string& scratch) {
scratch.clear();
if (valid(text, length)) return text;
scratch.reserve(length);
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(text);
size_t offset = 0;
while (offset < length) {
const size_t sequence = sequenceLength(bytes + offset, length - offset);
if (sequence) {
scratch.append(text + offset, sequence);
offset += sequence;
continue;
}
scratch.push_back('?');
++offset;
while (offset < length && continuation(bytes[offset])) ++offset;
}
return scratch.c_str();
}
} // namespace Utf8Text
+30 -2
View File
@@ -1473,6 +1473,34 @@ 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
@@ -1554,7 +1582,7 @@ local function redraw()
local e = list[i]
if e then
rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
e.n.name:sub(1, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
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 "")
@@ -1777,7 +1805,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = {
{ "snake", "Snake", "1.0", kLuaSrc_snake },
{ "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest },
{ "2048", "2048", "1.2", kLuaSrc_2048 },
{ "wardrive", "Wardrive", "1.0", kLuaSrc_wardrive },
{ "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive },
{ "nearby", "Nearby", "1.0", kLuaSrc_nearby },
};
static const int kLuaBuiltinCount = (int)(sizeof(kLuaBuiltin)/sizeof(kLuaBuiltin[0]));
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#include <string>
#include "ui-touch/Utf8Text.h"
int main() {
const char full[] = "Ouderkerk\xE2\x98\x80\xEF\xB8\x8F";
assert(strlen(full) == 15);
assert(Utf8Text::valid(full, strlen(full)));
std::string broken(full, 14); // Wardrive's former name:sub(1, 14)
assert(!Utf8Text::valid(broken.data(), broken.size()));
std::string scratch;
const char* clean = Utf8Text::sanitize(broken.data(), broken.size(), scratch);
assert(scratch == "Ouderkerk\xE2\x98\x80?");
assert(Utf8Text::valid(clean, scratch.size()));
scratch = "allocated";
const char* unchanged = Utf8Text::sanitize(full, strlen(full), scratch);
assert(unchanged == full);
assert(scratch.empty());
const char overlong[] = { (char)0xC0, (char)0xAF };
assert(!Utf8Text::valid(overlong, sizeof overlong));
Utf8Text::sanitize(overlong, sizeof overlong, scratch);
assert(scratch == "?");
return 0;
}