From 61ed43bc40a6523390cb911c26ffe412e355b9bb Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 28 Jul 2026 09:36:36 -0600 Subject: [PATCH 01/22] touch: add "at a glance" idle-dim notification overlay While unlocked but idle-dimmed to off, an incoming message (DND permitting) briefly lights a plain black overlay with the sender/ channel name and message text, then auto-dims after a fixed window without opening the chat or marking the message read. Board-agnostic, distinct from the T-Deck's existing opt-in msgFlash. extras_lat_28 (accented Latin/em-dash/ellipsis at 28px) moves from Tanmatsu-only to every board, since the glance body needs that coverage everywhere. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 194 ++++++++++++++++++++++++++++++++++- src/ui-touch/extras_lat_28.c | 8 +- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..54738e2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -439,6 +439,12 @@ extern "C" const lv_font_t cc_icons_16; extern "C" const lv_font_t extras_12; extern "C" const lv_font_t extras_14; extern "C" const lv_font_t extras_16; +// extras_lat_28 used to be compiled HAS_TANMATSU-only (Large/Huge UI-scale Latin- +// accent fallback, issue #129); it's now compiled on every board (extras_lat_28.c) +// for the "at a glance" notification's message body (atGlanceEnsureFont()) -- +// montserrat_28 was picked over 24 because only 12/14/16/28 are actually built +// into this project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't enabled). +extern "C" const lv_font_t extras_lat_28; #if defined(HAS_TANMATSU) // Compressed Latin-accent fonts at the scaled sizes so umlauts etc. match their // neighbours at Large/Huge UI scale (issue #129). Room for these was made by @@ -446,7 +452,6 @@ extern "C" const lv_font_t extras_16; // net binary SMALLER, so the P4 app-load ceiling is respected. extern "C" const lv_font_t extras_lat_20; extern "C" const lv_font_t extras_lat_24; -extern "C" const lv_font_t extras_lat_28; #endif static lv_font_t g_font_12; static lv_font_t g_font_14; @@ -43717,6 +43722,142 @@ void UITask::rebootDevice() { void UITask::msgRead(int msgcount) { _msgcount = msgcount; } +// ---- "At a glance" notification -------------------------------------------- +// New, board-agnostic feature (distinct from the T-Deck's existing opt-in +// "msgFlash", which fully wakes to the live app + pulses the keyboard +// backlight): while the device is UNLOCKED but its screen has idle-dimmed to +// off, an incoming message (DND permitting) briefly lights a plain black +// overlay showing just the channel/sender name and the message text, then +// dims back out on its own after a fixed window -- a glance, not a wake. +// Never opens the chat, so the message stays unread, and whatever tab/chat +// was open underneath is left exactly as it was. atGlanceShow() below only +// builds/updates the overlay; UITask::newMsgImpl() decides whether to fire it +// (and wakes the screen + arms the auto-hide window); UITask::loop() clears it. +static lv_obj_t* s_glance_root = nullptr; +static lv_obj_t* s_glance_title = nullptr; +static lv_obj_t* s_glance_body = nullptr; +static uint32_t s_glance_lit_ms = 0; // millis() when (re)shown, 0 = not showing -- loop() re-dims 5s later +static bool s_glance_fading_out = false; // one-shot guard: the 200ms fade-out anim has already been started + +static void atGlanceOpaCb(void* var, int32_t v) { + lv_obj_set_style_opa(static_cast(var), static_cast(v), LV_PART_MAIN); +} + +// Message-body font, built once: stock Montserrat 28 (ASCII) -> extras_lat_28 +// (accented Latin, em-dash, ellipsis -- now compiled on every board, not just the +// Tanmatsu; see extras_lat_28.c) -> extras_16 tail (Cyrillic/Greek/Arabic, baked at +// 16 px so those scripts render a bit small relative to Latin text, but never a +// missing-glyph box). 28, not 24: only 12/14/16/28 are actually enabled in this +// project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself +// is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER +// widgets use, not something to repurpose for one feature. +static lv_font_t s_glance_body_font; +static bool s_glance_font_ready = false; +static void atGlanceEnsureFont() { + if (s_glance_font_ready) return; + s_glance_font_ready = true; + static lv_font_t s_lat28; + s_lat28 = extras_lat_28; + s_lat28.fallback = &extras_16; + s_glance_body_font = lv_font_montserrat_28; + s_glance_body_font.fallback = &s_lat28; +} + +static void atGlanceHide() { + if (s_glance_root) { + // Only the TEXT fades (see atGlanceShow()) -- the root's own opa is never + // touched, so nothing to reset on it. Just cancel any in-flight label fades. + if (s_glance_title) { lv_anim_del(s_glance_title, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); } + if (s_glance_body) { lv_anim_del(s_glance_body, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); } + lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); + } + s_glance_lit_ms = 0; + s_glance_fading_out = false; +} + +// `fade_in`: true on the first reveal of a burst (screen was off), false when +// a later message in the same burst just updates the text on an already-lit +// overlay -- no need to re-fade something already fully visible. +static void atGlanceShow(const char* title, const char* body, bool fade_in) { + if (!g_lv.ready) return; + if (!s_glance_root) { + s_glance_root = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(s_glance_root); + lv_obj_set_style_bg_color(s_glance_root, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_glance_root, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps -- no UI leak underneath + // Never a keyboard/encoder nav target on any board: same reasoning as the + // lock screen's NAV_SKIP_FLAG (see lockscreenShow()) -- a CLICKABLE + // top-layer overlay with nothing to navigate to would otherwise be + // collected as the sole focus target, and navFocusCb's focus-highlight + // would invert its text to dark the instant the nav group (re)builds. + lv_obj_add_flag(s_glance_root, NAV_SKIP_FLAG); + + // Small "eyebrow" label (channel/sender) above the message, in the app's + // accent colour -- the message itself is the thing that needs to be + // readable from a few feet away (desk-mounted device), so it gets the + // dominant size/position below and this stays a secondary context cue. + s_glance_title = lv_label_create(s_glance_root); + lv_obj_set_style_text_font(s_glance_title, &g_font_16, LV_PART_MAIN); + lv_obj_set_style_text_color(s_glance_title, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_obj_set_width(s_glance_title, lv_pct(85)); + lv_obj_set_style_text_align(s_glance_title, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); + lv_label_set_long_mode(s_glance_title, LV_LABEL_LONG_DOT); // single line, ellipsize -- guaranteed to fit every board's width + lv_obj_align(s_glance_title, LV_ALIGN_TOP_LEFT, 14, 30); // below the ~22 px status bar band (the real status bar renders above this overlay) + + // Message body: 28 px (bumped up from 16 -- 16 wasn't legible from a few + // feet away on a desk-mounted device) via atGlanceEnsureFont()'s own + // fallback chain, so arbitrary chat text (accents, em-dash, ellipsis) + // still never shows a missing-glyph box at this size. + atGlanceEnsureFont(); + s_glance_body = lv_label_create(s_glance_root); + lv_obj_set_style_text_font(s_glance_body, &s_glance_body_font, LV_PART_MAIN); + lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN); + lv_obj_set_width(s_glance_body, lv_pct(85)); + lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); + lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); // single line -- keeps the smallest panels (Pager 222px tall) safe + lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12 + } + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + const lv_coord_t sh = lv_disp_get_ver_res(nullptr); + lv_obj_set_size(s_glance_root, sw, sh); + lv_obj_set_pos(s_glance_root, 0, 0); + lv_label_set_text(s_glance_title, title ? title : ""); + lv_label_set_text(s_glance_body, body ? body : ""); + // Cancel any fade-out already begun (by an earlier message in this burst) on + // either label. + lv_anim_del(s_glance_title, atGlanceOpaCb); + lv_anim_del(s_glance_body, atGlanceOpaCb); + lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_glance_root); + if (fade_in) { + // Only the TEXT fades in, never the root: the root's bg_opa is COVER (solid + // black) from the moment it's created and stays that way, so the very first + // frame -- forced-painted BEFORE the backlight comes on (see newMsgImpl()) -- + // is already a clean black screen, not a transparent one letting the live + // app screen show through underneath for a frame (reported: "I see the app + // screen then I see the at-glance"). Fading the whole root's opa used to fade + // the background along with the text, which is what caused that. + lv_obj_set_style_opa(s_glance_title, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_opa(s_glance_body, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; + for (lv_obj_t* t : targets) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, t); + lv_anim_set_time(&a, 180); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_exec_cb(&a, atGlanceOpaCb); + lv_anim_start(&a); + } + } else { + // Already visible -- just swap the text, no re-fade. + lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); + } +} + // Shared core for newMsg / newMsgFromPubWithMeta. Bundles the channel-vs-DM // sender parsing + thread routing in one place so the meta-aware path doesn't // drift from the plain path. `meta_flags` is 0 (no RX metadata) when called @@ -43779,6 +43920,23 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex else if (touchPrefsGetSoundMessages() && !(cmute & TOUCH_CHMUTE_MSG)) uiPlaySlot(TOUCH_SND_MSG); } } +#endif +#if defined(HAS_TOUCH_UI) + // "At a glance": device unlocked but idle-dimmed to off -- light the plain + // black glance overlay instead of the full app (see atGlanceShow() above). + // Skipped if DND is on or the device is manually locked (that case is the + // existing lock-screen reveal, untouched here). `_screen_off` covers the + // first message of a burst (wake + show); `s_glance_lit_ms` (already showing, + // from an earlier message in the same window) covers later ones -- update + // the text and restart the 5 s window instead of dropping them, since our + // own wake already cleared _screen_off by then. + if (!dndActive() && !_manual_lock && (_screen_off || s_glance_lit_ms)) { + const bool was_off = _screen_off; + atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst + if (was_off) { lv_refr_now(nullptr); wakeScreen(); } // paint before the backlight comes on -- no stale-frame flash + s_glance_lit_ms = millis(); + s_glance_fading_out = false; // a new message cancels any fade-out already begun + } #endif // Inbound route (repeater hashes) for the Info popup — flood RX only; read // synchronously from MyMesh, which stashed it just before this call. @@ -44347,6 +44505,40 @@ void UITask::loop() { } #endif // HAS_TDECK_KEYBOARD (notify-wake re-dim) + // "At a glance" auto-hide: same re-dim shape as the msgflash window above, + // but this feature's own fixed 5 s window (the last 200 ms of it spent + // fading out, not a hard cut) and unconditional on every board (see + // atGlanceShow()/newMsgImpl() above). Real input during the window hands + // off to the user actually looking at the screen now -- don't snatch it + // back off from underneath them; just stop tracking it and let the normal + // idle timeout (already bumped by that real input) take over. + if (s_glance_lit_ms) { + if (_screen_off) atGlanceHide(); // already dark again some other way + else if (_last_input_ms > s_glance_lit_ms) atGlanceHide(); // user took over + else { + const int32_t elapsed = (int32_t)(now - s_glance_lit_ms); + if (!s_glance_fading_out && elapsed >= 4800) { + s_glance_fading_out = true; + lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; // text only -- see atGlanceShow() + for (lv_obj_t* t : targets) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, t); + lv_anim_set_time(&a, 200); + lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_exec_cb(&a, atGlanceOpaCb); + lv_anim_start(&a); + } + } + if (elapsed >= 5000) { + atGlanceHide(); + touchScreenBacklight(false); + setCpuForScreen(false); + _screen_off = true; + } + } + } + #if defined(HAS_TOUCH_UI) if (!g_lv.ready) return; diff --git a/src/ui-touch/extras_lat_28.c b/src/ui-touch/extras_lat_28.c index 4c886ba..d465503 100644 --- a/src/ui-touch/extras_lat_28.c +++ b/src/ui-touch/extras_lat_28.c @@ -1,5 +1,8 @@ #include "device_caps.h" -#if defined(HAS_TANMATSU) +// Was HAS_TANMATSU-only (only consumer used to be the Tanmatsu's Large/Huge UI-scale +// accented-Latin fallback); now compiled on every board too for the "at a glance" +// notification's 28 px message body (see atGlanceEnsureFont() in UITask.cpp), which +// needs accented Latin / em-dash / ellipsis glyph coverage at that size on any board. /******************************************************************************* * Size: 28 px * Bpp: 4 @@ -3240,6 +3243,3 @@ lv_font_t extras_lat_28 = { #endif /*#if EXTRAS_LAT_28*/ - - -#endif From 14d5fdb8b328a4d1f3d13135ef380d9a2b0a1fdf Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Thu, 30 Jul 2026 09:12:05 -0600 Subject: [PATCH 02/22] touch: add "at a glance while locked" opt-in, fix glance multi-line body New Options > Lock screen toggle lets the at-a-glance overlay fire while the device is manually locked too, not just unlocked+idle-dimmed -- off by default since it makes message text readable off a locked device. Persisted via TouchPrefsStore (touchPrefsGetGlanceWhenLocked). When it fires from a locked state, mirrors lockscreenReveal()'s peek path (panel lit, _manual_lock left alone) rather than wakeScreen() (which would silently clear the lock). Also fixes the glance body label: LV_LABEL_LONG_DOT only wraps/dot- ellipsizes across multiple lines when the label has a fixed height, so left at auto-height it was effectively single-line everywhere, cropping longer messages instead of continuing to a second line. Now sized to the actual space available below it for the current board's resolution and font. Adds a T-Deck-only experiment shrinking the glance body font from 28px to 20px (extras_lat_20 gate widened past Tanmatsu-only to cover it); easy to revert in isolation per the comments at each site. Signed-off-by: Tesso M Costa --- include/lv_conf.h | 7 ++ platformio.ini | 9 ++ src/helpers/esp32/TouchPrefsStore.cpp | 2 + src/helpers/esp32/TouchPrefsStore.h | 2 + src/ui-touch/UITask.cpp | 116 +++++++++++++++++++++++--- src/ui-touch/extras_lat_20.c | 6 +- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/include/lv_conf.h b/include/lv_conf.h index 4c330f7..8808621 100644 --- a/include/lv_conf.h +++ b/include/lv_conf.h @@ -61,6 +61,13 @@ #define LV_FONT_MONTSERRAT_20 1 #define LV_FONT_MONTSERRAT_24 1 #endif +/* T-Deck-only, unrelated to the Large/Huge UI-scale block above: an experiment + * to shrink the "at a glance" notification's message body from 28px to 20px + * on this board only (see atGlanceEnsureFont() in UITask.cpp). Easy to revert + * by dropping this block + the T-Deck branch in atGlanceEnsureFont(). */ +#if defined(HAS_TDECK_GT911) +#define LV_FONT_MONTSERRAT_20 1 +#endif /* 28 px Montserrat for the boot splash title — keeps the rest of the UI on * the smaller fonts so the .data cost stays modest. */ #define LV_FONT_MONTSERRAT_28 1 diff --git a/platformio.ini b/platformio.ini index dc10360..1f64552 100644 --- a/platformio.ini +++ b/platformio.ini @@ -564,6 +564,15 @@ build_flags = -D HAS_TDECK_GT911=1 -D HAS_TDECK_TRACKBALL=1 -D HAS_TDECK_KEYBOARD=1 + ; Forced here (not just in lv_conf.h) because .pio/libdeps//MeshCore/include/lv_conf.h + ; -- a stale snapshot vendored by the pinned core tag -- sits earlier in the -I search + ; order than this repo's own include/lv_conf.h, so LV_CONF_INCLUDE_SIMPLE's quoted + ; #include "lv_conf.h" silently resolves to that stale copy for src/ compiles (though + ; library .c files, e.g. lvgl's own, still see the right one -- inconsistent per TU). A + ; -D here is immune to that: it's set before any header is processed and isn't + ; overridden by a plain #define. Only 20px is forced, for the "at a glance" experiment + ; below (atGlanceEnsureFont() in UITask.cpp) -- not 18/24, which this board doesn't use. + -D LV_FONT_MONTSERRAT_20=1 -D ENABLE_ADVERT_ON_BOOT=0 -D PIN_TB_UP=15 -D PIN_TB_DOWN=3 diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index c2952b9..705643c 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1785,6 +1785,8 @@ bool touchPrefsGetEdgeScroll() { if (!s_begun) touchPrefsBegin(); return s_ void touchPrefsSetEdgeScroll(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("tb_edgesc", on ? 1 : 0); } bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("lock_off", 0) != 0; } void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); } +bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; } +void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); } #if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 8cd41eb..c8c6ade 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -416,6 +416,8 @@ bool touchPrefsGetEdgeScroll(); // push cursor past edge to scrol void touchPrefsSetEdgeScroll(bool on); bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; only a deliberate hold wakes (default false) void touchPrefsSetLockOnScreenOff(bool on); +bool touchPrefsGetGlanceWhenLocked(); // "at a glance" also fires while manually/idle locked, not just unlocked+dimmed (default false) +void touchPrefsSetGlanceWhenLocked(bool on); /** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 = * mute @-mentions. Suppresses the notification SOUND for that channel (the diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 54738e2..a9f0361 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -445,12 +445,17 @@ extern "C" const lv_font_t extras_16; // montserrat_28 was picked over 24 because only 12/14/16/28 are actually built // into this project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't enabled). extern "C" const lv_font_t extras_lat_28; +// extras_lat_20 was HAS_TANMATSU-only too (Large/Huge UI-scale fallback); the +// T-Deck now also builds it, for its 20px at-glance body experiment (see +// atGlanceEnsureFont()) -- extras_lat_20.c's own gate was widened to match. +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911) +extern "C" const lv_font_t extras_lat_20; +#endif #if defined(HAS_TANMATSU) // Compressed Latin-accent fonts at the scaled sizes so umlauts etc. match their // neighbours at Large/Huge UI scale (issue #129). Room for these was made by // storing the extras fonts compressed (LV_USE_FONT_COMPRESSED) — nothing lost, // net binary SMALLER, so the P4 app-load ceiling is respected. -extern "C" const lv_font_t extras_lat_20; extern "C" const lv_font_t extras_lat_24; #endif static lv_font_t g_font_12; @@ -10399,6 +10404,21 @@ static void lockOnScreenOffToggleCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(unlock_hint, 1600); } +// "At a glance" is read straight from NVS at message time (not cached), same as +// the sound/DND checks right next to its call site in newMsgImpl -- it's not a +// per-loop hot path, just once per inbound message. +static void glanceWhenLockedToggleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); +#if defined(ESP32) + touchPrefsSetGlanceWhenLocked(on); +#endif + if (g_lv.task) { + g_lv.task->showAlert(on ? TR("Glance shows message previews\nwhile the screen is locked") + : TR("Glance only shows when unlocked"), 1600); + } +} + #if CAP_TRACKBALL // Invert the scrollball direction everywhere it drives motion (cursor, map pan, // emoji selector). Cached in s_tb_reverse so the per-tick poll never hits NVS. @@ -11920,6 +11940,21 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } + + // "At a glance" (see atGlanceShow()/newMsgImpl()) normally only fires while + // unlocked-but-idle-dimmed; this opts in to it firing while manually locked + // too, i.e. message previews become readable off a locked device -- off by + // default since that's a real trade-off, not just a convenience toggle. + { + int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); + lv_obj_t* sw = lv_switch_create(body); + lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(sw, LV_STATE_CHECKED); +#endif + lv_obj_add_event_cb(sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } #endif @@ -43751,16 +43786,29 @@ static void atGlanceOpaCb(void* var, int32_t v) { // project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself // is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER // widgets use, not something to repurpose for one feature. +// +// T-Deck experiment: 20px instead of 28px (LV_FONT_MONTSERRAT_20 + extras_lat_20 +// widened for this board specifically, see lv_conf.h/extras_lat_20.c) -- trying +// a smaller glance body on this panel. Revert by dropping this branch + those +// two gate widenings. static lv_font_t s_glance_body_font; static bool s_glance_font_ready = false; static void atGlanceEnsureFont() { if (s_glance_font_ready) return; s_glance_font_ready = true; +#if defined(HAS_TDECK_GT911) + static lv_font_t s_lat20; + s_lat20 = extras_lat_20; + s_lat20.fallback = &extras_16; + s_glance_body_font = lv_font_montserrat_20; + s_glance_body_font.fallback = &s_lat20; +#else static lv_font_t s_lat28; s_lat28 = extras_lat_28; s_lat28.fallback = &extras_16; s_glance_body_font = lv_font_montserrat_28; s_glance_body_font.fallback = &s_lat28; +#endif } static void atGlanceHide() { @@ -43816,7 +43864,21 @@ static void atGlanceShow(const char* title, const char* body, bool fade_in) { lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN); lv_obj_set_width(s_glance_body, lv_pct(85)); lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN); - lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); // single line -- keeps the smallest panels (Pager 222px tall) safe + // LV_LABEL_LONG_DOT only wraps across multiple lines (dot-ellipsizing the LAST + // one on overflow) when the label has a fixed HEIGHT, not just a fixed width -- + // left at auto/content height (as this was), it sizes to exactly one line, so + // every board was effectively single-line regardless of panel size (reported: + // text cropped at the end of the first line instead of continuing on a second). + // Give it the actual remaining space below this label's own y-offset down to a + // small bottom margin, computed from this board's resolution + active body font, + // so it uses exactly what's available -- more lines on tall panels, fewer on the + // 222 px-tall Pager -- instead of a guessed fixed line count. + { + const lv_coord_t avail_h = lv_disp_get_ver_res(nullptr) - 60 - 10; + const lv_coord_t line_h = lv_font_get_line_height(&s_glance_body_font); + lv_obj_set_height(s_glance_body, avail_h > line_h ? avail_h : line_h); + } + lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT); lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12 } const lv_coord_t sw = lv_disp_get_hor_res(nullptr); @@ -43924,16 +43986,50 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex #if defined(HAS_TOUCH_UI) // "At a glance": device unlocked but idle-dimmed to off -- light the plain // black glance overlay instead of the full app (see atGlanceShow() above). - // Skipped if DND is on or the device is manually locked (that case is the - // existing lock-screen reveal, untouched here). `_screen_off` covers the - // first message of a burst (wake + show); `s_glance_lit_ms` (already showing, - // from an earlier message in the same window) covers later ones -- update - // the text and restart the 5 s window instead of dropping them, since our - // own wake already cleared _screen_off by then. - if (!dndActive() && !_manual_lock && (_screen_off || s_glance_lit_ms)) { + // Skipped if DND is on. Manually locked is ALSO skipped unless the user + // opted in via Options > Lock screen > "At a glance while locked" + // (touchPrefsGetGlanceWhenLocked()) -- off by default since it's a real + // trade-off (message text becomes readable off a locked device). `_screen_off` + // covers the first message of a burst (wake + show); `s_glance_lit_ms` + // (already showing, from an earlier message in the same window) covers later + // ones -- update the text and restart the 5 s window instead of dropping + // them, since our own wake already cleared _screen_off by then. +#if defined(ESP32) + const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked(); +#else + const bool glance_locked_ok = !_manual_lock; +#endif + if (!dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { const bool was_off = _screen_off; atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst - if (was_off) { lv_refr_now(nullptr); wakeScreen(); } // paint before the backlight comes on -- no stale-frame flash + if (was_off) { + lv_refr_now(nullptr); // paint before the backlight comes on -- no stale-frame flash + if (_manual_lock) { + // Locked: light the panel WITHOUT clearing the lock -- wakeScreen() + // unconditionally clears _manual_lock, which would silently unlock the + // device (touch/input live again) just to show a glance. Mirrors + // lockscreenReveal()'s peek path instead: same fields, lock untouched. + // The auto-hide re-dim below (UITask::loop()) is symmetric -- it never + // touches _manual_lock either, so the device is exactly as locked after + // the glance as before it. + setCpuForScreen(true); + touchScreenBacklight(true); + _screen_off = false; + // MUST bump this even though it's not real input: loop()'s generic idle + // screen-timeout check fires on !_screen_off && (now - _last_input_ms) >= + // _screen_timeout_ms regardless of _manual_lock -- leaving it stale (from + // before the lock) made that check true on the very next loop() tick, + // instantly flipping the backlight back off before the glance was ever + // seen (reported: sound played but nothing showed). wakeScreen() bumps + // this for the same reason; omitting it here was the bug. + _last_input_ms = millis(); +#if CAP_LOCK_SCREEN + _lock_lit_ms = millis(); // keep the lock screen's own burn-in guard armed too +#endif + } else { + wakeScreen(); + } + } s_glance_lit_ms = millis(); s_glance_fading_out = false; // a new message cancels any fade-out already begun } diff --git a/src/ui-touch/extras_lat_20.c b/src/ui-touch/extras_lat_20.c index c8cef46..75778a4 100644 --- a/src/ui-touch/extras_lat_20.c +++ b/src/ui-touch/extras_lat_20.c @@ -1,5 +1,9 @@ #include "device_caps.h" -#if defined(HAS_TANMATSU) +// Was HAS_TANMATSU-only (Large/Huge UI-scale accented-Latin fallback); the T-Deck +// now also builds this for the "at a glance" notification's 20 px message body +// (see atGlanceEnsureFont() in UITask.cpp) -- an experiment to see whether a +// smaller-than-28px glance body is still legible on that panel. +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911) /******************************************************************************* * Size: 20 px * Bpp: 4 From acdcc6e7fc789a39856cc29b7a37668bd1f95717 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Sun, 23 Aug 2026 20:32:26 -0400 Subject: [PATCH 03/22] Work --- AUDIO_PLAYBACK_TESTING.md | 156 +++ NOTICE | 7 + README.md | 4 + deploy/apps/README.md | 6 +- deploy/apps/apps.json | 4 +- deploy/apps/sdktest/1.7/sdktest.json | 1 + deploy/apps/sdktest/1.7/sdktest.lua | 293 ++++ deploy/site/sdk.html | 23 +- lib/minimp3/LICENSE | 117 ++ lib/minimp3/README.md | 16 + lib/minimp3/minimp3.h | 1872 ++++++++++++++++++++++++++ scripts/audio_test.lua | 104 ++ scripts/lua-harness/harness.lua | 122 +- scripts/sideload_app.py | 15 +- src/DataStore.cpp | 7 + src/DataStore.h | 1 + src/MyMesh.cpp | 59 +- src/MyMesh.h | 2 + src/ui-touch/LuaAppHost.cpp | 123 +- src/ui-touch/UITask.cpp | 882 +++++++++++- src/ui-touch/device_caps.h | 17 + src/ui-touch/lua_builtin.h | 19 +- test/test_minimp3_decoder.cpp | 144 ++ variants/tdisplay_p4/P4Audio.cpp | 38 +- variants/tdisplay_p4/P4Audio.h | 8 + 25 files changed, 3980 insertions(+), 60 deletions(-) create mode 100644 AUDIO_PLAYBACK_TESTING.md create mode 100644 deploy/apps/sdktest/1.7/sdktest.json create mode 100644 deploy/apps/sdktest/1.7/sdktest.lua create mode 100644 lib/minimp3/LICENSE create mode 100644 lib/minimp3/README.md create mode 100644 lib/minimp3/minimp3.h create mode 100644 scripts/audio_test.lua create mode 100644 test/test_minimp3_decoder.cpp diff --git a/AUDIO_PLAYBACK_TESTING.md b/AUDIO_PLAYBACK_TESTING.md new file mode 100644 index 0000000..cbbc67b --- /dev/null +++ b/AUDIO_PLAYBACK_TESTING.md @@ -0,0 +1,156 @@ +# Testing Lua audio playback + +This guide tests issue #315 with a real WAV or MP3. The examples use the +T-Deck environment and the provided `scripts/audio_test.lua` app. + +## Build and flash + +Build and upload the branch as usual: + +```sh +pio run -e LilyGo_TDeck_companion_radio_touch +pio run -e LilyGo_TDeck_companion_radio_touch -t upload +``` + +Keep the serial monitor available for `[LUAAPP]` or storage errors: + +```sh +pio device monitor -b 115200 +``` + +## Quick MP3 test from SD + +1. Use a FAT32 SD card and create these paths on it: + + ```text + /Music/test.mp3 + /meshcomod/apps/audio_test.lua + ``` + +2. Copy `scripts/audio_test.lua` from this repository to the second path. Rename + your MP3 to `test.mp3`, or edit `SD_PATH` near the top of the Lua file. +3. Insert the card before boot, then reboot the T-Deck. +4. Enable **Settings > Sound** and set volume above zero. +5. Open **Apps > audio_test**. The screen should report + `audio=true`, `wav=true`, and `mp3=true`. +6. Press **Play**. Expected status: + + ```text + State: playing + Source: sd Format: mp3 + ``` + +7. Exercise **Pause**, **Resume**, and **Stop**. Pressing **Play** while already + playing must restart/replace the current track without overlapping it. +8. Start playback and leave the app. Audio must stop immediately when the app + closes. + +The direct SD API used by the test is: + +```lua +local ok, err = wada.audio.play("sd:/Music/test.mp3") +``` + +`sd:` paths are absolute from the physical card root. Empty segments, +backslashes, trailing slashes, `.` and `..` are rejected. + +## Test app-local/internal storage + +A plain filename is resolved inside the current app's private directory. For +`audio_test.lua`, the logical files are: + +```text +/apps/audio_test.lua +/apps/audio_test.d/test.mp3 +``` + +On T-Deck and Pager, internal app storage is SPIFFS. The least destructive way +to populate it is the repository's serial uploader: + +1. Power off, remove the SD card, and reboot. This forces T-Deck app storage to + the internal SPIFFS backend. +2. Make a short internal-storage fixture. T-Deck has a 3.375 MB SPIFFS partition + shared with settings and history, so use the full song for SD testing and a + small clip here: + + ```sh + ffmpeg -y -i "/path/to/your/file.mp3" \ + -t 10 -ac 1 -ar 22050 -b:a 48k \ + /tmp/wadamesh-audio-test.mp3 + ``` + +3. Find the serial port with `pio device list`. +4. Install `pyserial` once if needed: + + ```sh + python3 -m pip install pyserial + ``` + +5. Upload the app and short MP3. Replace the port as needed: + + ```sh + python3 scripts/sideload_app.py \ + --port /dev/cu.usbmodem101 \ + --remote /apps/audio_test.lua \ + scripts/audio_test.lua + + python3 scripts/sideload_app.py \ + --port /dev/cu.usbmodem101 \ + --remote /apps/audio_test.d/test.mp3 \ + --reboot \ + /tmp/wadamesh-audio-test.mp3 + ``` + +6. Open **Apps > audio_test** after reboot. With no card inserted, the app + selects `test.mp3`; press **Play**. +7. Expected status is `Source: app Format: mp3`. + +The app-local API is simply: + +```lua +local ok, err = wada.audio.play("test.mp3") +``` + +Tanmatsu uses internal FFat and T-Display P4 uses internal LittleFS. The same +serial destination `/apps/audio_test.d/test.mp3` follows the active internal +backend, so no Lua code changes are needed. Direct `sd:` playback is shown only +when `wada.sys.caps().audio_sd` is true. + +Do not use `uploadfs` on a device with data you need to keep: a filesystem-image +upload replaces the internal storage partition, including settings and history. + +## Error and lifecycle checks + +- Disable Sound, then press **Play**: expect `Command: muted`. +- Rename the file or remove the card: expect `not found` or `no sd`. +- Try a non-WAV/MP3 extension: expect `unsupported format`. +- Remove the card during SD playback only after the baseline test. Playback must + enter `error` or stop without rebooting; reinserting the card should allow a + later play after the normal SD recovery delay. +- Receive a mesh notification during playback: it must not start a second audio + stream over the media track. +- Repeatedly open/play/close the app: each close must release the worker, codec, + amplifier, file handle, and storage lease. + +## Host decoder regression test + +The standalone test validates the pinned decoder and the same rolling 16 KB +input algorithm used by firmware: + +```sh +c++ -std=c++17 -O2 test/test_minimp3_decoder.cpp -o /tmp/test_minimp3_decoder +/tmp/test_minimp3_decoder /path/to/your/file.mp3 +``` + +A successful run prints decoded frames, samples per channel, sample rate, +channels, and bitrate changes. The implementation has also been checked with +CBR, VBR, leading ID3v2, trailing ID3v1, and trailing APEv2 metadata. + +## Supported formats + +- WAV: PCM, 16-bit, mono or stereo, 8-48 kHz. +- MP3: MPEG Layer III, mono or stereo, 8-48 kHz, CBR or VBR, with common ID3 and + APEv2 metadata. + +Stereo is downmixed to the device's mono speaker path. `status()` reports +`stopped`, `playing`, `paused`, `ended`, or `error`. diff --git a/NOTICE b/NOTICE index e5b4be9..711abdc 100644 --- a/NOTICE +++ b/NOTICE @@ -54,6 +54,13 @@ ed25519 (bundled in lib/ed25519) Orson Peters / orlp — public domain (Creative Commons Zero / zlib) https://github.com/orlp/ed25519 +minimp3 (bundled in lib/minimp3) + lieff/minimp3 contributors — CC0 1.0 Universal + https://github.com/lieff/minimp3 + Pinned at ea99364f61c14656440e8d77e9c233ccf3124633. The bundled header has + a small opt-in scratch-storage hook so the decoder workspace can live in + PSRAM; default upstream behavior is unchanged when that hook is not used. + AsyncElegantOTA (vendored in arch/esp32/AsyncElegantOTA) Copyright (c) Ayush Sharma — MIT License diff --git a/README.md b/README.md index 60ea0cd..2946c56 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ the transports, …) are dropped from the lib via `-DMC_VENDORED_TOUCH_APP` so t aren't compiled twice. The build is byte-identical to the original in-tree meshcomod firmware. +Lua apps use the on-device SDK described in [LUA_APPS.md](LUA_APPS.md). For +WAV/MP3 playback, including a ready-to-sideload transport test app and exact SD +and internal-storage paths, see [AUDIO_PLAYBACK_TESTING.md](AUDIO_PLAYBACK_TESTING.md). + ## Build [PlatformIO](https://platformio.org/) pulls the core fork and all libraries diff --git a/deploy/apps/README.md b/deploy/apps/README.md index 6e9b046..751dcfb 100644 --- a/deploy/apps/README.md +++ b/deploy/apps/README.md @@ -16,8 +16,10 @@ One Lua file plus a small manifest. Apps talk to the firmware through the `wada. API — `wada.ui` (widgets, colours, a text prompt), `wada.sys`, `wada.store` (persistence), `wada.timer`, and on the larger boards `wada.fs`, `wada.net`, `wada.crypto` and the writable half of `wada.mesh`. Supported boards also expose -read-only SD directory metadata through `wada.sd`; file contents and writes stay -inaccessible. There is no general-purpose filesystem or network access; the API +asynchronous WAV/MP3 playback through `wada.audio`; plain filenames come from the +app sandbox on internal flash, SD, or SD_MMC. Read-only physical-SD directory +metadata is available through `wada.sd`, and direct card playback uses an explicit +`sd:/...` path. There is no general-purpose filesystem or network access; the API is the whole surface, which is what makes reviewing tractable. It is documented at [wadamesh.com/sdk.html](https://wadamesh.com/sdk.html). diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index ee2da03..62d8026 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -21,8 +21,8 @@ { "id": "sdktest", "name": "SDK Test", - "ver": "1.6", - "desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages." + "ver": "1.7", + "desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, audio API, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages." }, { "id": "2048", diff --git a/deploy/apps/sdktest/1.7/sdktest.json b/deploy/apps/sdktest/1.7/sdktest.json new file mode 100644 index 0000000..aa4e24e --- /dev/null +++ b/deploy/apps/sdktest/1.7/sdktest.json @@ -0,0 +1 @@ +{"id":"sdktest","name":"SDK Test","ver":"1.7","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private files, audio API, read-only SD listing, crypto vectors, packet identity, channels, and opt-in transmit calls."} diff --git a/deploy/apps/sdktest/1.7/sdktest.lua b/deploy/apps/sdktest/1.7/sdktest.lua new file mode 100644 index 0000000..94b64da --- /dev/null +++ b/deploy/apps/sdktest/1.7/sdktest.lua @@ -0,0 +1,293 @@ +-- SDK self-test. Exercises the extended SDK so the results can be read off the +-- screen instead of inferred from a build log. Published to the store as a +-- developer/bench tool. +-- +-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is +-- real evidence and not just "it returned something"), wada.mesh.channels, and +-- wada.mesh.send_dm. +-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet +-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the +-- windowed wada.fs.read. +-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root +-- metadata, and a nested directory listing when the card contains one. +-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback +-- stays manual so opening the bench test never emits sound unexpectedly. +-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the +-- firmware has it. That call was added because of the 1.3 bug below: an app +-- could ask how tall a line is but not how wide, so laying out rows meant +-- guessing whether text would wrap. Falls back to the 1.4 estimate on older +-- firmware, so it still lays out correctly there. +-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping +-- on, so any line wider than the screen became two lines while the caller still +-- advanced by one, and the next row landed on top. Reported with a photo of the +-- channels line sitting across the contacts line. row() now owns the cursor and +-- advances by the height the text actually needs. +local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer +local C = ui.colors + +local app = {} +local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil +local discline, inputline = nil, nil +local W = 300 + +-- row() owns the vertical cursor. Every caller used to pass a y and then add a +-- hand-picked constant, which is only correct while the text fits on one line. +-- An app cannot measure rendered text, so the wrapped line count is estimated +-- from a deliberately pessimistic character width: over-estimating costs a +-- little whitespace, under-estimating overlaps the next row. +local LH, GAP = 14, 3 +local cy = 4 +local function row(text, color, extra_gap) + local l = ui.label(text, 6, cy, 12, color or C.text) + l:width(W - 12) + rows[#rows + 1] = l + local n + if ui.text_lines then + n = ui.text_lines(tostring(text), W - 12, 12) -- measured: exact + else + local cpl = math.max(16, (W - 12) // 7) -- older firmware: estimate + n = 0 + for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do + n = n + math.max(1, math.ceil(#seg / cpl)) + end + end + cy = cy + math.max(1, n or 1) * LH + GAP + (extra_gap or 0) + return l +end +local function yn(v) return v and "yes" or "NO" end + +function app.on_open(w, h) + W = w or 300 + ui.scroll(true) + cy = 4 + local y + + local c = sys.caps() + row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) .. + " touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent) + row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) .. + " input=" .. yn(c.input) .. + " rx_identity=" .. yn(c.rx_identity), C.accent) + row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) .. + " mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent) + if c.audio then + local api_ok = wada.audio and type(wada.audio.play) == "function" and + type(wada.audio.pause) == "function" and + type(wada.audio.resume) == "function" and + type(wada.audio.stop) == "function" and + type(wada.audio.status) == "function" + local status = api_ok and wada.audio.status() or nil + api_ok = api_ok and type(status) == "table" and type(status.state) == "string" + row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"), + api_ok and C.good or C.bad) + else + row("wada.audio: unavailable on this board", C.sub) + end + row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"), + ui.text_lines and C.good or C.sub) + -- crypto: published test vectors, so this is checkable rather than merely alive + if wada.crypto then + local hex = wada.crypto.hex + local k20 = string.rep(string.char(0x0b), 20) + local checks = { + { "sha256('abc')", hex(wada.crypto.sha256("abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" }, + { "sha1('abc')", hex(wada.crypto.sha1("abc")), + "a9993e364706816aba3e25717850c26c9cd0d89d" }, + { "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")), + "b617318655057264e28bc0b6fb378c8ef146be00" }, + { "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" }, + } + local allok = true + for _, t in ipairs(checks) do + local ok = (t[2] == t[3]) + if not ok then allok = false end + row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))), + ok and C.good or C.bad) + end + -- The point of having it in C: this loop would blow the instruction budget in Lua. + local t0 = sys.millis() + for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end + row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0, + allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad) + else + row("wada.crypto: MISSING", C.bad) + end + + -- channel discovery + local chans = wada.mesh.channels and wada.mesh.channels() or nil + if chans then + local names = table.concat(chans, ", ") + row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text) + else + row("wada.mesh.channels: MISSING", C.bad) + end + + if not c.sdk_ext then + row("extended SDK is OFF on this board - stopping here.", C.sub) + return + end + + -- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field. + local cts = wada.mesh.contacts() + local first = cts[1] and cts[1].name or nil + row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text) + if cts[1] then + local pk = cts[1].pubkey + row("contacts[1].pubkey: " .. tostring(pk) .. + (type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"), + (type(pk) == "string" and #pk == 8) and C.good or C.bad) + end + + local me = wada.mesh.self() + row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text) + -- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track + -- must use the _e6 integers; this proves they are present and consistent. + local fix = sys.gps() + if fix then + local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat) + row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s", + fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift, + drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad) + else + row("gps: no fix (normal indoors)", C.sub) + end + + -- rx_log identity: adverts carry a real public key, addressed frames carry + -- one-byte hashes, everything else carries nothing. All three are correct. + local log = wada.mesh.rx_log() + local withpk, withsrc = 0, 0 + for _, r in ipairs(log) do + if r.pubkey then withpk = withpk + 1 end + if r.src then withsrc = withsrc + 1 end + end + row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst", + #log, withpk, withsrc), #log > 0 and C.text or C.sub) + -- fs: windowed read. Writes once (the 1/sec limit means one write per open). + if wada.fs then + local probe = "0123456789abcdef" + wada.fs.write("sdktest.bin", probe) + local part, total = wada.fs.read("sdktest.bin", 4, 4) + local ok = (part == "4567" and total == #probe) + row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) .. + (ok and " PASS" or " FAIL"), ok and C.good or C.bad) + end + + -- Physical SD listing is read-only and independently feature-detected. A + -- supported board with no inserted card is a normal bench state, not a test + -- failure; API shape and path rejection can still be checked separately. + if c.sd_list then + if not wada.sd or not wada.sd.list then + row("wada.sd.list: MISSING despite caps", C.bad) + else + local guard_ok, guard_failure = true, nil + local bad_paths = { "/..", "/.", "/a/../b", "/a//b", "/a/", "relative", "/a\\b" } + for _, path in ipairs(bad_paths) do + local rejected, baderr = wada.sd.list(path) + if rejected ~= nil or baderr ~= "bad path" then + guard_ok = false + guard_failure = path .. " -> " .. tostring(baderr) + break + end + end + row("sd traversal guard: " .. (guard_ok and "PASS" or ("FAIL " .. guard_failure)), + guard_ok and C.good or C.bad) + + local entries, err = wada.sd.list("/") + if not entries then + local note = err == "busy" and " (retry later)" or " (no readable card)" + row("sd.list('/'): " .. tostring(err) .. note, C.sub) + else + local metadata_ok, first_dir = true, nil + for _, entry in ipairs(entries) do + if type(entry.name) ~= "string" or + (entry.type ~= "file" and entry.type ~= "dir") or + type(entry.size) ~= "number" or type(entry.mtime) ~= "number" then + metadata_ok = false + end + if not first_dir and entry.type == "dir" then first_dir = entry.name end + end + row("sd.list('/'): " .. #entries .. " entries, metadata " .. + (metadata_ok and "PASS" or "FAIL") .. + (entries.truncated and " (truncated)" or ""), + metadata_ok and C.good or C.bad) + if first_dir then + local nested, nested_err = wada.sd.list("/" .. first_dir) + row("sd.list('/" .. first_dir .. "'): " .. + (nested and (#nested .. " entries PASS") or ("FAIL " .. tostring(nested_err))), + nested and C.good or C.bad) + else + row("sd nested listing: no directory on card to test", C.sub) + end + end + end + else + row("wada.sd.list: unavailable on this board", C.sub) + end + + sendline = row("mesh.send: not tried yet", C.sub) + dmline = row("mesh.send_dm: not tried yet", C.sub) + msgline = row("on_message: waiting (needs the read permissions)", C.sub) + y = cy + 4 + ui.button("Send to Public", 6, y, 120, 30, function() + local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test") + sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err)) + sendline:color(ok and C.good or C.bad) + end) + ui.button("DM first contact", 132, y, 130, 30, function() + if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end + local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)") + dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err)) + dmline:color(ok and C.good or C.bad) + end) + cy = y + 38 + discline = row("mesh.discover: not tried yet", C.sub) + inputline = row("ui.input: not tried yet", C.sub) + -- Probing TRANSMITS and makes every neighbour reply, so it is a button, not + -- something this app does on open. + y = cy + 4 + ui.button("Probe", 6, y, 90, 30, function() + local tag, err = wada.mesh.discover() + if not tag then + discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return + end + discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text) + end) + ui.button("Results", 102, y, 90, 30, function() + local hits = wada.mesh.discovered() + if #hits == 0 then + discline:set("discovered(): nothing yet - probe, then wait a few seconds") + discline:color(C.sub); return + end + local h = hits[1] + discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s", + #hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h"))) + discline:color(C.good) + end) + y = y + 34 + ui.button("Beep", 6, y, 70, 30, function() sys.beep() end) + ui.button("Input", 82, y, 90, 30, function() + ui.input("Type anything", "hello", function(text) + inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled")) + inputline:color(text and C.good or C.sub) + end) + end) +end + +function app.on_input(ev) + if ev.type == "key" and keyline then + keyline:set("keys: got '" .. tostring(ev.key) .. "'") + keyline:color(C.good) + end +end + +-- Proves the kind field and that DMs/rooms reach an app at all, not just channels. +function app.on_message(m) + if not msgline then return end + msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) .. + " ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20)) + msgline:color(C.good) +end + +return app diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html index 6e66ce6..c0a72dc 100644 --- a/deploy/site/sdk.html +++ b/deploy/site/sdk.html @@ -141,6 +141,7 @@ wada.net wada.store wada.fs + wada.audio wada.sd wada.sys wada.timer @@ -441,6 +442,24 @@ end +
+

wada.audio audio

+
+

Asynchronous audio playback from the current app's private storage. A plain name such as track.wav resolves inside the same app folder as wada.fs, whether that folder lives on internal flash, SD, or SD_MMC. Check wada.sys.caps().audio and audio_wav before using it.

+ + + + + + + +
CallReturns / does
wada.audio.play(name)Starts or replaces playback and returns true, or nil, error. Plain names use app-private storage. On boards with caps().audio_sd, sd:/Music/track.wav reads directly from the physical card.
wada.audio.pause()Pauses an active track. Returns whether the command was accepted.
wada.audio.resume()Resumes a paused track. Returns whether the command was accepted.
wada.audio.stop()Stops the active track. Returns whether the command was accepted.
wada.audio.status(){state, path, source, format, error}. State is stopped, playing, paused, ended, or error; error is present only after a playback failure.
+

Supported formats are PCM WAV (16-bit mono or stereo at 8–48 kHz) and MPEG Layer III MP3, including CBR, VBR, and ID3-tagged files. Stereo is mixed to mono on the device. Check audio_wav or audio_mp3 rather than assuming a format exists on older firmware.

+

Playback follows the user's Sound switch and volume, never blocks the Lua callback, and stops automatically when the app closes. A second play() replaces the current track. Playlist ordering belongs to the app, so there are no ambiguous host-side next() or previous() calls.

+

Plain names follow the same sandbox rules as wada.fs. Explicit sd: paths follow wada.sd path validation. Expected errors include bad path, no storage, no sd, not found, busy, muted, and unsupported format.

+
+
+

wada.sd sd_list

@@ -466,7 +485,7 @@ end wada.sys.datetime(){year, month, day, hour, min, sec, wday} in local time. wday is 0 for Sunday. wada.sys.tr(s)Translate s through the device's active language, using the same table the firmware's own interface uses. Returns s unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to deploy/apps/lang/<code>.lang alongside the firmware's. Older firmware has no sys.tr: write local tr = sys.tr or function(x) return x end and call tr(). wada.sys.beep()Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off. - wada.sys.caps(){sdk_ext, keyboard, touch, sd, sd_list, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext below, and sd_list before using wada.sd. The rest are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring. + wada.sys.caps(){sdk_ext, keyboard, touch, sd, sd_list, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext, sd_list before using wada.sd, and audio plus the format flag before using wada.audio. audio_sd means direct sd: paths are available; plain audio names use app storage and do not require it. wada.sys.battery() ext{mv, pct, charging}. wada.sys.env() sensors{temp_c, humidity, pressure_hpa, alt_m}, or nil. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on caps().sensors — does this board have the sensor rail — and not on sdk_ext, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all. wada.sys.gps() ext{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}, or nil with no fix — which is the normal indoor case, so handle it. lat_e6/lon_e6 are exact micro-degrees; lat/lon are single-precision floats, so log the integers. alt_m is metres. time is satellite time, absent until the receiver has decoded the date. speed_kmh and course (degrees clockwise from north) appear only on boards whose GPS provider reports them, and course only while actually moving — a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional. @@ -587,7 +606,7 @@ end

Apps run in a restricted environment. These are removed: io, os, require, dofile, and loading new chunks at runtime. Available: math, string, table, and the usual pairs, ipairs, select, pcall, tostring, tonumber.

Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an instruction budget on every callback for the same reason.

-

The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, wada.mesh.send_dm, wada.mesh.discover, sys.battery, sys.gps — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call wada.sys.caps().sdk_ext and degrade gracefully rather than assuming. wada.sd additionally needs a physical card interface, reported by caps().sd_list.

+

The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, wada.mesh.send_dm, wada.mesh.discover, sys.battery, sys.gps — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call wada.sys.caps().sdk_ext and degrade gracefully rather than assuming. wada.sd additionally needs a physical card interface, reported by caps().sd_list. wada.audio instead follows caps().audio: storage may be internal, but the device still needs a stream-capable speaker path.

Rate limits are part of the contract, not a rainy-day guard: wada.fs writes are about one per second with a 32 KB file cap, and wada.mesh.send has a 5 second floor and a 180-character limit. They return false, "too fast" rather than throwing, and hitting them is expected — handle it.

None of this makes a hostile app safe, which is why the catalog is curated. It makes an honest app that has a bug survivable: it gets closed, and the device keeps carrying traffic.

diff --git a/lib/minimp3/LICENSE b/lib/minimp3/LICENSE new file mode 100644 index 0000000..2c4afab --- /dev/null +++ b/lib/minimp3/LICENSE @@ -0,0 +1,117 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + + diff --git a/lib/minimp3/README.md b/lib/minimp3/README.md new file mode 100644 index 0000000..7fbdc38 --- /dev/null +++ b/lib/minimp3/README.md @@ -0,0 +1,16 @@ +# minimp3 + +This directory vendors `minimp3.h` from +[lieff/minimp3](https://github.com/lieff/minimp3) at commit +`ea99364f61c14656440e8d77e9c233ccf3124633` under CC0-1.0. + +Pinned upstream and local file hashes: + +- upstream `minimp3.h`: `57e437c5c1f0e8b243885d3929c8973b5e6c778451e0100ab4251d19915cb3ad` +- local `minimp3.h` with the hook below: `3f59a7f29de636ca0db0aab8a5b86739c43ac8bf9ac902010e7220f783c0ae0a` +- `LICENSE`: `6a1ee543e5282cd9061881edf462e6fdab181f328da71fc2c9a6950a80e94d01` + +Wadamesh adds one opt-in `MINIMP3_SCRATCH` hook around the decoder's local +scratch object. The Lua audio worker uses it to place the roughly 16 KB decoder +workspace in PSRAM instead of its FreeRTOS task stack. Builds that do not define +the hook retain upstream behavior. diff --git a/lib/minimp3/minimp3.h b/lib/minimp3/minimp3.h new file mode 100644 index 0000000..ffab423 --- /dev/null +++ b/lib/minimp3/minimp3.h @@ -0,0 +1,1872 @@ +#ifndef MINIMP3_H +#define MINIMP3_H +/* + https://github.com/lieff/minimp3 + To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. + This software is distributed without any warranty. + See . +*/ +#include + +#define MINIMP3_MAX_SAMPLES_PER_FRAME (1152*2) + +typedef struct +{ + int frame_bytes, frame_offset, channels, hz, layer, bitrate_kbps; +} mp3dec_frame_info_t; + +typedef struct +{ + float mdct_overlap[2][9*32], qmf_state[15*2*32]; + int reserv, free_format_bytes; + unsigned char header[4], reserv_buf[511]; +} mp3dec_t; + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +void mp3dec_init(mp3dec_t *dec); +#ifndef MINIMP3_FLOAT_OUTPUT +typedef int16_t mp3d_sample_t; +#else /* MINIMP3_FLOAT_OUTPUT */ +typedef float mp3d_sample_t; +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples); +#endif /* MINIMP3_FLOAT_OUTPUT */ +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info); + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* MINIMP3_H */ +#if defined(MINIMP3_IMPLEMENTATION) && !defined(_MINIMP3_IMPLEMENTATION_GUARD) +#define _MINIMP3_IMPLEMENTATION_GUARD + +#include +#include + +#define MAX_FREE_FORMAT_FRAME_SIZE 2304 /* more than ISO spec's */ +#ifndef MAX_FRAME_SYNC_MATCHES +#define MAX_FRAME_SYNC_MATCHES 10 +#endif /* MAX_FRAME_SYNC_MATCHES */ + +#define MAX_L3_FRAME_PAYLOAD_BYTES MAX_FREE_FORMAT_FRAME_SIZE /* MUST be >= 320000/8/32000*1152 = 1440 */ + +#define MAX_BITRESERVOIR_BYTES 511 +#define SHORT_BLOCK_TYPE 2 +#define STOP_BLOCK_TYPE 3 +#define MODE_MONO 3 +#define MODE_JOINT_STEREO 1 +#define HDR_SIZE 4 +#define HDR_IS_MONO(h) (((h[3]) & 0xC0) == 0xC0) +#define HDR_IS_MS_STEREO(h) (((h[3]) & 0xE0) == 0x60) +#define HDR_IS_FREE_FORMAT(h) (((h[2]) & 0xF0) == 0) +#define HDR_IS_CRC(h) (!((h[1]) & 1)) +#define HDR_TEST_PADDING(h) ((h[2]) & 0x2) +#define HDR_TEST_MPEG1(h) ((h[1]) & 0x8) +#define HDR_TEST_NOT_MPEG25(h) ((h[1]) & 0x10) +#define HDR_TEST_I_STEREO(h) ((h[3]) & 0x10) +#define HDR_TEST_MS_STEREO(h) ((h[3]) & 0x20) +#define HDR_GET_STEREO_MODE(h) (((h[3]) >> 6) & 3) +#define HDR_GET_STEREO_MODE_EXT(h) (((h[3]) >> 4) & 3) +#define HDR_GET_LAYER(h) (((h[1]) >> 1) & 3) +#define HDR_GET_BITRATE(h) ((h[2]) >> 4) +#define HDR_GET_SAMPLE_RATE(h) (((h[2]) >> 2) & 3) +#define HDR_GET_MY_SAMPLE_RATE(h) (HDR_GET_SAMPLE_RATE(h) + (((h[1] >> 3) & 1) + ((h[1] >> 4) & 1))*3) +#define HDR_IS_FRAME_576(h) ((h[1] & 14) == 2) +#define HDR_IS_LAYER_1(h) ((h[1] & 6) == 6) + +#define BITS_DEQUANTIZER_OUT -1 +#define MAX_SCF (255 + BITS_DEQUANTIZER_OUT*4 - 210) +#define MAX_SCFI ((MAX_SCF + 3) & ~3) + +#define MINIMP3_MIN(a, b) ((a) > (b) ? (b) : (a)) +#define MINIMP3_MAX(a, b) ((a) < (b) ? (b) : (a)) + +#if !defined(MINIMP3_NO_SIMD) + +#if !defined(MINIMP3_ONLY_SIMD) && (defined(_M_X64) || defined(__x86_64__) || defined(__aarch64__) || defined(_M_ARM64)) +/* x64 always have SSE2, arm64 always have neon, no need for generic code */ +#define MINIMP3_ONLY_SIMD +#endif /* SIMD checks... */ + +#if (defined(_MSC_VER) && (defined(_M_IX86) || defined(_M_X64))) || ((defined(__i386__) || defined(__x86_64__)) && defined(__SSE2__)) +#if defined(_MSC_VER) +#include +#endif /* defined(_MSC_VER) */ +#include +#define HAVE_SSE 1 +#define HAVE_SIMD 1 +#define VSTORE _mm_storeu_ps +#define VLD _mm_loadu_ps +#define VSET _mm_set1_ps +#define VADD _mm_add_ps +#define VSUB _mm_sub_ps +#define VMUL _mm_mul_ps +#define VMAC(a, x, y) _mm_add_ps(a, _mm_mul_ps(x, y)) +#define VMSB(a, x, y) _mm_sub_ps(a, _mm_mul_ps(x, y)) +#define VMUL_S(x, s) _mm_mul_ps(x, _mm_set1_ps(s)) +#define VREV(x) _mm_shuffle_ps(x, x, _MM_SHUFFLE(0, 1, 2, 3)) +typedef __m128 f4; +#if defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) +#define minimp3_cpuid __cpuid +#else /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static __inline__ __attribute__((always_inline)) void minimp3_cpuid(int CPUInfo[], const int InfoType) +{ +#if defined(__PIC__) + __asm__ __volatile__( +#if defined(__x86_64__) + "push %%rbx\n" + "cpuid\n" + "xchgl %%ebx, %1\n" + "pop %%rbx\n" +#else /* defined(__x86_64__) */ + "xchgl %%ebx, %1\n" + "cpuid\n" + "xchgl %%ebx, %1\n" +#endif /* defined(__x86_64__) */ + : "=a" (CPUInfo[0]), "=r" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#else /* defined(__PIC__) */ + __asm__ __volatile__( + "cpuid" + : "=a" (CPUInfo[0]), "=b" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#endif /* defined(__PIC__)*/ +} +#endif /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static int have_simd(void) +{ +#ifdef MINIMP3_ONLY_SIMD + return 1; +#else /* MINIMP3_ONLY_SIMD */ + static int g_have_simd; + int CPUInfo[4]; +#ifdef MINIMP3_TEST + static int g_counter; + if (g_counter++ > 100) + return 0; +#endif /* MINIMP3_TEST */ + if (g_have_simd) + goto end; + minimp3_cpuid(CPUInfo, 0); + g_have_simd = 1; + if (CPUInfo[0] > 0) + { + minimp3_cpuid(CPUInfo, 1); + g_have_simd = (CPUInfo[3] & (1 << 26)) + 1; /* SSE2 */ + } +end: + return g_have_simd - 1; +#endif /* MINIMP3_ONLY_SIMD */ +} +#elif defined(__ARM_NEON) || defined(__aarch64__) || defined(_M_ARM64) +#include +#define HAVE_SSE 0 +#define HAVE_SIMD 1 +#define VSTORE vst1q_f32 +#define VLD vld1q_f32 +#define VSET vmovq_n_f32 +#define VADD vaddq_f32 +#define VSUB vsubq_f32 +#define VMUL vmulq_f32 +#define VMAC(a, x, y) vmlaq_f32(a, x, y) +#define VMSB(a, x, y) vmlsq_f32(a, x, y) +#define VMUL_S(x, s) vmulq_f32(x, vmovq_n_f32(s)) +#define VREV(x) vcombine_f32(vget_high_f32(vrev64q_f32(x)), vget_low_f32(vrev64q_f32(x))) +typedef float32x4_t f4; +static int have_simd() +{ /* TODO: detect neon for !MINIMP3_ONLY_SIMD */ + return 1; +} +#else /* SIMD checks... */ +#define HAVE_SSE 0 +#define HAVE_SIMD 0 +#ifdef MINIMP3_ONLY_SIMD +#error MINIMP3_ONLY_SIMD used, but SSE/NEON not enabled +#endif /* MINIMP3_ONLY_SIMD */ +#endif /* SIMD checks... */ +#else /* !defined(MINIMP3_NO_SIMD) */ +#define HAVE_SIMD 0 +#endif /* !defined(MINIMP3_NO_SIMD) */ + +#if defined(__ARM_ARCH) && (__ARM_ARCH >= 6) && !defined(__aarch64__) && !defined(_M_ARM64) && !defined(__ARM_ARCH_6M__) +#define HAVE_ARMV6 1 +static __inline__ __attribute__((always_inline)) int32_t minimp3_clip_int16_arm(int32_t a) +{ + int32_t x = 0; + __asm__ ("ssat %0, #16, %1" : "=r"(x) : "r"(a)); + return x; +} +#else +#define HAVE_ARMV6 0 +#endif + +typedef struct +{ + const uint8_t *buf; + int pos, limit; +} bs_t; + +typedef struct +{ + float scf[3*64]; + uint8_t total_bands, stereo_bands, bitalloc[64], scfcod[64]; +} L12_scale_info; + +typedef struct +{ + uint8_t tab_offset, code_tab_width, band_count; +} L12_subband_alloc_t; + +typedef struct +{ + const uint8_t *sfbtab; + uint16_t part_23_length, big_values, scalefac_compress; + uint8_t global_gain, block_type, mixed_block_flag, n_long_sfb, n_short_sfb; + uint8_t table_select[3], region_count[3], subblock_gain[3]; + uint8_t preflag, scalefac_scale, count1_table, scfsi; +} L3_gr_info_t; + +typedef struct +{ + bs_t bs; + uint8_t maindata[MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES]; + L3_gr_info_t gr_info[4]; + float grbuf[2][576], scf[40], syn[18 + 15][2*32]; + uint8_t ist_pos[2][39]; +} mp3dec_scratch_t; + +static void bs_init(bs_t *bs, const uint8_t *data, int bytes) +{ + bs->buf = data; + bs->pos = 0; + bs->limit = bytes*8; +} + +static uint32_t get_bits(bs_t *bs, int n) +{ + uint32_t next, cache = 0, s = bs->pos & 7; + int shl = n + s; + const uint8_t *p = bs->buf + (bs->pos >> 3); + if ((bs->pos += n) > bs->limit) + return 0; + next = *p++ & (255 >> s); + while ((shl -= 8) > 0) + { + cache |= next << shl; + next = *p++; + } + return cache | (next >> -shl); +} + +static int hdr_valid(const uint8_t *h) +{ + return h[0] == 0xff && + ((h[1] & 0xF0) == 0xf0 || (h[1] & 0xFE) == 0xe2) && + (HDR_GET_LAYER(h) != 0) && + (HDR_GET_BITRATE(h) != 15) && + (HDR_GET_SAMPLE_RATE(h) != 3); +} + +static int hdr_compare(const uint8_t *h1, const uint8_t *h2) +{ + return hdr_valid(h2) && + ((h1[1] ^ h2[1]) & 0xFE) == 0 && + ((h1[2] ^ h2[2]) & 0x0C) == 0 && + !(HDR_IS_FREE_FORMAT(h1) ^ HDR_IS_FREE_FORMAT(h2)); +} + +static unsigned hdr_bitrate_kbps(const uint8_t *h) +{ + static const uint8_t halfrate[2][3][15] = { + { { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,16,24,28,32,40,48,56,64,72,80,88,96,112,128 } }, + { { 0,16,20,24,28,32,40,48,56,64,80,96,112,128,160 }, { 0,16,24,28,32,40,48,56,64,80,96,112,128,160,192 }, { 0,16,32,48,64,80,96,112,128,144,160,176,192,208,224 } }, + }; + return 2*halfrate[!!HDR_TEST_MPEG1(h)][HDR_GET_LAYER(h) - 1][HDR_GET_BITRATE(h)]; +} + +static unsigned hdr_sample_rate_hz(const uint8_t *h) +{ + static const unsigned g_hz[3] = { 44100, 48000, 32000 }; + return g_hz[HDR_GET_SAMPLE_RATE(h)] >> (int)!HDR_TEST_MPEG1(h) >> (int)!HDR_TEST_NOT_MPEG25(h); +} + +static unsigned hdr_frame_samples(const uint8_t *h) +{ + return HDR_IS_LAYER_1(h) ? 384 : (1152 >> (int)HDR_IS_FRAME_576(h)); +} + +static int hdr_frame_bytes(const uint8_t *h, int free_format_size) +{ + int frame_bytes = hdr_frame_samples(h)*hdr_bitrate_kbps(h)*125/hdr_sample_rate_hz(h); + if (HDR_IS_LAYER_1(h)) + { + frame_bytes &= ~3; /* slot align */ + } + return frame_bytes ? frame_bytes : free_format_size; +} + +static int hdr_padding(const uint8_t *h) +{ + return HDR_TEST_PADDING(h) ? (HDR_IS_LAYER_1(h) ? 4 : 1) : 0; +} + +#ifndef MINIMP3_ONLY_MP3 +static const L12_subband_alloc_t *L12_subband_alloc_table(const uint8_t *hdr, L12_scale_info *sci) +{ + const L12_subband_alloc_t *alloc; + int mode = HDR_GET_STEREO_MODE(hdr); + int nbands, stereo_bands = (mode == MODE_MONO) ? 0 : (mode == MODE_JOINT_STEREO) ? (HDR_GET_STEREO_MODE_EXT(hdr) << 2) + 4 : 32; + + if (HDR_IS_LAYER_1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L1[] = { { 76, 4, 32 } }; + alloc = g_alloc_L1; + nbands = 32; + } else if (!HDR_TEST_MPEG1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L2M2[] = { { 60, 4, 4 }, { 44, 3, 7 }, { 44, 2, 19 } }; + alloc = g_alloc_L2M2; + nbands = 30; + } else + { + static const L12_subband_alloc_t g_alloc_L2M1[] = { { 0, 4, 3 }, { 16, 4, 8 }, { 32, 3, 12 }, { 40, 2, 7 } }; + int sample_rate_idx = HDR_GET_SAMPLE_RATE(hdr); + unsigned kbps = hdr_bitrate_kbps(hdr) >> (int)(mode != MODE_MONO); + if (!kbps) /* free-format */ + { + kbps = 192; + } + + alloc = g_alloc_L2M1; + nbands = 27; + if (kbps < 56) + { + static const L12_subband_alloc_t g_alloc_L2M1_lowrate[] = { { 44, 4, 2 }, { 44, 3, 10 } }; + alloc = g_alloc_L2M1_lowrate; + nbands = sample_rate_idx == 2 ? 12 : 8; + } else if (kbps >= 96 && sample_rate_idx != 1) + { + nbands = 30; + } + } + + sci->total_bands = (uint8_t)nbands; + sci->stereo_bands = (uint8_t)MINIMP3_MIN(stereo_bands, nbands); + + return alloc; +} + +static void L12_read_scalefactors(bs_t *bs, uint8_t *pba, uint8_t *scfcod, int bands, float *scf) +{ + static const float g_deq_L12[18*3] = { +#define DQ(x) 9.53674316e-07f/x, 7.56931807e-07f/x, 6.00777173e-07f/x + DQ(3),DQ(7),DQ(15),DQ(31),DQ(63),DQ(127),DQ(255),DQ(511),DQ(1023),DQ(2047),DQ(4095),DQ(8191),DQ(16383),DQ(32767),DQ(65535),DQ(3),DQ(5),DQ(9) + }; + int i, m; + for (i = 0; i < bands; i++) + { + float s = 0; + int ba = *pba++; + int mask = ba ? 4 + ((19 >> scfcod[i]) & 3) : 0; + for (m = 4; m; m >>= 1) + { + if (mask & m) + { + int b = get_bits(bs, 6); + s = g_deq_L12[ba*3 - 6 + b % 3]*(1 << 21 >> b/3); + } + *scf++ = s; + } + } +} + +static void L12_read_scale_info(const uint8_t *hdr, bs_t *bs, L12_scale_info *sci) +{ + static const uint8_t g_bitalloc_code_tab[] = { + 0,17, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,16, + 0,17,18, 3,19,4,5,16, + 0,17,18,16, + 0,17,18,19, 4,5,6, 7,8, 9,10,11,12,13,14,15, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,14, + 0, 2, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16 + }; + const L12_subband_alloc_t *subband_alloc = L12_subband_alloc_table(hdr, sci); + + int i, k = 0, ba_bits = 0; + const uint8_t *ba_code_tab = g_bitalloc_code_tab; + + for (i = 0; i < sci->total_bands; i++) + { + uint8_t ba; + if (i == k) + { + k += subband_alloc->band_count; + ba_bits = subband_alloc->code_tab_width; + ba_code_tab = g_bitalloc_code_tab + subband_alloc->tab_offset; + subband_alloc++; + } + ba = ba_code_tab[get_bits(bs, ba_bits)]; + sci->bitalloc[2*i] = ba; + if (i < sci->stereo_bands) + { + ba = ba_code_tab[get_bits(bs, ba_bits)]; + } + sci->bitalloc[2*i + 1] = sci->stereo_bands ? ba : 0; + } + + for (i = 0; i < 2*sci->total_bands; i++) + { + sci->scfcod[i] = sci->bitalloc[i] ? HDR_IS_LAYER_1(hdr) ? 2 : get_bits(bs, 2) : 6; + } + + L12_read_scalefactors(bs, sci->bitalloc, sci->scfcod, sci->total_bands*2, sci->scf); + + for (i = sci->stereo_bands; i < sci->total_bands; i++) + { + sci->bitalloc[2*i + 1] = 0; + } +} + +static int L12_dequantize_granule(float *grbuf, bs_t *bs, L12_scale_info *sci, int group_size) +{ + int i, j, k, choff = 576; + for (j = 0; j < 4; j++) + { + float *dst = grbuf + group_size*j; + for (i = 0; i < 2*sci->total_bands; i++) + { + int ba = sci->bitalloc[i]; + if (ba != 0) + { + if (ba < 17) + { + int half = (1 << (ba - 1)) - 1; + for (k = 0; k < group_size; k++) + { + dst[k] = (float)((int)get_bits(bs, ba) - half); + } + } else + { + unsigned mod = (2 << (ba - 17)) + 1; /* 3, 5, 9 */ + unsigned code = get_bits(bs, mod + 2 - (mod >> 3)); /* 5, 7, 10 */ + for (k = 0; k < group_size; k++, code /= mod) + { + dst[k] = (float)((int)(code % mod - mod/2)); + } + } + } + dst += choff; + choff = 18 - choff; + } + } + return group_size*4; +} + +static void L12_apply_scf_384(L12_scale_info *sci, const float *scf, float *dst) +{ + int i, k; + memcpy(dst + 576 + sci->stereo_bands*18, dst + sci->stereo_bands*18, (sci->total_bands - sci->stereo_bands)*18*sizeof(float)); + for (i = 0; i < sci->total_bands; i++, dst += 18, scf += 6) + { + for (k = 0; k < 12; k++) + { + dst[k + 0] *= scf[0]; + dst[k + 576] *= scf[3]; + } + } +} +#endif /* MINIMP3_ONLY_MP3 */ + +static int L3_read_side_info(bs_t *bs, L3_gr_info_t *gr, const uint8_t *hdr) +{ + static const uint8_t g_scf_long[8][23] = { + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 12,12,12,12,12,12,16,20,24,28,32,40,48,56,64,76,90,2,2,2,2,2,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,18,22,26,32,38,46,54,62,70,76,36,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 4,4,4,4,4,4,6,6,8,8,10,12,16,20,24,28,34,42,50,54,76,158,0 }, + { 4,4,4,4,4,4,6,6,6,8,10,12,16,18,22,28,34,40,46,54,54,192,0 }, + { 4,4,4,4,4,4,6,6,8,10,12,16,20,24,30,38,46,56,68,84,102,26,0 } + }; + static const uint8_t g_scf_short[8][40] = { + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 8,8,8,8,8,8,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + static const uint8_t g_scf_mixed[8][40] = { + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 12,12,12,4,4,4,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 6,6,6,6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + + unsigned tables, scfsi = 0; + int main_data_begin, part_23_sum = 0; + int sr_idx = HDR_GET_MY_SAMPLE_RATE(hdr); sr_idx -= (sr_idx != 0); + int gr_count = HDR_IS_MONO(hdr) ? 1 : 2; + + if (HDR_TEST_MPEG1(hdr)) + { + gr_count *= 2; + main_data_begin = get_bits(bs, 9); + scfsi = get_bits(bs, 7 + gr_count); + } else + { + main_data_begin = get_bits(bs, 8 + gr_count) >> gr_count; + } + + do + { + if (HDR_IS_MONO(hdr)) + { + scfsi <<= 4; + } + gr->part_23_length = (uint16_t)get_bits(bs, 12); + part_23_sum += gr->part_23_length; + gr->big_values = (uint16_t)get_bits(bs, 9); + if (gr->big_values > 288) + { + return -1; + } + gr->global_gain = (uint8_t)get_bits(bs, 8); + gr->scalefac_compress = (uint16_t)get_bits(bs, HDR_TEST_MPEG1(hdr) ? 4 : 9); + gr->sfbtab = g_scf_long[sr_idx]; + gr->n_long_sfb = 22; + gr->n_short_sfb = 0; + if (get_bits(bs, 1)) + { + gr->block_type = (uint8_t)get_bits(bs, 2); + if (!gr->block_type) + { + return -1; + } + gr->mixed_block_flag = (uint8_t)get_bits(bs, 1); + gr->region_count[0] = 7; + gr->region_count[1] = 255; + if (gr->block_type == SHORT_BLOCK_TYPE) + { + scfsi &= 0x0F0F; + if (!gr->mixed_block_flag) + { + gr->region_count[0] = 8; + gr->sfbtab = g_scf_short[sr_idx]; + gr->n_long_sfb = 0; + gr->n_short_sfb = 39; + } else + { + gr->sfbtab = g_scf_mixed[sr_idx]; + gr->n_long_sfb = HDR_TEST_MPEG1(hdr) ? 8 : 6; + gr->n_short_sfb = 30; + } + } + tables = get_bits(bs, 10); + tables <<= 5; + gr->subblock_gain[0] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[1] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[2] = (uint8_t)get_bits(bs, 3); + } else + { + gr->block_type = 0; + gr->mixed_block_flag = 0; + tables = get_bits(bs, 15); + gr->region_count[0] = (uint8_t)get_bits(bs, 4); + gr->region_count[1] = (uint8_t)get_bits(bs, 3); + gr->region_count[2] = 255; + } + gr->table_select[0] = (uint8_t)(tables >> 10); + gr->table_select[1] = (uint8_t)((tables >> 5) & 31); + gr->table_select[2] = (uint8_t)((tables) & 31); + gr->preflag = HDR_TEST_MPEG1(hdr) ? get_bits(bs, 1) : (gr->scalefac_compress >= 500); + gr->scalefac_scale = (uint8_t)get_bits(bs, 1); + gr->count1_table = (uint8_t)get_bits(bs, 1); + gr->scfsi = (uint8_t)((scfsi >> 12) & 15); + scfsi <<= 4; + gr++; + } while(--gr_count); + + if (part_23_sum + bs->pos > bs->limit + main_data_begin*8) + { + return -1; + } + + return main_data_begin; +} + +static void L3_read_scalefactors(uint8_t *scf, uint8_t *ist_pos, const uint8_t *scf_size, const uint8_t *scf_count, bs_t *bitbuf, int scfsi) +{ + int i, k; + for (i = 0; i < 4 && scf_count[i]; i++, scfsi *= 2) + { + int cnt = scf_count[i]; + if (scfsi & 8) + { + memcpy(scf, ist_pos, cnt); + } else + { + int bits = scf_size[i]; + if (!bits) + { + memset(scf, 0, cnt); + memset(ist_pos, 0, cnt); + } else + { + int max_scf = (scfsi < 0) ? (1 << bits) - 1 : -1; + for (k = 0; k < cnt; k++) + { + int s = get_bits(bitbuf, bits); + ist_pos[k] = (s == max_scf ? -1 : s); + scf[k] = s; + } + } + } + ist_pos += cnt; + scf += cnt; + } + scf[0] = scf[1] = scf[2] = 0; +} + +static float L3_ldexp_q2(float y, int exp_q2) +{ + static const float g_expfrac[4] = { 9.31322575e-10f,7.83145814e-10f,6.58544508e-10f,5.53767716e-10f }; + int e; + do + { + e = MINIMP3_MIN(30*4, exp_q2); + y *= g_expfrac[e & 3]*(1 << 30 >> (e >> 2)); + } while ((exp_q2 -= e) > 0); + return y; +} + +static void L3_decode_scalefactors(const uint8_t *hdr, uint8_t *ist_pos, bs_t *bs, const L3_gr_info_t *gr, float *scf, int ch) +{ + static const uint8_t g_scf_partitions[3][28] = { + { 6,5,5, 5,6,5,5,5,6,5, 7,3,11,10,0,0, 7, 7, 7,0, 6, 6,6,3, 8, 8,5,0 }, + { 8,9,6,12,6,9,9,9,6,9,12,6,15,18,0,0, 6,15,12,0, 6,12,9,6, 6,18,9,0 }, + { 9,9,6,12,9,9,9,9,9,9,12,6,18,18,0,0,12,12,12,0,12, 9,9,6,15,12,9,0 } + }; + const uint8_t *scf_partition = g_scf_partitions[!!gr->n_short_sfb + !gr->n_long_sfb]; + uint8_t scf_size[4], iscf[40]; + int i, scf_shift = gr->scalefac_scale + 1, gain_exp, scfsi = gr->scfsi; + float gain; + + if (HDR_TEST_MPEG1(hdr)) + { + static const uint8_t g_scfc_decode[16] = { 0,1,2,3, 12,5,6,7, 9,10,11,13, 14,15,18,19 }; + int part = g_scfc_decode[gr->scalefac_compress]; + scf_size[1] = scf_size[0] = (uint8_t)(part >> 2); + scf_size[3] = scf_size[2] = (uint8_t)(part & 3); + } else + { + static const uint8_t g_mod[6*4] = { 5,5,4,4,5,5,4,1,4,3,1,1,5,6,6,1,4,4,4,1,4,3,1,1 }; + int k, modprod, sfc, ist = HDR_TEST_I_STEREO(hdr) && ch; + sfc = gr->scalefac_compress >> ist; + for (k = ist*3*4; sfc >= 0; sfc -= modprod, k += 4) + { + for (modprod = 1, i = 3; i >= 0; i--) + { + scf_size[i] = (uint8_t)(sfc / modprod % g_mod[k + i]); + modprod *= g_mod[k + i]; + } + } + scf_partition += k; + scfsi = -16; + } + L3_read_scalefactors(iscf, ist_pos, scf_size, scf_partition, bs, scfsi); + + if (gr->n_short_sfb) + { + int sh = 3 - scf_shift; + for (i = 0; i < gr->n_short_sfb; i += 3) + { + iscf[gr->n_long_sfb + i + 0] += gr->subblock_gain[0] << sh; + iscf[gr->n_long_sfb + i + 1] += gr->subblock_gain[1] << sh; + iscf[gr->n_long_sfb + i + 2] += gr->subblock_gain[2] << sh; + } + } else if (gr->preflag) + { + static const uint8_t g_preamp[10] = { 1,1,1,1,2,2,3,3,3,2 }; + for (i = 0; i < 10; i++) + { + iscf[11 + i] += g_preamp[i]; + } + } + + gain_exp = gr->global_gain + BITS_DEQUANTIZER_OUT*4 - 210 - (HDR_IS_MS_STEREO(hdr) ? 2 : 0); + gain = L3_ldexp_q2(1 << (MAX_SCFI/4), MAX_SCFI - gain_exp); + for (i = 0; i < (int)(gr->n_long_sfb + gr->n_short_sfb); i++) + { + scf[i] = L3_ldexp_q2(gain, iscf[i] << scf_shift); + } +} + +static const float g_pow43[129 + 16] = { + 0,-1,-2.519842f,-4.326749f,-6.349604f,-8.549880f,-10.902724f,-13.390518f,-16.000000f,-18.720754f,-21.544347f,-24.463781f,-27.473142f,-30.567351f,-33.741992f,-36.993181f, + 0,1,2.519842f,4.326749f,6.349604f,8.549880f,10.902724f,13.390518f,16.000000f,18.720754f,21.544347f,24.463781f,27.473142f,30.567351f,33.741992f,36.993181f,40.317474f,43.711787f,47.173345f,50.699631f,54.288352f,57.937408f,61.644865f,65.408941f,69.227979f,73.100443f,77.024898f,81.000000f,85.024491f,89.097188f,93.216975f,97.382800f,101.593667f,105.848633f,110.146801f,114.487321f,118.869381f,123.292209f,127.755065f,132.257246f,136.798076f,141.376907f,145.993119f,150.646117f,155.335327f,160.060199f,164.820202f,169.614826f,174.443577f,179.305980f,184.201575f,189.129918f,194.090580f,199.083145f,204.107210f,209.162385f,214.248292f,219.364564f,224.510845f,229.686789f,234.892058f,240.126328f,245.389280f,250.680604f,256.000000f,261.347174f,266.721841f,272.123723f,277.552547f,283.008049f,288.489971f,293.998060f,299.532071f,305.091761f,310.676898f,316.287249f,321.922592f,327.582707f,333.267377f,338.976394f,344.709550f,350.466646f,356.247482f,362.051866f,367.879608f,373.730522f,379.604427f,385.501143f,391.420496f,397.362314f,403.326427f,409.312672f,415.320884f,421.350905f,427.402579f,433.475750f,439.570269f,445.685987f,451.822757f,457.980436f,464.158883f,470.357960f,476.577530f,482.817459f,489.077615f,495.357868f,501.658090f,507.978156f,514.317941f,520.677324f,527.056184f,533.454404f,539.871867f,546.308458f,552.764065f,559.238575f,565.731879f,572.243870f,578.774440f,585.323483f,591.890898f,598.476581f,605.080431f,611.702349f,618.342238f,625.000000f,631.675540f,638.368763f,645.079578f +}; + +static float L3_pow_43(int x) +{ + float frac; + int sign, mult = 256; + + if (x < 129) + { + return g_pow43[16 + x]; + } + + if (x < 1024) + { + mult = 16; + x <<= 3; + } + + sign = 2*x & 64; + frac = (float)((x & 63) - sign) / ((x & ~63) + sign); + return g_pow43[16 + ((x + sign) >> 6)]*(1.f + frac*((4.f/3) + frac*(2.f/9)))*mult; +} + +static void L3_huffman(float *dst, bs_t *bs, const L3_gr_info_t *gr_info, const float *scf, int layer3gr_limit) +{ + static const int16_t tabs[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 785,785,785,785,784,784,784,784,513,513,513,513,513,513,513,513,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256, + -255,1313,1298,1282,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,290,288, + -255,1313,1298,1282,769,769,769,769,529,529,529,529,529,529,529,529,528,528,528,528,528,528,528,528,512,512,512,512,512,512,512,512,290,288, + -253,-318,-351,-367,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,819,818,547,547,275,275,275,275,561,560,515,546,289,274,288,258, + -254,-287,1329,1299,1314,1312,1057,1057,1042,1042,1026,1026,784,784,784,784,529,529,529,529,529,529,529,529,769,769,769,769,768,768,768,768,563,560,306,306,291,259, + -252,-413,-477,-542,1298,-575,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-383,-399,1107,1092,1106,1061,849,849,789,789,1104,1091,773,773,1076,1075,341,340,325,309,834,804,577,577,532,532,516,516,832,818,803,816,561,561,531,531,515,546,289,289,288,258, + -252,-429,-493,-559,1057,1057,1042,1042,529,529,529,529,529,529,529,529,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,-382,1077,-415,1106,1061,1104,849,849,789,789,1091,1076,1029,1075,834,834,597,581,340,340,339,324,804,833,532,532,832,772,818,803,817,787,816,771,290,290,290,290,288,258, + -253,-349,-414,-447,-463,1329,1299,-479,1314,1312,1057,1057,1042,1042,1026,1026,785,785,785,785,784,784,784,784,769,769,769,769,768,768,768,768,-319,851,821,-335,836,850,805,849,341,340,325,336,533,533,579,579,564,564,773,832,578,548,563,516,321,276,306,291,304,259, + -251,-572,-733,-830,-863,-879,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,1396,1351,1381,1366,1395,1335,1380,-559,1334,1138,1138,1063,1063,1350,1392,1031,1031,1062,1062,1364,1363,1120,1120,1333,1348,881,881,881,881,375,374,359,373,343,358,341,325,791,791,1123,1122,-703,1105,1045,-719,865,865,790,790,774,774,1104,1029,338,293,323,308,-799,-815,833,788,772,818,803,816,322,292,307,320,561,531,515,546,289,274,288,258, + -251,-525,-605,-685,-765,-831,-846,1298,1057,1057,1312,1282,785,785,785,785,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,1399,1398,1383,1367,1382,1396,1351,-511,1381,1366,1139,1139,1079,1079,1124,1124,1364,1349,1363,1333,882,882,882,882,807,807,807,807,1094,1094,1136,1136,373,341,535,535,881,775,867,822,774,-591,324,338,-671,849,550,550,866,864,609,609,293,336,534,534,789,835,773,-751,834,804,308,307,833,788,832,772,562,562,547,547,305,275,560,515,290,290, + -252,-397,-477,-557,-622,-653,-719,-735,-750,1329,1299,1314,1057,1057,1042,1042,1312,1282,1024,1024,785,785,785,785,784,784,784,784,769,769,769,769,-383,1127,1141,1111,1126,1140,1095,1110,869,869,883,883,1079,1109,882,882,375,374,807,868,838,881,791,-463,867,822,368,263,852,837,836,-543,610,610,550,550,352,336,534,534,865,774,851,821,850,805,593,533,579,564,773,832,578,578,548,548,577,577,307,276,306,291,516,560,259,259, + -250,-2107,-2507,-2764,-2909,-2974,-3007,-3023,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-767,-1052,-1213,-1277,-1358,-1405,-1469,-1535,-1550,-1582,-1614,-1647,-1662,-1694,-1726,-1759,-1774,-1807,-1822,-1854,-1886,1565,-1919,-1935,-1951,-1967,1731,1730,1580,1717,-1983,1729,1564,-1999,1548,-2015,-2031,1715,1595,-2047,1714,-2063,1610,-2079,1609,-2095,1323,1323,1457,1457,1307,1307,1712,1547,1641,1700,1699,1594,1685,1625,1442,1442,1322,1322,-780,-973,-910,1279,1278,1277,1262,1276,1261,1275,1215,1260,1229,-959,974,974,989,989,-943,735,478,478,495,463,506,414,-1039,1003,958,1017,927,942,987,957,431,476,1272,1167,1228,-1183,1256,-1199,895,895,941,941,1242,1227,1212,1135,1014,1014,490,489,503,487,910,1013,985,925,863,894,970,955,1012,847,-1343,831,755,755,984,909,428,366,754,559,-1391,752,486,457,924,997,698,698,983,893,740,740,908,877,739,739,667,667,953,938,497,287,271,271,683,606,590,712,726,574,302,302,738,736,481,286,526,725,605,711,636,724,696,651,589,681,666,710,364,467,573,695,466,466,301,465,379,379,709,604,665,679,316,316,634,633,436,436,464,269,424,394,452,332,438,363,347,408,393,448,331,422,362,407,392,421,346,406,391,376,375,359,1441,1306,-2367,1290,-2383,1337,-2399,-2415,1426,1321,-2431,1411,1336,-2447,-2463,-2479,1169,1169,1049,1049,1424,1289,1412,1352,1319,-2495,1154,1154,1064,1064,1153,1153,416,390,360,404,403,389,344,374,373,343,358,372,327,357,342,311,356,326,1395,1394,1137,1137,1047,1047,1365,1392,1287,1379,1334,1364,1349,1378,1318,1363,792,792,792,792,1152,1152,1032,1032,1121,1121,1046,1046,1120,1120,1030,1030,-2895,1106,1061,1104,849,849,789,789,1091,1076,1029,1090,1060,1075,833,833,309,324,532,532,832,772,818,803,561,561,531,560,515,546,289,274,288,258, + -250,-1179,-1579,-1836,-1996,-2124,-2253,-2333,-2413,-2477,-2542,-2574,-2607,-2622,-2655,1314,1313,1298,1312,1282,785,785,785,785,1040,1040,1025,1025,768,768,768,768,-766,-798,-830,-862,-895,-911,-927,-943,-959,-975,-991,-1007,-1023,-1039,-1055,-1070,1724,1647,-1103,-1119,1631,1767,1662,1738,1708,1723,-1135,1780,1615,1779,1599,1677,1646,1778,1583,-1151,1777,1567,1737,1692,1765,1722,1707,1630,1751,1661,1764,1614,1736,1676,1763,1750,1645,1598,1721,1691,1762,1706,1582,1761,1566,-1167,1749,1629,767,766,751,765,494,494,735,764,719,749,734,763,447,447,748,718,477,506,431,491,446,476,461,505,415,430,475,445,504,399,460,489,414,503,383,474,429,459,502,502,746,752,488,398,501,473,413,472,486,271,480,270,-1439,-1455,1357,-1471,-1487,-1503,1341,1325,-1519,1489,1463,1403,1309,-1535,1372,1448,1418,1476,1356,1462,1387,-1551,1475,1340,1447,1402,1386,-1567,1068,1068,1474,1461,455,380,468,440,395,425,410,454,364,467,466,464,453,269,409,448,268,432,1371,1473,1432,1417,1308,1460,1355,1446,1459,1431,1083,1083,1401,1416,1458,1445,1067,1067,1370,1457,1051,1051,1291,1430,1385,1444,1354,1415,1400,1443,1082,1082,1173,1113,1186,1066,1185,1050,-1967,1158,1128,1172,1097,1171,1081,-1983,1157,1112,416,266,375,400,1170,1142,1127,1065,793,793,1169,1033,1156,1096,1141,1111,1155,1080,1126,1140,898,898,808,808,897,897,792,792,1095,1152,1032,1125,1110,1139,1079,1124,882,807,838,881,853,791,-2319,867,368,263,822,852,837,866,806,865,-2399,851,352,262,534,534,821,836,594,594,549,549,593,593,533,533,848,773,579,579,564,578,548,563,276,276,577,576,306,291,516,560,305,305,275,259, + -251,-892,-2058,-2620,-2828,-2957,-3023,-3039,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,-559,1530,-575,-591,1528,1527,1407,1526,1391,1023,1023,1023,1023,1525,1375,1268,1268,1103,1103,1087,1087,1039,1039,1523,-604,815,815,815,815,510,495,509,479,508,463,507,447,431,505,415,399,-734,-782,1262,-815,1259,1244,-831,1258,1228,-847,-863,1196,-879,1253,987,987,748,-767,493,493,462,477,414,414,686,669,478,446,461,445,474,429,487,458,412,471,1266,1264,1009,1009,799,799,-1019,-1276,-1452,-1581,-1677,-1757,-1821,-1886,-1933,-1997,1257,1257,1483,1468,1512,1422,1497,1406,1467,1496,1421,1510,1134,1134,1225,1225,1466,1451,1374,1405,1252,1252,1358,1480,1164,1164,1251,1251,1238,1238,1389,1465,-1407,1054,1101,-1423,1207,-1439,830,830,1248,1038,1237,1117,1223,1148,1236,1208,411,426,395,410,379,269,1193,1222,1132,1235,1221,1116,976,976,1192,1162,1177,1220,1131,1191,963,963,-1647,961,780,-1663,558,558,994,993,437,408,393,407,829,978,813,797,947,-1743,721,721,377,392,844,950,828,890,706,706,812,859,796,960,948,843,934,874,571,571,-1919,690,555,689,421,346,539,539,944,779,918,873,932,842,903,888,570,570,931,917,674,674,-2575,1562,-2591,1609,-2607,1654,1322,1322,1441,1441,1696,1546,1683,1593,1669,1624,1426,1426,1321,1321,1639,1680,1425,1425,1305,1305,1545,1668,1608,1623,1667,1592,1638,1666,1320,1320,1652,1607,1409,1409,1304,1304,1288,1288,1664,1637,1395,1395,1335,1335,1622,1636,1394,1394,1319,1319,1606,1621,1392,1392,1137,1137,1137,1137,345,390,360,375,404,373,1047,-2751,-2767,-2783,1062,1121,1046,-2799,1077,-2815,1106,1061,789,789,1105,1104,263,355,310,340,325,354,352,262,339,324,1091,1076,1029,1090,1060,1075,833,833,788,788,1088,1028,818,818,803,803,561,561,531,531,816,771,546,546,289,274,288,258, + -253,-317,-381,-446,-478,-509,1279,1279,-811,-1179,-1451,-1756,-1900,-2028,-2189,-2253,-2333,-2414,-2445,-2511,-2526,1313,1298,-2559,1041,1041,1040,1040,1025,1025,1024,1024,1022,1007,1021,991,1020,975,1019,959,687,687,1018,1017,671,671,655,655,1016,1015,639,639,758,758,623,623,757,607,756,591,755,575,754,559,543,543,1009,783,-575,-621,-685,-749,496,-590,750,749,734,748,974,989,1003,958,988,973,1002,942,987,957,972,1001,926,986,941,971,956,1000,910,985,925,999,894,970,-1071,-1087,-1102,1390,-1135,1436,1509,1451,1374,-1151,1405,1358,1480,1420,-1167,1507,1494,1389,1342,1465,1435,1450,1326,1505,1310,1493,1373,1479,1404,1492,1464,1419,428,443,472,397,736,526,464,464,486,457,442,471,484,482,1357,1449,1434,1478,1388,1491,1341,1490,1325,1489,1463,1403,1309,1477,1372,1448,1418,1433,1476,1356,1462,1387,-1439,1475,1340,1447,1402,1474,1324,1461,1371,1473,269,448,1432,1417,1308,1460,-1711,1459,-1727,1441,1099,1099,1446,1386,1431,1401,-1743,1289,1083,1083,1160,1160,1458,1445,1067,1067,1370,1457,1307,1430,1129,1129,1098,1098,268,432,267,416,266,400,-1887,1144,1187,1082,1173,1113,1186,1066,1050,1158,1128,1143,1172,1097,1171,1081,420,391,1157,1112,1170,1142,1127,1065,1169,1049,1156,1096,1141,1111,1155,1080,1126,1154,1064,1153,1140,1095,1048,-2159,1125,1110,1137,-2175,823,823,1139,1138,807,807,384,264,368,263,868,838,853,791,867,822,852,837,866,806,865,790,-2319,851,821,836,352,262,850,805,849,-2399,533,533,835,820,336,261,578,548,563,577,532,532,832,772,562,562,547,547,305,275,560,515,290,290,288,258 }; + static const uint8_t tab32[] = { 130,162,193,209,44,28,76,140,9,9,9,9,9,9,9,9,190,254,222,238,126,94,157,157,109,61,173,205 }; + static const uint8_t tab33[] = { 252,236,220,204,188,172,156,140,124,108,92,76,60,44,28,12 }; + static const int16_t tabindex[2*16] = { 0,32,64,98,0,132,180,218,292,364,426,538,648,746,0,1126,1460,1460,1460,1460,1460,1460,1460,1460,1842,1842,1842,1842,1842,1842,1842,1842 }; + static const uint8_t g_linbits[] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3,4,6,8,10,13,4,5,6,7,8,9,11,13 }; + +#define PEEK_BITS(n) (bs_cache >> (32 - n)) +#define FLUSH_BITS(n) { bs_cache <<= (n); bs_sh += (n); } +#define CHECK_BITS while (bs_sh >= 0) { bs_cache |= (uint32_t)*bs_next_ptr++ << bs_sh; bs_sh -= 8; } +#define BSPOS ((bs_next_ptr - bs->buf)*8 - 24 + bs_sh) + + float one = 0.0f; + int ireg = 0, big_val_cnt = gr_info->big_values; + const uint8_t *sfb = gr_info->sfbtab; + const uint8_t *bs_next_ptr = bs->buf + bs->pos/8; + uint32_t bs_cache = (((bs_next_ptr[0]*256u + bs_next_ptr[1])*256u + bs_next_ptr[2])*256u + bs_next_ptr[3]) << (bs->pos & 7); + int pairs_to_decode, np, bs_sh = (bs->pos & 7) - 8; + bs_next_ptr += 4; + + while (big_val_cnt > 0) + { + int tab_num = gr_info->table_select[ireg]; + int sfb_cnt = gr_info->region_count[ireg++]; + const int16_t *codebook = tabs + tabindex[tab_num]; + int linbits = g_linbits[tab_num]; + if (linbits) + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + if (lsb == 15) + { + lsb += PEEK_BITS(linbits); + FLUSH_BITS(linbits); + CHECK_BITS; + *dst = one*L3_pow_43(lsb)*((int32_t)bs_cache < 0 ? -1: 1); + } else + { + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + } + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } else + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } + } + + for (np = 1 - big_val_cnt;; dst += 4) + { + const uint8_t *codebook_count1 = (gr_info->count1_table) ? tab33 : tab32; + int leaf = codebook_count1[PEEK_BITS(4)]; + if (!(leaf & 8)) + { + leaf = codebook_count1[(leaf >> 3) + (bs_cache << 4 >> (32 - (leaf & 3)))]; + } + FLUSH_BITS(leaf & 7); + if (BSPOS > layer3gr_limit) + { + break; + } +#define RELOAD_SCALEFACTOR if (!--np) { np = *sfb++/2; if (!np) break; one = *scf++; } +#define DEQ_COUNT1(s) if (leaf & (128 >> s)) { dst[s] = ((int32_t)bs_cache < 0) ? -one : one; FLUSH_BITS(1) } + RELOAD_SCALEFACTOR; + DEQ_COUNT1(0); + DEQ_COUNT1(1); + RELOAD_SCALEFACTOR; + DEQ_COUNT1(2); + DEQ_COUNT1(3); + CHECK_BITS; + } + + bs->pos = layer3gr_limit; +} + +static void L3_midside_stereo(float *left, int n) +{ + int i = 0; + float *right = left + 576; +#if HAVE_SIMD + if (have_simd()) + { + for (; i < n - 3; i += 4) + { + f4 vl = VLD(left + i); + f4 vr = VLD(right + i); + VSTORE(left + i, VADD(vl, vr)); + VSTORE(right + i, VSUB(vl, vr)); + } +#ifdef __GNUC__ + /* Workaround for spurious -Waggressive-loop-optimizations warning from gcc. + * For more info see: https://github.com/lieff/minimp3/issues/88 + */ + if (__builtin_constant_p(n % 4 == 0) && n % 4 == 0) + return; +#endif + } +#endif /* HAVE_SIMD */ + for (; i < n; i++) + { + float a = left[i]; + float b = right[i]; + left[i] = a + b; + right[i] = a - b; + } +} + +static void L3_intensity_stereo_band(float *left, int n, float kl, float kr) +{ + int i; + for (i = 0; i < n; i++) + { + left[i + 576] = left[i]*kr; + left[i] = left[i]*kl; + } +} + +static void L3_stereo_top_band(const float *right, const uint8_t *sfb, int nbands, int max_band[3]) +{ + int i, k; + + max_band[0] = max_band[1] = max_band[2] = -1; + + for (i = 0; i < nbands; i++) + { + for (k = 0; k < sfb[i]; k += 2) + { + if (right[k] != 0 || right[k + 1] != 0) + { + max_band[i % 3] = i; + break; + } + } + right += sfb[i]; + } +} + +static void L3_stereo_process(float *left, const uint8_t *ist_pos, const uint8_t *sfb, const uint8_t *hdr, int max_band[3], int mpeg2_sh) +{ + static const float g_pan[7*2] = { 0,1,0.21132487f,0.78867513f,0.36602540f,0.63397460f,0.5f,0.5f,0.63397460f,0.36602540f,0.78867513f,0.21132487f,1,0 }; + unsigned i, max_pos = HDR_TEST_MPEG1(hdr) ? 7 : 64; + + for (i = 0; sfb[i]; i++) + { + unsigned ipos = ist_pos[i]; + if ((int)i > max_band[i % 3] && ipos < max_pos) + { + float kl, kr, s = HDR_TEST_MS_STEREO(hdr) ? 1.41421356f : 1; + if (HDR_TEST_MPEG1(hdr)) + { + kl = g_pan[2*ipos]; + kr = g_pan[2*ipos + 1]; + } else + { + kl = 1; + kr = L3_ldexp_q2(1, (ipos + 1) >> 1 << mpeg2_sh); + if (ipos & 1) + { + kl = kr; + kr = 1; + } + } + L3_intensity_stereo_band(left, sfb[i], kl*s, kr*s); + } else if (HDR_TEST_MS_STEREO(hdr)) + { + L3_midside_stereo(left, sfb[i]); + } + left += sfb[i]; + } +} + +static void L3_intensity_stereo(float *left, uint8_t *ist_pos, const L3_gr_info_t *gr, const uint8_t *hdr) +{ + int max_band[3], n_sfb = gr->n_long_sfb + gr->n_short_sfb; + int i, max_blocks = gr->n_short_sfb ? 3 : 1; + + L3_stereo_top_band(left + 576, gr->sfbtab, n_sfb, max_band); + if (gr->n_long_sfb) + { + max_band[0] = max_band[1] = max_band[2] = MINIMP3_MAX(MINIMP3_MAX(max_band[0], max_band[1]), max_band[2]); + } + for (i = 0; i < max_blocks; i++) + { + int default_pos = HDR_TEST_MPEG1(hdr) ? 3 : 0; + int itop = n_sfb - max_blocks + i; + int prev = itop - max_blocks; + ist_pos[itop] = max_band[i] >= prev ? default_pos : ist_pos[prev]; + } + L3_stereo_process(left, ist_pos, gr->sfbtab, hdr, max_band, gr[1].scalefac_compress & 1); +} + +static void L3_reorder(float *grbuf, float *scratch, const uint8_t *sfb) +{ + int i, len; + float *src = grbuf, *dst = scratch; + + for (;0 != (len = *sfb); sfb += 3, src += 2*len) + { + for (i = 0; i < len; i++, src++) + { + *dst++ = src[0*len]; + *dst++ = src[1*len]; + *dst++ = src[2*len]; + } + } + memcpy(grbuf, scratch, (dst - scratch)*sizeof(float)); +} + +static void L3_antialias(float *grbuf, int nbands) +{ + static const float g_aa[2][8] = { + {0.85749293f,0.88174200f,0.94962865f,0.98331459f,0.99551782f,0.99916056f,0.99989920f,0.99999316f}, + {0.51449576f,0.47173197f,0.31337745f,0.18191320f,0.09457419f,0.04096558f,0.01419856f,0.00369997f} + }; + + for (; nbands > 0; nbands--, grbuf += 18) + { + int i = 0; +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vu = VLD(grbuf + 18 + i); + f4 vd = VLD(grbuf + 14 - i); + f4 vc0 = VLD(g_aa[0] + i); + f4 vc1 = VLD(g_aa[1] + i); + vd = VREV(vd); + VSTORE(grbuf + 18 + i, VSUB(VMUL(vu, vc0), VMUL(vd, vc1))); + vd = VADD(VMUL(vu, vc1), VMUL(vd, vc0)); + VSTORE(grbuf + 14 - i, VREV(vd)); + } +#endif /* HAVE_SIMD */ +#ifndef MINIMP3_ONLY_SIMD + for(; i < 8; i++) + { + float u = grbuf[18 + i]; + float d = grbuf[17 - i]; + grbuf[18 + i] = u*g_aa[0][i] - d*g_aa[1][i]; + grbuf[17 - i] = u*g_aa[1][i] + d*g_aa[0][i]; + } +#endif /* MINIMP3_ONLY_SIMD */ + } +} + +static void L3_dct3_9(float *y) +{ + float s0, s1, s2, s3, s4, s5, s6, s7, s8, t0, t2, t4; + + s0 = y[0]; s2 = y[2]; s4 = y[4]; s6 = y[6]; s8 = y[8]; + t0 = s0 + s6*0.5f; + s0 -= s6; + t4 = (s4 + s2)*0.93969262f; + t2 = (s8 + s2)*0.76604444f; + s6 = (s4 - s8)*0.17364818f; + s4 += s8 - s2; + + s2 = s0 - s4*0.5f; + y[4] = s4 + s0; + s8 = t0 - t2 + s6; + s0 = t0 - t4 + t2; + s4 = t0 + t4 - s6; + + s1 = y[1]; s3 = y[3]; s5 = y[5]; s7 = y[7]; + + s3 *= 0.86602540f; + t0 = (s5 + s1)*0.98480775f; + t4 = (s5 - s7)*0.34202014f; + t2 = (s1 + s7)*0.64278761f; + s1 = (s1 - s5 - s7)*0.86602540f; + + s5 = t0 - s3 - t2; + s7 = t4 - s3 - t0; + s3 = t4 + s3 - t2; + + y[0] = s4 - s7; + y[1] = s2 + s1; + y[2] = s0 - s3; + y[3] = s8 + s5; + y[5] = s8 - s5; + y[6] = s0 + s3; + y[7] = s2 - s1; + y[8] = s4 + s7; +} + +static void L3_imdct36(float *grbuf, float *overlap, const float *window, int nbands) +{ + int i, j; + static const float g_twid9[18] = { + 0.73727734f,0.79335334f,0.84339145f,0.88701083f,0.92387953f,0.95371695f,0.97629601f,0.99144486f,0.99904822f,0.67559021f,0.60876143f,0.53729961f,0.46174861f,0.38268343f,0.30070580f,0.21643961f,0.13052619f,0.04361938f + }; + + for (j = 0; j < nbands; j++, grbuf += 18, overlap += 9) + { + float co[9], si[9]; + co[0] = -grbuf[0]; + si[0] = grbuf[17]; + for (i = 0; i < 4; i++) + { + si[8 - 2*i] = grbuf[4*i + 1] - grbuf[4*i + 2]; + co[1 + 2*i] = grbuf[4*i + 1] + grbuf[4*i + 2]; + si[7 - 2*i] = grbuf[4*i + 4] - grbuf[4*i + 3]; + co[2 + 2*i] = -(grbuf[4*i + 3] + grbuf[4*i + 4]); + } + L3_dct3_9(co); + L3_dct3_9(si); + + si[1] = -si[1]; + si[3] = -si[3]; + si[5] = -si[5]; + si[7] = -si[7]; + + i = 0; + +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vovl = VLD(overlap + i); + f4 vc = VLD(co + i); + f4 vs = VLD(si + i); + f4 vr0 = VLD(g_twid9 + i); + f4 vr1 = VLD(g_twid9 + 9 + i); + f4 vw0 = VLD(window + i); + f4 vw1 = VLD(window + 9 + i); + f4 vsum = VADD(VMUL(vc, vr1), VMUL(vs, vr0)); + VSTORE(overlap + i, VSUB(VMUL(vc, vr0), VMUL(vs, vr1))); + VSTORE(grbuf + i, VSUB(VMUL(vovl, vw0), VMUL(vsum, vw1))); + vsum = VADD(VMUL(vovl, vw1), VMUL(vsum, vw0)); + VSTORE(grbuf + 14 - i, VREV(vsum)); + } +#endif /* HAVE_SIMD */ + for (; i < 9; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid9[9 + i] + si[i]*g_twid9[0 + i]; + overlap[i] = co[i]*g_twid9[0 + i] - si[i]*g_twid9[9 + i]; + grbuf[i] = ovl*window[0 + i] - sum*window[9 + i]; + grbuf[17 - i] = ovl*window[9 + i] + sum*window[0 + i]; + } + } +} + +static void L3_idct3(float x0, float x1, float x2, float *dst) +{ + float m1 = x1*0.86602540f; + float a1 = x0 - x2*0.5f; + dst[1] = x0 + x2; + dst[0] = a1 + m1; + dst[2] = a1 - m1; +} + +static void L3_imdct12(float *x, float *dst, float *overlap) +{ + static const float g_twid3[6] = { 0.79335334f,0.92387953f,0.99144486f, 0.60876143f,0.38268343f,0.13052619f }; + float co[3], si[3]; + int i; + + L3_idct3(-x[0], x[6] + x[3], x[12] + x[9], co); + L3_idct3(x[15], x[12] - x[9], x[6] - x[3], si); + si[1] = -si[1]; + + for (i = 0; i < 3; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid3[3 + i] + si[i]*g_twid3[0 + i]; + overlap[i] = co[i]*g_twid3[0 + i] - si[i]*g_twid3[3 + i]; + dst[i] = ovl*g_twid3[2 - i] - sum*g_twid3[5 - i]; + dst[5 - i] = ovl*g_twid3[5 - i] + sum*g_twid3[2 - i]; + } +} + +static void L3_imdct_short(float *grbuf, float *overlap, int nbands) +{ + for (;nbands > 0; nbands--, overlap += 9, grbuf += 18) + { + float tmp[18]; + memcpy(tmp, grbuf, sizeof(tmp)); + memcpy(grbuf, overlap, 6*sizeof(float)); + L3_imdct12(tmp, grbuf + 6, overlap + 6); + L3_imdct12(tmp + 1, grbuf + 12, overlap + 6); + L3_imdct12(tmp + 2, overlap, overlap + 6); + } +} + +static void L3_change_sign(float *grbuf) +{ + int b, i; + for (b = 0, grbuf += 18; b < 32; b += 2, grbuf += 36) + for (i = 1; i < 18; i += 2) + grbuf[i] = -grbuf[i]; +} + +static void L3_imdct_gr(float *grbuf, float *overlap, unsigned block_type, unsigned n_long_bands) +{ + static const float g_mdct_window[2][18] = { + { 0.99904822f,0.99144486f,0.97629601f,0.95371695f,0.92387953f,0.88701083f,0.84339145f,0.79335334f,0.73727734f,0.04361938f,0.13052619f,0.21643961f,0.30070580f,0.38268343f,0.46174861f,0.53729961f,0.60876143f,0.67559021f }, + { 1,1,1,1,1,1,0.99144486f,0.92387953f,0.79335334f,0,0,0,0,0,0,0.13052619f,0.38268343f,0.60876143f } + }; + if (n_long_bands) + { + L3_imdct36(grbuf, overlap, g_mdct_window[0], n_long_bands); + grbuf += 18*n_long_bands; + overlap += 9*n_long_bands; + } + if (block_type == SHORT_BLOCK_TYPE) + L3_imdct_short(grbuf, overlap, 32 - n_long_bands); + else + L3_imdct36(grbuf, overlap, g_mdct_window[block_type == STOP_BLOCK_TYPE], 32 - n_long_bands); +} + +static void L3_save_reservoir(mp3dec_t *h, mp3dec_scratch_t *s) +{ + int pos = (s->bs.pos + 7)/8u; + int remains = s->bs.limit/8u - pos; + if (remains > MAX_BITRESERVOIR_BYTES) + { + pos += remains - MAX_BITRESERVOIR_BYTES; + remains = MAX_BITRESERVOIR_BYTES; + } + if (remains > 0) + { + memmove(h->reserv_buf, s->maindata + pos, remains); + } + h->reserv = remains; +} + +static int L3_restore_reservoir(mp3dec_t *h, bs_t *bs, mp3dec_scratch_t *s, int main_data_begin) +{ + int frame_bytes = (bs->limit - bs->pos)/8; + int bytes_have = MINIMP3_MIN(h->reserv, main_data_begin); + memcpy(s->maindata, h->reserv_buf + MINIMP3_MAX(0, h->reserv - main_data_begin), MINIMP3_MIN(h->reserv, main_data_begin)); + memcpy(s->maindata + bytes_have, bs->buf + bs->pos/8, frame_bytes); + bs_init(&s->bs, s->maindata, bytes_have + frame_bytes); + return h->reserv >= main_data_begin; +} + +static void L3_decode(mp3dec_t *h, mp3dec_scratch_t *s, L3_gr_info_t *gr_info, int nch) +{ + int ch; + + for (ch = 0; ch < nch; ch++) + { + int layer3gr_limit = s->bs.pos + gr_info[ch].part_23_length; + L3_decode_scalefactors(h->header, s->ist_pos[ch], &s->bs, gr_info + ch, s->scf, ch); + L3_huffman(s->grbuf[ch], &s->bs, gr_info + ch, s->scf, layer3gr_limit); + } + + if (HDR_TEST_I_STEREO(h->header)) + { + L3_intensity_stereo(s->grbuf[0], s->ist_pos[1], gr_info, h->header); + } else if (HDR_IS_MS_STEREO(h->header)) + { + L3_midside_stereo(s->grbuf[0], 576); + } + + for (ch = 0; ch < nch; ch++, gr_info++) + { + int aa_bands = 31; + int n_long_bands = (gr_info->mixed_block_flag ? 2 : 0) << (int)(HDR_GET_MY_SAMPLE_RATE(h->header) == 2); + + if (gr_info->n_short_sfb) + { + aa_bands = n_long_bands - 1; + L3_reorder(s->grbuf[ch] + n_long_bands*18, s->syn[0], gr_info->sfbtab + gr_info->n_long_sfb); + } + + L3_antialias(s->grbuf[ch], aa_bands); + L3_imdct_gr(s->grbuf[ch], h->mdct_overlap[ch], gr_info->block_type, n_long_bands); + L3_change_sign(s->grbuf[ch]); + } +} + +static void mp3d_DCT_II(float *grbuf, int n) +{ + static const float g_sec[24] = { + 10.19000816f,0.50060302f,0.50241929f,3.40760851f,0.50547093f,0.52249861f,2.05778098f,0.51544732f,0.56694406f,1.48416460f,0.53104258f,0.64682180f,1.16943991f,0.55310392f,0.78815460f,0.97256821f,0.58293498f,1.06067765f,0.83934963f,0.62250412f,1.72244716f,0.74453628f,0.67480832f,5.10114861f + }; + int i, k = 0; +#if HAVE_SIMD + if (have_simd()) for (; k < n; k += 4) + { + f4 t[4][8], *x; + float *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + f4 x0 = VLD(&y[i*18]); + f4 x1 = VLD(&y[(15 - i)*18]); + f4 x2 = VLD(&y[(16 + i)*18]); + f4 x3 = VLD(&y[(31 - i)*18]); + f4 t0 = VADD(x0, x3); + f4 t1 = VADD(x1, x2); + f4 t2 = VMUL_S(VSUB(x1, x2), g_sec[3*i + 0]); + f4 t3 = VMUL_S(VSUB(x0, x3), g_sec[3*i + 1]); + x[0] = VADD(t0, t1); + x[8] = VMUL_S(VSUB(t0, t1), g_sec[3*i + 2]); + x[16] = VADD(t3, t2); + x[24] = VMUL_S(VSUB(t3, t2), g_sec[3*i + 2]); + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + f4 x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = VSUB(x0, x7); x0 = VADD(x0, x7); + x7 = VSUB(x1, x6); x1 = VADD(x1, x6); + x6 = VSUB(x2, x5); x2 = VADD(x2, x5); + x5 = VSUB(x3, x4); x3 = VADD(x3, x4); + x4 = VSUB(x0, x3); x0 = VADD(x0, x3); + x3 = VSUB(x1, x2); x1 = VADD(x1, x2); + x[0] = VADD(x0, x1); + x[4] = VMUL_S(VSUB(x0, x1), 0.70710677f); + x5 = VADD(x5, x6); + x6 = VMUL_S(VADD(x6, x7), 0.70710677f); + x7 = VADD(x7, xt); + x3 = VMUL_S(VADD(x3, x4), 0.70710677f); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); /* rotate by PI/8 */ + x7 = VADD(x7, VMUL_S(x5, 0.382683432f)); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); + x0 = VSUB(xt, x6); xt = VADD(xt, x6); + x[1] = VMUL_S(VADD(xt, x7), 0.50979561f); + x[2] = VMUL_S(VADD(x4, x3), 0.54119611f); + x[3] = VMUL_S(VSUB(x0, x5), 0.60134488f); + x[5] = VMUL_S(VADD(x0, x5), 0.89997619f); + x[6] = VMUL_S(VSUB(x4, x3), 1.30656302f); + x[7] = VMUL_S(VSUB(xt, x7), 2.56291556f); + } + + if (k > n - 3) + { +#if HAVE_SSE +#define VSAVE2(i, v) _mm_storel_pi((__m64 *)(void*)&y[i*18], v) +#else /* HAVE_SSE */ +#define VSAVE2(i, v) vst1_f32((float32_t *)&y[i*18], vget_low_f32(v)) +#endif /* HAVE_SSE */ + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE2(0, t[0][i]); + VSAVE2(1, VADD(t[2][i], s)); + VSAVE2(2, VADD(t[1][i], t[1][i + 1])); + VSAVE2(3, VADD(t[2][1 + i], s)); + } + VSAVE2(0, t[0][7]); + VSAVE2(1, VADD(t[2][7], t[3][7])); + VSAVE2(2, t[1][7]); + VSAVE2(3, t[3][7]); + } else + { +#define VSAVE4(i, v) VSTORE(&y[i*18], v) + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE4(0, t[0][i]); + VSAVE4(1, VADD(t[2][i], s)); + VSAVE4(2, VADD(t[1][i], t[1][i + 1])); + VSAVE4(3, VADD(t[2][1 + i], s)); + } + VSAVE4(0, t[0][7]); + VSAVE4(1, VADD(t[2][7], t[3][7])); + VSAVE4(2, t[1][7]); + VSAVE4(3, t[3][7]); + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (; k < n; k++) + { + float t[4][8], *x, *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + float x0 = y[i*18]; + float x1 = y[(15 - i)*18]; + float x2 = y[(16 + i)*18]; + float x3 = y[(31 - i)*18]; + float t0 = x0 + x3; + float t1 = x1 + x2; + float t2 = (x1 - x2)*g_sec[3*i + 0]; + float t3 = (x0 - x3)*g_sec[3*i + 1]; + x[0] = t0 + t1; + x[8] = (t0 - t1)*g_sec[3*i + 2]; + x[16] = t3 + t2; + x[24] = (t3 - t2)*g_sec[3*i + 2]; + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + float x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = x0 - x7; x0 += x7; + x7 = x1 - x6; x1 += x6; + x6 = x2 - x5; x2 += x5; + x5 = x3 - x4; x3 += x4; + x4 = x0 - x3; x0 += x3; + x3 = x1 - x2; x1 += x2; + x[0] = x0 + x1; + x[4] = (x0 - x1)*0.70710677f; + x5 = x5 + x6; + x6 = (x6 + x7)*0.70710677f; + x7 = x7 + xt; + x3 = (x3 + x4)*0.70710677f; + x5 -= x7*0.198912367f; /* rotate by PI/8 */ + x7 += x5*0.382683432f; + x5 -= x7*0.198912367f; + x0 = xt - x6; xt += x6; + x[1] = (xt + x7)*0.50979561f; + x[2] = (x4 + x3)*0.54119611f; + x[3] = (x0 - x5)*0.60134488f; + x[5] = (x0 + x5)*0.89997619f; + x[6] = (x4 - x3)*1.30656302f; + x[7] = (xt - x7)*2.56291556f; + + } + for (i = 0; i < 7; i++, y += 4*18) + { + y[0*18] = t[0][i]; + y[1*18] = t[2][i] + t[3][i] + t[3][i + 1]; + y[2*18] = t[1][i] + t[1][i + 1]; + y[3*18] = t[2][i + 1] + t[3][i] + t[3][i + 1]; + } + y[0*18] = t[0][7]; + y[1*18] = t[2][7] + t[3][7]; + y[2*18] = t[1][7]; + y[3*18] = t[3][7]; + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +#ifndef MINIMP3_FLOAT_OUTPUT +static int16_t mp3d_scale_pcm(float sample) +{ +#if HAVE_ARMV6 + int32_t s32 = (int32_t)(sample + .5f); + s32 -= (s32 < 0); + int16_t s = (int16_t)minimp3_clip_int16_arm(s32); +#else + if (sample >= 32766.5) return (int16_t) 32767; + if (sample <= -32767.5) return (int16_t)-32768; + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ +#endif + return s; +} +#else /* MINIMP3_FLOAT_OUTPUT */ +static float mp3d_scale_pcm(float sample) +{ + return sample*(1.f/32768.f); +} +#endif /* MINIMP3_FLOAT_OUTPUT */ + +static void mp3d_synth_pair(mp3d_sample_t *pcm, int nch, const float *z) +{ + float a; + a = (z[14*64] - z[ 0]) * 29; + a += (z[ 1*64] + z[13*64]) * 213; + a += (z[12*64] - z[ 2*64]) * 459; + a += (z[ 3*64] + z[11*64]) * 2037; + a += (z[10*64] - z[ 4*64]) * 5153; + a += (z[ 5*64] + z[ 9*64]) * 6574; + a += (z[ 8*64] - z[ 6*64]) * 37489; + a += z[ 7*64] * 75038; + pcm[0] = mp3d_scale_pcm(a); + + z += 2; + a = z[14*64] * 104; + a += z[12*64] * 1567; + a += z[10*64] * 9727; + a += z[ 8*64] * 64019; + a += z[ 6*64] * -9975; + a += z[ 4*64] * -45; + a += z[ 2*64] * 146; + a += z[ 0*64] * -5; + pcm[16*nch] = mp3d_scale_pcm(a); +} + +static void mp3d_synth(float *xl, mp3d_sample_t *dstl, int nch, float *lins) +{ + int i; + float *xr = xl + 576*(nch - 1); + mp3d_sample_t *dstr = dstl + (nch - 1); + + static const float g_win[] = { + -1,26,-31,208,218,401,-519,2063,2000,4788,-5517,7134,5959,35640,-39336,74992, + -1,24,-35,202,222,347,-581,2080,1952,4425,-5879,7640,5288,33791,-41176,74856, + -1,21,-38,196,225,294,-645,2087,1893,4063,-6237,8092,4561,31947,-43006,74630, + -1,19,-41,190,227,244,-711,2085,1822,3705,-6589,8492,3776,30112,-44821,74313, + -1,17,-45,183,228,197,-779,2075,1739,3351,-6935,8840,2935,28289,-46617,73908, + -1,16,-49,176,228,153,-848,2057,1644,3004,-7271,9139,2037,26482,-48390,73415, + -2,14,-53,169,227,111,-919,2032,1535,2663,-7597,9389,1082,24694,-50137,72835, + -2,13,-58,161,224,72,-991,2001,1414,2330,-7910,9592,70,22929,-51853,72169, + -2,11,-63,154,221,36,-1064,1962,1280,2006,-8209,9750,-998,21189,-53534,71420, + -2,10,-68,147,215,2,-1137,1919,1131,1692,-8491,9863,-2122,19478,-55178,70590, + -3,9,-73,139,208,-29,-1210,1870,970,1388,-8755,9935,-3300,17799,-56778,69679, + -3,8,-79,132,200,-57,-1283,1817,794,1095,-8998,9966,-4533,16155,-58333,68692, + -4,7,-85,125,189,-83,-1356,1759,605,814,-9219,9959,-5818,14548,-59838,67629, + -4,7,-91,117,177,-106,-1428,1698,402,545,-9416,9916,-7154,12980,-61289,66494, + -5,6,-97,111,163,-127,-1498,1634,185,288,-9585,9838,-8540,11455,-62684,65290 + }; + float *zlin = lins + 15*64; + const float *w = g_win; + + zlin[4*15] = xl[18*16]; + zlin[4*15 + 1] = xr[18*16]; + zlin[4*15 + 2] = xl[0]; + zlin[4*15 + 3] = xr[0]; + + zlin[4*31] = xl[1 + 18*16]; + zlin[4*31 + 1] = xr[1 + 18*16]; + zlin[4*31 + 2] = xl[1]; + zlin[4*31 + 3] = xr[1]; + + mp3d_synth_pair(dstr, nch, lins + 4*15 + 1); + mp3d_synth_pair(dstr + 32*nch, nch, lins + 4*15 + 64 + 1); + mp3d_synth_pair(dstl, nch, lins + 4*15); + mp3d_synth_pair(dstl + 32*nch, nch, lins + 4*15 + 64); + +#if HAVE_SIMD + if (have_simd()) for (i = 14; i >= 0; i--) + { +#define VLOAD(k) f4 w0 = VSET(*w++); f4 w1 = VSET(*w++); f4 vz = VLD(&zlin[4*i - 64*k]); f4 vy = VLD(&zlin[4*i - 64*(15 - k)]); +#define V0(k) { VLOAD(k) b = VADD(VMUL(vz, w1), VMUL(vy, w0)) ; a = VSUB(VMUL(vz, w0), VMUL(vy, w1)); } +#define V1(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vz, w0), VMUL(vy, w1))); } +#define V2(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vy, w1), VMUL(vz, w0))); } + f4 a, b; + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*i + 64] = xl[1 + 18*(1 + i)]; + zlin[4*i + 64 + 1] = xr[1 + 18*(1 + i)]; + zlin[4*i - 64 + 2] = xl[18*(1 + i)]; + zlin[4*i - 64 + 3] = xr[18*(1 + i)]; + + V0(0) V2(1) V1(2) V2(3) V1(4) V2(5) V1(6) V2(7) + + { +#ifndef MINIMP3_FLOAT_OUTPUT +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + dstr[(15 - i)*nch] = _mm_extract_epi16(pcm8, 1); + dstr[(17 + i)*nch] = _mm_extract_epi16(pcm8, 5); + dstl[(15 - i)*nch] = _mm_extract_epi16(pcm8, 0); + dstl[(17 + i)*nch] = _mm_extract_epi16(pcm8, 4); + dstr[(47 - i)*nch] = _mm_extract_epi16(pcm8, 3); + dstr[(49 + i)*nch] = _mm_extract_epi16(pcm8, 7); + dstl[(47 - i)*nch] = _mm_extract_epi16(pcm8, 2); + dstl[(49 + i)*nch] = _mm_extract_epi16(pcm8, 6); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(dstr + (15 - i)*nch, pcma, 1); + vst1_lane_s16(dstr + (17 + i)*nch, pcmb, 1); + vst1_lane_s16(dstl + (15 - i)*nch, pcma, 0); + vst1_lane_s16(dstl + (17 + i)*nch, pcmb, 0); + vst1_lane_s16(dstr + (47 - i)*nch, pcma, 3); + vst1_lane_s16(dstr + (49 + i)*nch, pcmb, 3); + vst1_lane_s16(dstl + (47 - i)*nch, pcma, 2); + vst1_lane_s16(dstl + (49 + i)*nch, pcmb, 2); +#endif /* HAVE_SSE */ + +#else /* MINIMP3_FLOAT_OUTPUT */ + + static const f4 g_scale = { 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f }; + a = VMUL(a, g_scale); + b = VMUL(b, g_scale); +#if HAVE_SSE + _mm_store_ss(dstr + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstr + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstl + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstl + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstr + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstr + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstl + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(2, 2, 2, 2))); + _mm_store_ss(dstl + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(2, 2, 2, 2))); +#else /* HAVE_SSE */ + vst1q_lane_f32(dstr + (15 - i)*nch, a, 1); + vst1q_lane_f32(dstr + (17 + i)*nch, b, 1); + vst1q_lane_f32(dstl + (15 - i)*nch, a, 0); + vst1q_lane_f32(dstl + (17 + i)*nch, b, 0); + vst1q_lane_f32(dstr + (47 - i)*nch, a, 3); + vst1q_lane_f32(dstr + (49 + i)*nch, b, 3); + vst1q_lane_f32(dstl + (47 - i)*nch, a, 2); + vst1q_lane_f32(dstl + (49 + i)*nch, b, 2); +#endif /* HAVE_SSE */ +#endif /* MINIMP3_FLOAT_OUTPUT */ + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (i = 14; i >= 0; i--) + { +#define LOAD(k) float w0 = *w++; float w1 = *w++; float *vz = &zlin[4*i - k*64]; float *vy = &zlin[4*i - (15 - k)*64]; +#define S0(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] = vz[j]*w1 + vy[j]*w0, a[j] = vz[j]*w0 - vy[j]*w1; } +#define S1(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vz[j]*w0 - vy[j]*w1; } +#define S2(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vy[j]*w1 - vz[j]*w0; } + float a[4], b[4]; + + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*(i + 16)] = xl[1 + 18*(1 + i)]; + zlin[4*(i + 16) + 1] = xr[1 + 18*(1 + i)]; + zlin[4*(i - 16) + 2] = xl[18*(1 + i)]; + zlin[4*(i - 16) + 3] = xr[18*(1 + i)]; + + S0(0) S2(1) S1(2) S2(3) S1(4) S2(5) S1(6) S2(7) + + dstr[(15 - i)*nch] = mp3d_scale_pcm(a[1]); + dstr[(17 + i)*nch] = mp3d_scale_pcm(b[1]); + dstl[(15 - i)*nch] = mp3d_scale_pcm(a[0]); + dstl[(17 + i)*nch] = mp3d_scale_pcm(b[0]); + dstr[(47 - i)*nch] = mp3d_scale_pcm(a[3]); + dstr[(49 + i)*nch] = mp3d_scale_pcm(b[3]); + dstl[(47 - i)*nch] = mp3d_scale_pcm(a[2]); + dstl[(49 + i)*nch] = mp3d_scale_pcm(b[2]); + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +static void mp3d_synth_granule(float *qmf_state, float *grbuf, int nbands, int nch, mp3d_sample_t *pcm, float *lins) +{ + int i; + for (i = 0; i < nch; i++) + { + mp3d_DCT_II(grbuf + 576*i, nbands); + } + + memcpy(lins, qmf_state, sizeof(float)*15*64); + + for (i = 0; i < nbands; i += 2) + { + mp3d_synth(grbuf + i, pcm + 32*nch*i, nch, lins + i*64); + } +#ifndef MINIMP3_NONSTANDARD_BUT_LOGICAL + if (nch == 1) + { + for (i = 0; i < 15*64; i += 2) + { + qmf_state[i] = lins[nbands*64 + i]; + } + } else +#endif /* MINIMP3_NONSTANDARD_BUT_LOGICAL */ + { + memcpy(qmf_state, lins + nbands*64, sizeof(float)*15*64); + } +} + +static int mp3d_match_frame(const uint8_t *hdr, int mp3_bytes, int frame_bytes) +{ + int i, nmatch; + for (i = 0, nmatch = 0; nmatch < MAX_FRAME_SYNC_MATCHES; nmatch++) + { + i += hdr_frame_bytes(hdr + i, frame_bytes) + hdr_padding(hdr + i); + if (i + HDR_SIZE > mp3_bytes) + return nmatch > 0; + if (!hdr_compare(hdr, hdr + i)) + return 0; + } + return 1; +} + +static int mp3d_find_frame(const uint8_t *mp3, int mp3_bytes, int *free_format_bytes, int *ptr_frame_bytes) +{ + int i, k; + for (i = 0; i < mp3_bytes - HDR_SIZE; i++, mp3++) + { + if (hdr_valid(mp3)) + { + int frame_bytes = hdr_frame_bytes(mp3, *free_format_bytes); + int frame_and_padding = frame_bytes + hdr_padding(mp3); + + for (k = HDR_SIZE; !frame_bytes && k < MAX_FREE_FORMAT_FRAME_SIZE && i + 2*k < mp3_bytes - HDR_SIZE; k++) + { + if (hdr_compare(mp3, mp3 + k)) + { + int fb = k - hdr_padding(mp3); + int nextfb = fb + hdr_padding(mp3 + k); + if (i + k + nextfb + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + k + nextfb)) + continue; + frame_and_padding = k; + frame_bytes = fb; + *free_format_bytes = fb; + } + } + if ((frame_bytes && i + frame_and_padding <= mp3_bytes && + mp3d_match_frame(mp3, mp3_bytes - i, frame_bytes)) || + (!i && frame_and_padding == mp3_bytes)) + { + *ptr_frame_bytes = frame_and_padding; + return i; + } + *free_format_bytes = 0; + } + } + *ptr_frame_bytes = 0; + return mp3_bytes; +} + +void mp3dec_init(mp3dec_t *dec) +{ + dec->header[0] = 0; +} + +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info) +{ + int i = 0, igr, frame_size = 0, success = 1; + const uint8_t *hdr; + bs_t bs_frame[1]; +#ifdef MINIMP3_SCRATCH +#define scratch (*MINIMP3_SCRATCH) +#else + mp3dec_scratch_t scratch; +#endif + + if (mp3_bytes > 4 && dec->header[0] == 0xff && hdr_compare(dec->header, mp3)) + { + frame_size = hdr_frame_bytes(mp3, dec->free_format_bytes) + hdr_padding(mp3); + if (frame_size != mp3_bytes && (frame_size + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + frame_size))) + { + frame_size = 0; + } + } + if (!frame_size) + { + memset(dec, 0, sizeof(mp3dec_t)); + i = mp3d_find_frame(mp3, mp3_bytes, &dec->free_format_bytes, &frame_size); + if (!frame_size || i + frame_size > mp3_bytes) + { + info->frame_bytes = i; + return 0; + } + } + + hdr = mp3 + i; + memcpy(dec->header, hdr, HDR_SIZE); + info->frame_bytes = i + frame_size; + info->frame_offset = i; + info->channels = HDR_IS_MONO(hdr) ? 1 : 2; + info->hz = hdr_sample_rate_hz(hdr); + info->layer = 4 - HDR_GET_LAYER(hdr); + info->bitrate_kbps = hdr_bitrate_kbps(hdr); + + if (!pcm) + { + return hdr_frame_samples(hdr); + } + + bs_init(bs_frame, hdr + HDR_SIZE, frame_size - HDR_SIZE); + if (HDR_IS_CRC(hdr)) + { + get_bits(bs_frame, 16); + } + + if (info->layer == 3) + { + int main_data_begin = L3_read_side_info(bs_frame, scratch.gr_info, hdr); + if (main_data_begin < 0 || bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + success = L3_restore_reservoir(dec, bs_frame, &scratch, main_data_begin); + if (success) + { + for (igr = 0; igr < (HDR_TEST_MPEG1(hdr) ? 2 : 1); igr++, pcm += 576*info->channels) + { + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + L3_decode(dec, &scratch, scratch.gr_info + igr*info->channels, info->channels); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 18, info->channels, pcm, scratch.syn[0]); + } + } + L3_save_reservoir(dec, &scratch); + } else + { +#ifdef MINIMP3_ONLY_MP3 + return 0; +#else /* MINIMP3_ONLY_MP3 */ + L12_scale_info sci[1]; + L12_read_scale_info(hdr, bs_frame, sci); + + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + for (i = 0, igr = 0; igr < 3; igr++) + { + if (12 == (i += L12_dequantize_granule(scratch.grbuf[0] + i, bs_frame, sci, info->layer | 1))) + { + i = 0; + L12_apply_scf_384(sci, sci->scf + igr, scratch.grbuf[0]); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 12, info->channels, pcm, scratch.syn[0]); + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + pcm += 384*info->channels; + } + if (bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + } +#endif /* MINIMP3_ONLY_MP3 */ + } +#ifdef MINIMP3_SCRATCH +#undef scratch +#endif + return success*hdr_frame_samples(dec->header); +} + +#ifdef MINIMP3_FLOAT_OUTPUT +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples) +{ + int i = 0; +#if HAVE_SIMD + int aligned_count = num_samples & ~7; + for(; i < aligned_count; i += 8) + { + static const f4 g_scale = { 32768.0f, 32768.0f, 32768.0f, 32768.0f }; + f4 a = VMUL(VLD(&in[i ]), g_scale); + f4 b = VMUL(VLD(&in[i+4]), g_scale); +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + out[i ] = _mm_extract_epi16(pcm8, 0); + out[i+1] = _mm_extract_epi16(pcm8, 1); + out[i+2] = _mm_extract_epi16(pcm8, 2); + out[i+3] = _mm_extract_epi16(pcm8, 3); + out[i+4] = _mm_extract_epi16(pcm8, 4); + out[i+5] = _mm_extract_epi16(pcm8, 5); + out[i+6] = _mm_extract_epi16(pcm8, 6); + out[i+7] = _mm_extract_epi16(pcm8, 7); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(out+i , pcma, 0); + vst1_lane_s16(out+i+1, pcma, 1); + vst1_lane_s16(out+i+2, pcma, 2); + vst1_lane_s16(out+i+3, pcma, 3); + vst1_lane_s16(out+i+4, pcmb, 0); + vst1_lane_s16(out+i+5, pcmb, 1); + vst1_lane_s16(out+i+6, pcmb, 2); + vst1_lane_s16(out+i+7, pcmb, 3); +#endif /* HAVE_SSE */ + } +#endif /* HAVE_SIMD */ + for(; i < num_samples; i++) + { + float sample = in[i] * 32768.0f; + if (sample >= 32766.5) + out[i] = (int16_t) 32767; + else if (sample <= -32767.5) + out[i] = (int16_t)-32768; + else + { + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ + out[i] = s; + } + } +} +#endif /* MINIMP3_FLOAT_OUTPUT */ +#endif /* MINIMP3_IMPLEMENTATION && !_MINIMP3_IMPLEMENTATION_GUARD */ diff --git a/scripts/audio_test.lua b/scripts/audio_test.lua new file mode 100644 index 0000000..c539d3a --- /dev/null +++ b/scripts/audio_test.lua @@ -0,0 +1,104 @@ +-- Manual WAV/MP3 transport test for wada.audio. +-- SD test file: /Music/test.mp3 +-- App-local file: /apps/audio_test.d/test.mp3 on the active app storage. +local ui, sys, timer = wada.ui, wada.sys, wada.timer +local audio = wada.audio +local C = ui.colors + +local SD_PATH = "sd:/Music/test.mp3" +local APP_PATH = "test.mp3" + +local app = {} +local source = APP_PATH +local source_line, state_line, detail_line, command_line + +local function show_command(ok, err) + command_line:set(ok and "Command: accepted" or ("Command: " .. tostring(err or "rejected"))) + command_line:color(ok and C.good or C.bad) +end + +local function refresh() + if not audio then return end + local status = audio.status() + state_line:set("State: " .. tostring(status.state)) + state_line:color(status.state == "error" and C.bad or + status.state == "playing" and C.good or C.text) + local detail = "Source: " .. tostring(status.source or "-") .. + " Format: " .. tostring(status.format or "-") + if status.error then detail = detail .. " Error: " .. tostring(status.error) end + detail_line:set(detail) + detail_line:color(status.error and C.bad or C.sub) +end + +function app.on_open(w, h) + local caps = sys.caps() + source = APP_PATH + if caps.audio_sd and wada.sd and wada.sd.list then + local entries = wada.sd.list("/") + if entries then source = SD_PATH end + end + + ui.label("Audio playback test", 6, 4, 14, C.accent) + ui.label("audio=" .. tostring(caps.audio) .. + " wav=" .. tostring(caps.audio_wav) .. + " mp3=" .. tostring(caps.audio_mp3), 6, 24, 12, C.sub) + source_line = ui.label("File: " .. source, 6, 44, 12, C.text) + source_line:width(w - 12) + state_line = ui.label("State: stopped", 6, 64, 12, C.text) + detail_line = ui.label("Source: - Format: -", 6, 82, 12, C.sub) + detail_line:width(w - 12) + command_line = ui.label("Command: ready", 6, 100, 12, C.sub) + command_line:width(w - 12) + + if not caps.audio or not audio then + command_line:set("Command: wada.audio unavailable") + command_line:color(C.bad) + return + end + + local gap = 5 + local button_w = math.max(54, (w - 12 - gap * 3) // 4) + local x = 6 + ui.button("Play", x, 116, button_w, 30, function() + local ok, err = audio.play(source) + show_command(ok, err) + refresh() + end) + x = x + button_w + gap + ui.button("Pause", x, 116, button_w, 30, function() + show_command(audio.pause()) + refresh() + end) + x = x + button_w + gap + ui.button("Resume", x, 116, button_w, 30, function() + show_command(audio.resume()) + refresh() + end) + x = x + button_w + gap + ui.button("Stop", x, 116, button_w, 30, function() + show_command(audio.stop()) + refresh() + end) + + if caps.audio_sd then + ui.button("Switch SD / app storage", 6, 150, math.min(w - 12, 190), 26, function() + source = source == SD_PATH and APP_PATH or SD_PATH + source_line:set("File: " .. source) + command_line:set("Command: source changed") + command_line:color(C.sub) + end) + end + + timer.every(200) + refresh() +end + +function app.on_tick() + refresh() +end + +function app.on_close() + if audio then audio.stop() end +end + +return app diff --git a/scripts/lua-harness/harness.lua b/scripts/lua-harness/harness.lua index dc7adc2..de97db2 100644 --- a/scripts/lua-harness/harness.lua +++ b/scripts/lua-harness/harness.lua @@ -26,6 +26,13 @@ local cfg = {} -- per-scenario device config local widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil } local drawlog = { text = {}, circles = {}, ops = 0 } local storekv = {} +local audio_state = { state = "stopped", path = "", source = "", format = "", error = nil } +local audio_log = {} + +local function audio_host_close() + audio_state.state = "stopped" + audio_log[#audio_log + 1] = "release" +end local function mkcanvas(w, h) checkint(w, "canvas w"); checkint(h, "canvas h") @@ -102,7 +109,11 @@ 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, sd_list = cfg.caps.sd_list or false, + audio = cfg.caps.audio or false, + audio_wav = cfg.caps.audio or false, + audio_mp3 = cfg.caps.audio or false, + audio_sd = cfg.caps.audio_sd or false } 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 @@ -126,6 +137,56 @@ local function build_wada() wada.timer.every = function(ms) checkint(ms, "timer.every"); if ms < 33 then ms = 33 end; widgets.timer_ms = ms end wada.timer.stop = function() widgets.timer_ms = nil end + + if cfg.caps.audio then + wada.audio = {} + wada.audio.play = function(path) + assert(type(path) == "string", "audio.play path must be a string") + local source = "app" + if path:sub(1, 3) == "sd:" then + if not cfg.caps.audio_sd then return nil, "no sd" end + local card_path = path:sub(4) + if card_path:sub(1, 1) ~= "/" or card_path:find("//", 1, true) or + card_path:find("/../", 1, true) or card_path:sub(-3) == "/.." or + card_path:sub(-2) == "/." or card_path:sub(-1) == "/" then + return nil, "bad path" + end + source = "sd" + elseif #path == 0 or #path > 32 or path:sub(1, 1) == "." or + not path:match("^[A-Za-z0-9._-]+$") then + return nil, "bad path" + end + local format = path:lower():sub(-4) + if format ~= ".wav" and format ~= ".mp3" then return nil, "unsupported format" end + audio_state = { state = "playing", path = path, source = source, + format = format:sub(2), error = nil } + audio_log[#audio_log + 1] = "play:" .. path + return true + end + wada.audio.pause = function() + if audio_state.state ~= "playing" then return false end + audio_state.state = "paused"; audio_log[#audio_log + 1] = "pause"; return true + end + wada.audio.resume = function() + if audio_state.state ~= "paused" then return false end + audio_state.state = "playing"; audio_log[#audio_log + 1] = "resume"; return true + end + wada.audio.stop = function() + if audio_state.state ~= "playing" and audio_state.state ~= "paused" then return false end + audio_state.state = "stopped"; audio_log[#audio_log + 1] = "stop"; return true + end + wada.audio.status = function() + local out = {} + for key, value in pairs(audio_state) do out[key] = value end + return out + end + end + if cfg.caps.sd_list then + wada.sd = { list = function(path) + assert(path == "/", "mock SD only exposes the root") + return {} + end } + end return wada end @@ -159,6 +220,8 @@ local function reset_world() widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil } labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 } clock_ms = 1000 + audio_state = { state = "stopped", path = "", source = "", format = "", error = nil } + audio_log = {} end -- simulated magnetometer: Earth field 0.45 G at true heading `deg` (device frame == @@ -683,6 +746,61 @@ scenarios.tanmatsu = function() if app.on_close then guarded(BUDGET, app.on_close) end end +scenarios.audio_api = function() + cfg = { w = 320, h = 196, + caps = { sdk_ext = true, keyboard = true, touch = false, + audio = true, audio_sd = true, sd_list = true } } + wada = build_wada() + local caps = wada.sys.caps() + assert(caps.audio and caps.audio_wav and caps.audio_mp3 and caps.audio_sd, + "audio capability flags do not match the WAV/MP3 contract") + assert(wada.audio and wada.audio.play and wada.audio.pause and wada.audio.resume and + wada.audio.stop and wada.audio.status, "wada.audio API is incomplete") + + assert(wada.audio.play("track.wav")) + local status = wada.audio.status() + assert(status.state == "playing" and status.path == "track.wav" and + status.source == "app" and status.format == "wav", "app-storage play status is wrong") + assert(wada.audio.pause() and wada.audio.status().state == "paused", "pause failed") + assert(wada.audio.resume() and wada.audio.status().state == "playing", "resume failed") + assert(wada.audio.play("next.wav") and wada.audio.status().path == "next.wav", + "a second play must replace the active track") + assert(wada.audio.play("sd:/Music/card.wav")) + assert(wada.audio.status().source == "sd", "sd: source was not reported") + + local value, err = wada.audio.play("../escape.wav") + assert(value == nil and err == "bad path", "app sandbox traversal was accepted") + value, err = wada.audio.play("sd:/Music/../escape.wav") + assert(value == nil and err == "bad path", "SD traversal was accepted") + assert(wada.audio.play("track.mp3") and wada.audio.status().format == "mp3", + "MP3 playback was not accepted or reported") + value, err = wada.audio.play("track.flac") + assert(value == nil and err == "unsupported format", "unknown audio format was accepted") + + assert(wada.audio.stop() and wada.audio.status().state == "stopped", "stop failed") + assert(wada.audio.play("close.wav")) + audio_host_close() + assert(wada.audio.status().state == "stopped" and audio_log[#audio_log] == "release", + "host close did not release playback") + print(" transport + storage sandbox: PASS (" .. #audio_log .. " host calls)") + + if APP_PATH:match("audio_test%.lua$") then + local app = load_app() + assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) + assert(#buttons == 5, "audio test app did not create all transport/source buttons") + buttons[1].fn(); assert(wada.audio.status().state == "playing", "Play button failed") + buttons[2].fn(); assert(wada.audio.status().state == "paused", "Pause button failed") + buttons[3].fn(); assert(wada.audio.status().state == "playing", "Resume button failed") + buttons[4].fn(); assert(wada.audio.status().state == "stopped", "Stop button failed") + buttons[5].fn(); buttons[1].fn() + assert(wada.audio.status().source == "app", "source switch did not select app storage") + guarded(BUDGET, app.on_tick) + guarded(BUDGET, app.on_close) + assert(wada.audio.status().state == "stopped", "test app close did not stop playback") + print(" scripts/audio_test.lua UI: PASS") + end +end + -- instruction cost of one heavy tick (redraw forced every tick by spinning the heading) scenarios.cost = function() cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true }, @@ -711,7 +829,7 @@ 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 = { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" } for _, name in ipairs(order) do if SCENARIO == "all" or SCENARIO == name then print("== " .. name) diff --git a/scripts/sideload_app.py b/scripts/sideload_app.py index c74525e..3b29497 100644 --- a/scripts/sideload_app.py +++ b/scripts/sideload_app.py @@ -22,6 +22,7 @@ Usage: scripts/sideload_app.py --port /dev/cu.wchusbserial10 deploy/apps/gpscompass/1.0 scripts/sideload_app.py --port ... --reboot deploy/apps/gpscompass/1.0/gpscompass.lua scripts/sideload_app.py --port ... --dest /lang deploy/apps/lang/11/de.lang + scripts/sideload_app.py --port ... --remote /apps/audio_test.d/test.mp3 ~/Music/test.mp3 Pass an app VERSION directory to send its .lua + .json, or individual files. --reboot restarts the device afterwards so the drawer/Store rescan @@ -152,6 +153,7 @@ def main(): ap.add_argument("--port", required=True) ap.add_argument("--baud", type=int, default=115200) ap.add_argument("--dest", default="/apps", help="/apps (default) or /lang") + ap.add_argument("--remote", help="exact remote path for one file, including /apps/.d/") ap.add_argument("--reboot", action="store_true", help="reboot the device afterwards") args = ap.parse_args() @@ -170,10 +172,19 @@ def main(): else: sys.exit("not found: " + p) + if args.remote: + if len(files) != 1: + sys.exit("--remote requires exactly one input file") + if not args.remote.startswith(("/apps/", "/lang/")): + sys.exit("--remote must begin with /apps/ or /lang/") + s = open_port(args.port, args.baud) try: - for f in files: - push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f))) + if args.remote: + push(s, files[0], args.remote) + else: + for f in files: + push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f))) if args.reboot: s.write(b"reboot\n") s.flush() diff --git a/src/DataStore.cpp b/src/DataStore.cpp index 4919f10..cd698b8 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -230,6 +230,13 @@ bool DataStore::mkdirRooted(FILESYSTEM* fs, const char* dir) { return fs->exists(p) || fs->mkdir(p); } +File DataStore::openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename) { + // Do not pass create=true here. Arduino's VFS implementation interprets it + // as "mkdir every parent first", but SPIFFS is flat and returns ENOTSUP for + // mkdir while still accepting slash-containing file keys. + return fs->open(_rp(filename), "w"); +} + bool DataStore::removeRooted(FILESYSTEM* fs, const char* filename) { return fs->remove(_rp(filename)); } diff --git a/src/DataStore.h b/src/DataStore.h index 0a547c3..539f8e9 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -109,6 +109,7 @@ public: File openAppend(FILESYSTEM* fs, const char* filename); // create if missing bool fileExists(FILESYSTEM* fs, const char* filename); bool mkdirRooted(FILESYSTEM* fs, const char* dir); // true if it exists afterwards + File openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename); // SPIFFS accepts slash keys but no mkdir bool removeRooted(FILESYSTEM* fs, const char* filename); bool renameFile(FILESYSTEM* fs, const char* from, const char* to); #endif diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index 8f482cc..b769e62 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -5445,7 +5445,7 @@ void MyMesh::checkCLIRescueCmd() { // opens the file, "fadd " lines append to it // (self-checking, see cliPutChunk), "fend" closes it. // Same physical-access trust level as "rm" and "erase" above, with a - // NARROWER scope: only the two sideload directories the Store writes. + // NARROWER scope: Store files plus one app's private .d directory. cliPutBegin(&cli_command[5]); } else if (memcmp(cli_command, "fadd ", 5) == 0) { cliPutChunk(&cli_command[5]); @@ -5470,24 +5470,51 @@ void MyMesh::checkCLIRescueCmd() { // "Error: ...". void MyMesh::cliPutBegin(const char* path) { if (_cli_put) { _cli_put.close(); _cli_put_len = 0; } + _cli_put_ended = false; const char* dir = nullptr; if (memcmp(path, "/apps/", 6) == 0) dir = "/apps"; else if (memcmp(path, "/lang/", 6) == 0) dir = "/lang"; if (!dir) { Serial.println("Error: path must be /apps/ or /lang/"); return; } const char* name = path + 6; - const size_t nlen = strlen(name); - if (nlen == 0 || nlen > 40) { Serial.println("Error: bad file name length"); return; } - for (size_t i = 0; i < nlen; i++) { - const char c = name[i]; - const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '.' || c == '_' || c == '-'; + const char* slash = strchr(name, '/'); + const char* leaf = slash ? slash + 1 : name; + const size_t dir_len = slash ? (size_t)(slash - name) : 0; + const size_t leaf_len = strlen(leaf); + if (!leaf_len || leaf_len > 40 || (slash && leaf_len > 32) || + leaf[0] == '.' || strchr(leaf, '/')) { + Serial.println("Error: bad file name"); + return; + } + for (size_t i = 0; i < leaf_len; i++) { + const char c = leaf[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; if (!ok) { Serial.println("Error: file name may only use A-Z a-z 0-9 . _ -"); return; } } - if (name[0] == '.') { Serial.println("Error: file name may not start with '.'"); return; } + + char app_data_dir[48] = ""; + if (slash) { + // The only nested destination is /apps/.d/, matching wada.fs. + if (strcmp(dir, "/apps") != 0 || dir_len < 3 || dir_len > 25 || + name[dir_len - 2] != '.' || name[dir_len - 1] != 'd' || name[0] == '.') { + Serial.println("Error: nested path must be /apps/.d/"); + return; + } + for (size_t i = 0; i + 2 < dir_len; i++) { + const char c = name[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) { Serial.println("Error: bad app id"); return; } + } + snprintf(app_data_dir, sizeof app_data_dir, "/apps/%.*s", (int)dir_len, name); + } FILESYSTEM* fs = _store->getHotDataFS(); if (!fs) { Serial.println("Error: storage not ready"); return; } - if (!_store->mkdirRooted(fs, dir)) { Serial.printf("Error: cannot create %s\n", dir); return; } - _cli_put = _store->openWrite(fs, path); + // Hierarchical filesystems create these directories. SPIFFS returns ENOTSUP + // but accepts the complete slash-containing path as a flat file key. + _store->mkdirRooted(fs, dir); + if (app_data_dir[0]) _store->mkdirRooted(fs, app_data_dir); + _cli_put = _store->openWriteRootedFlatSafe(fs, path); if (!_cli_put) { Serial.printf("Error: cannot open %s for writing\n", path); return; } _cli_put_len = 0; Serial.printf("ok fput %s\n", path); @@ -5518,7 +5545,7 @@ void MyMesh::cliPutChunk(const char* args) { if ((s & 0xFFFF) != (sum & 0xFFFF)) { Serial.printf("Error: checksum at %u\n", (unsigned)_cli_put_len); return; } if (olen && _cli_put.write(buf, olen) != olen) { Serial.println("Error: write failed (card full?)"); - _cli_put.close(); _cli_put_len = 0; + _cli_put.close(); _cli_put_len = 0; _cli_put_ended = false; return; } _cli_put_len += olen; @@ -5526,9 +5553,15 @@ void MyMesh::cliPutChunk(const char* args) { } void MyMesh::cliPutEnd() { - if (!_cli_put) { Serial.println("Error: no file open (fput first)"); return; } + if (!_cli_put) { + if (_cli_put_ended) Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len); + else Serial.println("Error: no file open (fput first)"); + return; + } _cli_put.close(); - Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_len); + _cli_put_last_len = _cli_put_len; + _cli_put_ended = true; + Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len); _cli_put_len = 0; } #endif // ESP32 diff --git a/src/MyMesh.h b/src/MyMesh.h index 4fb8a51..ad2118b 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -1296,6 +1296,8 @@ private: // Serial sideload state ("fput" / "fadd" / "fend"): the file being written. File _cli_put; uint32_t _cli_put_len = 0; + uint32_t _cli_put_last_len = 0; + bool _cli_put_ended = false; void cliPutBegin(const char* path); void cliPutChunk(const char* args); void cliPutEnd(); diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index dd51781..f30c20a 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -35,6 +35,18 @@ extern bool luaHostScreenOn(); // false = display asleep; app tick extern void luaHostKeepAwake(bool on); // hold the screen + ticks for a measuring app // notification chime; false = no sounder / muted extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null) extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root +#if CAP_LUA_AUDIO +extern bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, + const char* source, uint32_t owner, + char* error, size_t error_cap); +extern bool luaHostAudioPause(uint32_t owner, bool pause); +extern bool luaHostAudioStop(uint32_t owner, bool release); +extern void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap, + char* path, size_t path_cap, + char* source, size_t source_cap, + char* format, size_t format_cap, + char* error, size_t error_cap); +#endif #if CAP_LUA_SD_LIST extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null extern bool luaHostSdReadFailed(); // failed open was a dead card @@ -184,10 +196,12 @@ struct Host { AppTimer timers[kMaxTimers]; bool in_lua = false; // re-entrancy guard (dismiss from inside a callback) bool want_close = false; + uint32_t generation = 0; // resource ownership across close/reopen char id[24] = ""; char title[32] = ""; }; Host* s_h = nullptr; +uint32_t s_host_generation = 0; char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page @@ -764,12 +778,17 @@ int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; } // rather than assume: the extended SDK is absent on low-resource boards (see // CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them. int sysCaps(lua_State* L) { - lua_createtable(L, 0, 14); + lua_createtable(L, 0, 20); lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext"); lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard"); lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch"); lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd"); lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_list"); + lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio"); + lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_wav"); + lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_mp3"); + lua_pushboolean(L, CAP_LUA_AUDIO && CAP_LUA_SD_LIST); + lua_setfield(L, -2, "audio_sd"); // Feature flags for the calls added after the first extended SDK shipped, so // an app can degrade instead of erroring on firmware that predates them. lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "discover"); // wada.mesh.discover @@ -1563,6 +1582,93 @@ int sdList(lua_State* L) { #endif #endif // CAP_LUA_SDK_EXT +#if CAP_LUA_AUDIO +static bool audioSafeName(const char* name, size_t len) { + if (!name || len == 0 || len > 32 || name[0] == '.') return false; + for (size_t i = 0; i < len; ++i) { + const char c = name[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!ok) return false; + } + return true; +} + +static int audioError(lua_State* L, const char* error) { + lua_pushnil(L); + lua_pushstring(L, error); + return 2; +} + +// wada.audio.play(name) resolves inside /apps/.d on the board's active +// storage backend. An explicit sd:/... path is available only with sd_list. +int audioPlay(lua_State* L) { + if (!s_h) return audioError(L, "closed"); + size_t requested_len = 0; + const char* requested = luaL_checklstring(L, 1, &requested_len); + fs::FS* fs = nullptr; + char path[224] = ""; + const char* source = "app"; + + if (requested_len >= 3 && !memcmp(requested, "sd:", 3)) { +#if CAP_LUA_SD_LIST + const char* card_path = requested + 3; + const size_t card_len = requested_len - 3; + if (!sdSafePath(card_path, card_len, path, sizeof path)) return audioError(L, "bad path"); + bool busy = false; + fs = luaHostSdFs(&busy); + if (!fs) return audioError(L, busy ? "busy" : "no sd"); + source = "sd"; +#else + return audioError(L, "no sd"); +#endif + } else { + if (!audioSafeName(requested, requested_len)) return audioError(L, "bad path"); + fs = luaHostAppFs(); + if (!fs) return audioError(L, "no storage"); + char rel[96]; + snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, requested); + luaHostAppPath(path, sizeof path, rel); + } + + char error[40] = ""; + if (!luaHostAudioPlay(fs, path, requested, source, s_h->generation, error, sizeof error)) + return audioError(L, error[0] ? error : "playback failed"); + lua_pushboolean(L, 1); + return 1; +} + +int audioPause(lua_State* L) { + lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, true)); + return 1; +} + +int audioResume(lua_State* L) { + lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, false)); + return 1; +} + +int audioStop(lua_State* L) { + lua_pushboolean(L, s_h && luaHostAudioStop(s_h->generation, false)); + return 1; +} + +int audioStatus(lua_State* L) { + char state[12], path[192], source[8], format[8], error[40]; + luaHostAudioStatus(s_h ? s_h->generation : 0, + state, sizeof state, path, sizeof path, + source, sizeof source, format, sizeof format, + error, sizeof error); + lua_createtable(L, 0, 5); + lua_pushstring(L, state); lua_setfield(L, -2, "state"); + lua_pushstring(L, path); lua_setfield(L, -2, "path"); + lua_pushstring(L, source); lua_setfield(L, -2, "source"); + lua_pushstring(L, format); lua_setfield(L, -2, "format"); + if (error[0]) { lua_pushstring(L, error); lua_setfield(L, -2, "error"); } + return 1; +} +#endif + void storePath(char* out, size_t cap) { char rel[40]; snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id); @@ -2172,6 +2278,16 @@ void openWada(lua_State* L) { lua_setfield(L, -2, "sd"); #endif +#if CAP_LUA_AUDIO + lua_newtable(L); // wada.audio (app storage + optional SD) + lua_pushcfunction(L, audioPlay); lua_setfield(L, -2, "play"); + lua_pushcfunction(L, audioPause); lua_setfield(L, -2, "pause"); + lua_pushcfunction(L, audioResume); lua_setfield(L, -2, "resume"); + lua_pushcfunction(L, audioStop); lua_setfield(L, -2, "stop"); + lua_pushcfunction(L, audioStatus); lua_setfield(L, -2, "status"); + lua_setfield(L, -2, "audio"); +#endif + lua_newtable(L); // wada.mesh (read-only) lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts"); lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log"); @@ -2397,6 +2513,9 @@ void hostTeardown() { Host* h = s_h; if (!h) return; s_h = nullptr; // bindings see "closed" from here on +#if CAP_LUA_AUDIO + luaHostAudioStop(h->generation, true); +#endif // A wada.ui.input dialog lives on lv_layer_top, so it would outlive the app // that opened it. Drop it before the state goes, not after. luaHostTextPromptDismiss(); @@ -2465,6 +2584,8 @@ bool luaAppLaunch(const char* id, const char* title, const char* src, size_t len Host* h = new Host(); h->heap.cap = kHeapCap; + h->generation = ++s_host_generation; + if (!h->generation) h->generation = ++s_host_generation; snprintf(h->id, sizeof h->id, "%s", id); snprintf(h->title, sizeof h->title, "%s", title ? title : id); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..de0c913 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -19,6 +19,20 @@ #include #include #include // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO) +#if CAP_LUA_AUDIO +static void* wadaMp3Scratch(); +#define MINIMP3_ONLY_MP3 +#define MINIMP3_NO_SIMD +#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)wadaMp3Scratch()) +#define MINIMP3_IMPLEMENTATION +#include "../../lib/minimp3/minimp3.h" +#undef MINIMP3_IMPLEMENTATION +#undef MINIMP3_SCRATCH +#undef MINIMP3_NO_SIMD +#undef MINIMP3_ONLY_MP3 +static void* s_wada_mp3_scratch = nullptr; +static void* wadaMp3Scratch() { return s_wada_mp3_scratch; } +#endif #if defined(ESP32) #include #include @@ -52,6 +66,7 @@ #include // detect/read/erase the panic coredump for the crash-report export #include #include // xTaskGetCurrentTaskHandleForCPU / pcTaskGetName — Task-WDT crash self-record + #include #else // Tanmatsu's 16M.csv has no coredump partition yet — stub so the crash-export compiles + links. #include @@ -832,6 +847,56 @@ static inline void tileFetchPendingDec() { // below are shared; T-Deck and the pager each get their own I2S install/ // tone/WAV functions, since the pager additionally drives an ES8311 codec // over I2C that the T-Deck's plain MAX98357A DAC doesn't have. +#if CAP_AUDIO_STREAM +static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); } +static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); } +static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) { + char tag[4]; + if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false; + const uint32_t riff_size = wavRd32(f); + const uint64_t riff_end = 8u + (uint64_t)riff_size; + if (riff_size < 4 || riff_end > f.size() || riff_end > 0xFFFFFFFFu) return false; + if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false; + uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false; + while ((uint64_t)f.position() + 8u <= riff_end) { + if (f.read((uint8_t*)tag,4)!=4) break; + uint32_t csz = wavRd32(f); + if (!memcmp(tag,"fmt ",4)) { + if (csz < 16) return false; + fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f); + const uint64_t skip = (uint64_t)(csz - 16) + (csz & 1u); + const uint64_t next = (uint64_t)f.position() + skip; + if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false; + hf = true; + } else if (!memcmp(tag,"data",4)) { + if ((uint64_t)f.position() + csz > riff_end) return false; + dlen = csz; hd = true; break; + } else { + const uint64_t skip = (uint64_t)csz + (csz & 1u); + const uint64_t next = (uint64_t)f.position() + skip; + if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false; + } + } + if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000) + return false; + if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen; + return true; +} +#endif + +#if CAP_LUA_AUDIO +static volatile bool s_lua_audio_active = false; +static volatile uint32_t s_lua_audio_storage_pending = 0; +static volatile bool s_lua_audio_storage_active = false; +static inline bool luaAudioActive() { + return __atomic_load_n(&s_lua_audio_active, __ATOMIC_ACQUIRE); +} +static inline bool luaAudioStorageBusy() { + return __atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0 || + __atomic_load_n(&s_lua_audio_storage_active, __ATOMIC_ACQUIRE); +} +#endif + #if defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9) static bool fmSdTryMount(); // V4-R8/M9 microSD — fwd decl (defined in the mount-helper block below; sdRestoreRun needs it) #endif @@ -988,29 +1053,6 @@ static void pagerPlayToneRaw(int freq, int ms) { // amp. Supports PCM 16-bit, mono or stereo (downmixed to the mono amp), sample // rate read from the header; capped to ~6 s so a stray big file can't hold the // notify task. Any problem -> false, and the caller falls back to the chime. -static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); } -static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); } -static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) { - char tag[4]; - if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false; - wavRd32(f); - if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false; - uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false; - while (f.available() >= 8) { - if (f.read((uint8_t*)tag,4)!=4) break; - uint32_t csz = wavRd32(f); - if (!memcmp(tag,"fmt ",4)) { - fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f); - if (csz > 16) f.seek(f.position() + (csz - 16)); - hf = true; - } else if (!memcmp(tag,"data",4)) { dlen = csz; hd = true; break; } - else f.seek(f.position() + csz + (csz & 1)); - } - if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000) - return false; - if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen; - return true; -} static bool wavOpen(const char* prefpath, File& f) { if (!prefpath || !prefpath[0]) return false; fs::FS* fsp = &SPIFFS; const char* fp = prefpath; @@ -1302,6 +1344,9 @@ static void tanBeep(); // I2S notification tick; defined far below (with the C #endif // Play the platform's notification chime. Caller checks the buzzer/sound pref. static inline void uiPlaySlot(int slot) { +#if CAP_LUA_AUDIO + if (luaAudioActive()) return; +#endif #if defined(HAS_TDECK_GT911) tdeckPlayNotifySlot(slot); #elif defined(HAS_TDISPLAY_P4) @@ -1321,6 +1366,9 @@ static inline void uiPlayMention() { uiPlaySlot(TOUCH_SND_MEN); } // Preview an arbitrary WAV file (not yet saved to a slot) -- used by the // sound picker's "play" button before the user commits to a choice. static inline void uiPreviewWavFile(const char* path) { +#if CAP_LUA_AUDIO + if (luaAudioActive()) return; +#endif #if defined(HAS_TDECK_GT911) tdeckPreviewWavFile(path); #elif defined(TLORA_PAGER) @@ -20033,8 +20081,10 @@ static void fmOpenStorage(fs::FS* fs, const char* store, const char* path) { } static void fmInternalClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; -#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) - fmOpenStorage(&FFat, "Internal", "/"); // the internal FAT data partition (locfd / 'storage') +#if defined(HAS_TANMATSU) + fmOpenStorage(&FFat, "Internal", "/"); // internal FAT data partition (locfd) +#elif defined(HAS_TDISPLAY_P4) + fmOpenStorage(&LittleFS, "Internal", "/"); // internal LittleFS 'storage' partition #else fmOpenStorage(&SPIFFS, "Internal", "/"); #endif @@ -21552,6 +21602,9 @@ static uint64_t s_tan_sd_size = 0; static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render static bool tanSdTryMount() { if (s_tan_sd_mounted) return true; +#if CAP_LUA_AUDIO + if (luaAudioStorageBusy()) return false; +#endif if (millis() < s_tan_sd_retry_after) return false; #if defined(HAS_TDISPLAY_P4) // Hot-insert path (no-op when main.cpp already mounted at boot): 20 MHz like the boot ladder, @@ -39611,6 +39664,11 @@ static void tanKbBacklightTick(bool off = false) { if (v != s_last) { s_last = v; bsp_input_set_backlight_brightness(v); } } static uint8_t s_volume_pct = 70; +static SemaphoreHandle_t s_tan_audio_mutex = nullptr; +static SemaphoreHandle_t tanAudioMutex() { + if (!s_tan_audio_mutex) s_tan_audio_mutex = xSemaphoreCreateMutex(); + return s_tan_audio_mutex; +} // The audio subsystem (ES8156 codec + I2S) is brought up by bsp_device_initialize() // at boot — so here we only set the codec volume and toggle the speaker amplifier. static void applyVolume(uint8_t pct) { @@ -39627,11 +39685,13 @@ static void applyVolume(uint8_t pct) { // which leaves every descriptor zeroed once played out, and the tone stops cleanly. static void tanBeep() { if (s_volume_pct == 0) return; + SemaphoreHandle_t mutex = tanAudioMutex(); + if (!mutex || xSemaphoreTake(mutex, 0) != pdTRUE) return; static uint32_t s_last_beep = 0; // throttle: holding the slider ramps fast, but - if (millis() - s_last_beep < 200) return; // don't machine-gun a tone on every repeat step + if (millis() - s_last_beep < 200) { xSemaphoreGive(mutex); return; } // don't machine-gun a tone on every repeat step s_last_beep = millis(); i2s_chan_handle_t h = nullptr; - if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) return; + if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) { xSemaphoreGive(mutex); return; } const int rate = 44100, freq = 880; const int tone = rate * 30 / 1000; // ~30 ms tone … const int total = tone + rate * 50 / 1000; // … then ~50 ms silence (> the DMA ring) flushes the loop @@ -39650,6 +39710,7 @@ static void tanBeep() { } size_t wr = 0; i2s_channel_write(h, buf, (size_t)n * 2 * sizeof(int16_t), &wr, 200 / portTICK_PERIOD_MS); + xSemaphoreGive(mutex); } #elif defined(HAS_TDISPLAY_P4) // T-Display P4: the RM69A10 AMOLED has no backlight — brightness is the panel's own DCS @@ -47071,18 +47132,22 @@ static bool uiDataFsReady() { } #endif #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) - // Tanmatsu + T-Display P4: prefer the microSD card. On the Tanmatsu the internal FFat 'locfd' - // loses frequently-rewritten data (broken FAT metadata; see the tile-cache notes); on the - // T-Display P4 SD-first keeps history on the same medium the DataStore adopts. Mirror the - // T-Deck's /meshcomod root; fall back to FFat only when no card is present. Both are mounted at - // boot in main.cpp (g_sd_ok / g_fs_ok). (The P4 used to fall into the #else SPIFFS branch below — - // no SPIFFS partition exists there, so history was never persisted and vanished on every reboot.) + // Tanmatsu + T-Display P4: use each board's mounted internal data partition + // (Tanmatsu FFat 'locfd', P4 LittleFS 'storage'), with SD_MMC as fallback. // #167: hot UI data (chat history) lives on the INTERNAL LittleFS -- SD write bursts // electrically disturb this board's AMOLED, and the P4's internal FAT layer is broken // (see the storage note in tdisplay_p4/main/main.cpp). SD = degraded fallback only; // tiles keep using the SD via their own selector. extern bool g_fs_ok; - if (g_fs_ok) { s_ui_data_fs = &LittleFS; s_ui_data_root[0] = '\0'; return true; } + if (g_fs_ok) { +#if defined(HAS_TANMATSU) + s_ui_data_fs = &FFat; +#else + s_ui_data_fs = &LittleFS; +#endif + s_ui_data_root[0] = '\0'; + return true; + } extern bool g_sd_ok; if (g_sd_ok) { SD_MMC.mkdir("/meshcomod"); @@ -47209,6 +47274,752 @@ fs::FS* luaHostAppFs() { return uiDataFsReady() ? s_ui_data_fs : nullptr; } void luaHostAppPath(char* out, size_t cap, const char* rel) { snprintf(out, cap, "%s%s", s_ui_data_root, rel); // SD-rooted stores prefix /meshcomod } + +#if CAP_LUA_AUDIO +namespace { +enum class LuaAudioCommandKind : uint8_t { Play, Pause, Resume, Stop, Release }; +enum class LuaAudioState : uint8_t { Stopped, Playing, Paused, Ended, Error }; +enum class LuaAudioRunResult : uint8_t { Ended, Stopped, Replaced, Released, Error }; +enum class LuaAudioTaskState : uint8_t { Stopped, Starting, Running, Stopping }; +enum class LuaAudioFormat : uint8_t { Wav, Mp3 }; + +struct LuaAudioCommand { + LuaAudioCommandKind kind = LuaAudioCommandKind::Stop; + fs::FS* fs = nullptr; + uint32_t owner = 0; + LuaAudioFormat format = LuaAudioFormat::Wav; + char path[224] = ""; + char shown[192] = ""; + char source[8] = ""; +}; + +struct LuaAudioStatus { + uint32_t owner = 0; + LuaAudioState state = LuaAudioState::Stopped; + char path[192] = ""; + char source[8] = ""; + char format[8] = ""; + char error[40] = ""; +}; + +struct LuaAudioSinkSession { + uint32_t output_rate = 0; + float gain = 1.0f; + bool open = false; +#if defined(HAS_TANMATSU) + i2s_chan_handle_t handle = nullptr; + SemaphoreHandle_t mutex = nullptr; +#endif +}; + +class LuaAudioStorageLease { + public: + LuaAudioStorageLease() { + __atomic_store_n(&s_lua_audio_storage_active, true, __ATOMIC_RELEASE); + if (__atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0) + __atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); + } + ~LuaAudioStorageLease() { + __atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE); + } +}; + +static QueueHandle_t s_lua_audio_queue = nullptr; +static TaskHandle_t s_lua_audio_task = nullptr; +static LuaAudioTaskState s_lua_audio_task_state = LuaAudioTaskState::Stopped; +static portMUX_TYPE s_lua_audio_mux = portMUX_INITIALIZER_UNLOCKED; +static LuaAudioStatus s_lua_audio_status; + +static void luaAudioCopy(char* out, size_t cap, const char* value) { + if (!cap) return; + snprintf(out, cap, "%s", value ? value : ""); +} + +static void luaAudioSetTrackStatus(const LuaAudioCommand& command, LuaAudioState state, + const char* error = nullptr) { + portENTER_CRITICAL(&s_lua_audio_mux); + s_lua_audio_status.owner = command.owner; + s_lua_audio_status.state = state; + luaAudioCopy(s_lua_audio_status.path, sizeof s_lua_audio_status.path, command.shown); + luaAudioCopy(s_lua_audio_status.source, sizeof s_lua_audio_status.source, command.source); + luaAudioCopy(s_lua_audio_status.format, sizeof s_lua_audio_status.format, + command.format == LuaAudioFormat::Mp3 ? "mp3" : "wav"); + luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error); + portEXIT_CRITICAL(&s_lua_audio_mux); +} + +static void luaAudioSetState(uint32_t owner, LuaAudioState state, const char* error = nullptr) { + portENTER_CRITICAL(&s_lua_audio_mux); + if (s_lua_audio_status.owner == owner) { + s_lua_audio_status.state = state; + luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error); + } + portEXIT_CRITICAL(&s_lua_audio_mux); +} + +static bool luaAudioSinkBegin(uint32_t source_rate, LuaAudioSinkSession* sink, + const char** error) { + if (!sink) return false; + const int volume = (int)touchPrefsGetSoundVolume(); + if (volume <= 0) { if (error) *error = "muted"; return false; } + +#if defined(HAS_TDECK_GT911) + if (s_notify_playing) { if (error) *error = "busy"; return false; } + if (!tdeckAudioInstallRate((int)source_rate)) { + if (error) *error = "audio unavailable"; + return false; + } + sink->output_rate = source_rate; + sink->gain = (float)volume / 100.0f; +#elif defined(TLORA_PAGER) + if (s_notify_playing) { if (error) *error = "busy"; return false; } + board.setAmpEnabled(true); + if (!pagerAudioInstallRate((int)source_rate)) { + board.setAmpEnabled(false); + if (error) *error = "audio unavailable"; + return false; + } + s_pager_codec.setVolumePercent((uint8_t)volume); + s_pager_codec.setMute(false); + sink->output_rate = source_rate; +#elif defined(HAS_TDISPLAY_P4) + if (!p4AudioStreamBegin()) { + if (error) *error = "audio unavailable"; + return false; + } + sink->output_rate = p4AudioStreamRate(); + sink->gain = (float)volume / 100.0f; +#elif defined(HAS_TANMATSU) + sink->mutex = tanAudioMutex(); + if (!sink->mutex || xSemaphoreTake(sink->mutex, pdMS_TO_TICKS(300)) != pdTRUE) { + if (error) *error = "busy"; + return false; + } + if (bsp_audio_get_i2s_handle(&sink->handle) != ESP_OK || !sink->handle) { + xSemaphoreGive(sink->mutex); + sink->mutex = nullptr; + if (error) *error = "audio unavailable"; + return false; + } + applyVolume((uint8_t)volume); + sink->output_rate = 44100; +#endif + + sink->open = sink->output_rate != 0; + return sink->open; +} + +static bool luaAudioSinkWrite(LuaAudioSinkSession* sink, const int16_t* samples, size_t frames) { + if (!sink || !sink->open || !samples || !frames) return false; +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) + size_t written = 0; + const size_t bytes = frames * sizeof(int16_t); + return i2s_write(kI2sPort, samples, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK && + written == bytes; +#elif defined(HAS_TDISPLAY_P4) + return p4AudioStreamWrite(samples, frames); +#elif defined(HAS_TANMATSU) + static int16_t stereo[256 * 2]; + if (frames > 256) return false; + for (size_t i = 0; i < frames; ++i) + stereo[2 * i] = stereo[2 * i + 1] = samples[i]; + size_t written = 0; + const size_t bytes = frames * 2 * sizeof(int16_t); + return i2s_channel_write(sink->handle, stereo, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK && + written == bytes; +#endif +} + +static void luaAudioSinkEnd(LuaAudioSinkSession* sink) { + if (!sink || !sink->open) return; +#if defined(HAS_TDECK_GT911) + i2s_zero_dma_buffer(kI2sPort); + i2s_driver_uninstall(kI2sPort); +#elif defined(TLORA_PAGER) + pagerAudioUninstall(); + board.setAmpEnabled(false); +#elif defined(HAS_TDISPLAY_P4) + p4AudioStreamEnd(); +#elif defined(HAS_TANMATSU) + static const int16_t silence[256 * 2] = {}; + for (int i = 0; i < 16; ++i) { + size_t written = 0; + i2s_channel_write(sink->handle, silence, sizeof silence, &written, pdMS_TO_TICKS(300)); + } + if (sink->mutex) xSemaphoreGive(sink->mutex); + sink->handle = nullptr; + sink->mutex = nullptr; +#endif + sink->open = false; +} + +static bool luaAudioControlMatches(const LuaAudioCommand& control, uint32_t owner) { + return control.kind == LuaAudioCommandKind::Play || control.owner == owner; +} + +static bool luaAudioPollControl(const LuaAudioCommand& command, + LuaAudioCommand* replacement, + LuaAudioRunResult* result) { + LuaAudioCommand control; + if (xQueueReceive(s_lua_audio_queue, &control, 0) != pdTRUE || + !luaAudioControlMatches(control, command.owner)) return false; + + if (control.kind == LuaAudioCommandKind::Play) { + if (replacement) *replacement = control; + if (result) *result = LuaAudioRunResult::Replaced; + return true; + } + if (control.kind == LuaAudioCommandKind::Stop) { + if (result) *result = LuaAudioRunResult::Stopped; + return true; + } + if (control.kind == LuaAudioCommandKind::Release) { + if (result) *result = LuaAudioRunResult::Released; + return true; + } + if (control.kind != LuaAudioCommandKind::Pause) return false; + + luaAudioSetState(command.owner, LuaAudioState::Paused); + for (;;) { + if (xQueueReceive(s_lua_audio_queue, &control, portMAX_DELAY) != pdTRUE) continue; + if (!luaAudioControlMatches(control, command.owner)) continue; + if (control.kind == LuaAudioCommandKind::Resume) { + luaAudioSetState(command.owner, LuaAudioState::Playing); + return false; + } + if (control.kind == LuaAudioCommandKind::Play) { + if (replacement) *replacement = control; + if (result) *result = LuaAudioRunResult::Replaced; + return true; + } + if (control.kind == LuaAudioCommandKind::Release) { + if (result) *result = LuaAudioRunResult::Released; + return true; + } + if (control.kind == LuaAudioCommandKind::Stop) { + if (result) *result = LuaAudioRunResult::Stopped; + return true; + } + } +} + +static LuaAudioRunResult luaAudioRunTrack(const LuaAudioCommand& command, + LuaAudioCommand* replacement) { + LuaAudioStorageLease storage_lease; + File file = command.fs ? command.fs->open(command.path, "r") : File(); + if (!file || file.isDirectory()) { + if (file) file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found"); + return LuaAudioRunResult::Error; + } + + uint16_t channels = 0; + uint32_t source_rate = 0, data_len = 0; + if (!wavParse(file, &channels, &source_rate, &data_len) || + data_len < (uint32_t)channels * sizeof(int16_t)) { + file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format"); + return LuaAudioRunResult::Error; + } + + LuaAudioSinkSession sink; + const char* sink_error = nullptr; + __atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE); + if (!luaAudioSinkBegin(source_rate, &sink, &sink_error)) { + __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); + file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, + sink_error ? sink_error : "audio unavailable"); + return LuaAudioRunResult::Error; + } + + luaAudioSetTrackStatus(command, LuaAudioState::Playing); + LuaAudioRunResult result = LuaAudioRunResult::Ended; + const char* run_error = nullptr; + bool interrupted = false; + uint32_t remaining = data_len; + uint64_t resample_phase = 0; + int16_t input[256]; + int16_t output[256]; + size_t output_count = 0; + const uint32_t frame_bytes = (uint32_t)channels * sizeof(int16_t); + + while (remaining >= frame_bytes && !interrupted) { + if (luaAudioPollControl(command, replacement, &result)) break; + + size_t want = sizeof input; + if (want > remaining) want = remaining; + want -= want % frame_bytes; + const int got = file.read((uint8_t*)input, want); + if (got <= 0) { + result = LuaAudioRunResult::Error; + run_error = "read failed"; + break; + } + const size_t frames = (size_t)got / frame_bytes; + for (size_t i = 0; i < frames; ++i) { + int32_t sample = channels == 2 + ? ((int32_t)input[2 * i] + input[2 * i + 1]) / 2 + : input[i]; + sample = (int32_t)((float)sample * sink.gain); + if (sample > 32767) sample = 32767; + else if (sample < -32768) sample = -32768; + + resample_phase += sink.output_rate; + while (resample_phase >= source_rate) { + output[output_count++] = (int16_t)sample; + resample_phase -= source_rate; + if (output_count == sizeof output / sizeof output[0]) { + if (!luaAudioSinkWrite(&sink, output, output_count)) { + result = LuaAudioRunResult::Error; + run_error = "output failed"; + interrupted = true; + break; + } + output_count = 0; + } + } + if (interrupted) break; + } + remaining -= (uint32_t)got; + } + + if (!interrupted && result == LuaAudioRunResult::Ended && output_count && + !luaAudioSinkWrite(&sink, output, output_count)) { + result = LuaAudioRunResult::Error; + run_error = "output failed"; + } + + luaAudioSinkEnd(&sink); + file.close(); + __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); + + if (result == LuaAudioRunResult::Ended) + luaAudioSetState(command.owner, LuaAudioState::Ended); + else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released) + luaAudioSetState(command.owner, LuaAudioState::Stopped); + else if (result == LuaAudioRunResult::Error) + luaAudioSetState(command.owner, LuaAudioState::Error, run_error ? run_error : "playback failed"); + return result; +} + +struct LuaMp3Work { + mp3dec_t decoder; + mp3dec_scratch_t scratch; + uint8_t input[16 * 1024]; + mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME]; +}; + +static uint32_t luaAudioReadLe32(const uint8_t* value) { + return (uint32_t)value[0] | ((uint32_t)value[1] << 8) | + ((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24); +} + +static uint32_t luaAudioMp3DataEnd(File& file) { + uint32_t end = (uint32_t)file.size(); + uint8_t footer[128]; + if (end >= 128 && file.seek(end - 128) && file.read(footer, 3) == 3 && + !memcmp(footer, "TAG", 3)) { + end -= 128; + } + if (end >= 32 && file.seek(end - 32) && file.read(footer, 32) == 32 && + !memcmp(footer, "APETAGEX", 8)) { + const uint32_t version = luaAudioReadLe32(footer + 8); + const uint32_t tag_size = luaAudioReadLe32(footer + 12); + const uint32_t flags = luaAudioReadLe32(footer + 20); + const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u); + if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= end) + end -= (uint32_t)total_size; + } + file.seek(0); + return end; +} + +static LuaAudioRunResult luaAudioRunMp3Track(const LuaAudioCommand& command, + LuaAudioCommand* replacement) { + LuaAudioStorageLease storage_lease; + File file = command.fs ? command.fs->open(command.path, "r") : File(); + if (!file || file.isDirectory()) { + if (file) file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found"); + return LuaAudioRunResult::Error; + } + + LuaMp3Work* work = (LuaMp3Work*)heap_caps_malloc( + sizeof(LuaMp3Work), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!work) { + file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, "out of memory"); + return LuaAudioRunResult::Error; + } + s_wada_mp3_scratch = &work->scratch; + mp3dec_init(&work->decoder); + const uint32_t data_end = luaAudioMp3DataEnd(file); + if (!data_end) { + s_wada_mp3_scratch = nullptr; + heap_caps_free(work); + file.close(); + luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format"); + return LuaAudioRunResult::Error; + } + + __atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE); + luaAudioSetTrackStatus(command, LuaAudioState::Playing); + LuaAudioRunResult result = LuaAudioRunResult::Ended; + const char* run_error = nullptr; + LuaAudioSinkSession sink; + uint32_t source_rate = 0; + uint64_t resample_phase = 0; + int16_t output[256]; + size_t output_count = 0; + size_t input_start = 0, input_count = 0; + bool eof = false, decoded_any = false, interrupted = false; + + while (!interrupted) { + if (luaAudioPollControl(command, replacement, &result)) break; + + if (!eof && input_count < 4096) { + if (input_start && input_start + input_count + 4096 > sizeof work->input) { + memmove(work->input, work->input + input_start, input_count); + input_start = 0; + } + size_t room = sizeof work->input - (input_start + input_count); + const uint32_t position = (uint32_t)file.position(); + const uint32_t remaining = position < data_end ? data_end - position : 0; + if (room > remaining) room = remaining; + const int got = room ? file.read(work->input + input_start + input_count, room) : 0; + if (got > 0) { + input_count += (size_t)got; + eof = file.position() >= data_end; + } else if (file.position() < data_end) { + result = LuaAudioRunResult::Error; + run_error = "read failed"; + break; + } else { + eof = true; + } + } + + if (!input_count) { + if (!decoded_any) { + result = LuaAudioRunResult::Error; + run_error = "unsupported format"; + } + break; + } + + mp3dec_frame_info_t info = {}; + const int samples = mp3dec_decode_frame(&work->decoder, + work->input + input_start, (int)input_count, work->pcm, &info); + if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) { + result = LuaAudioRunResult::Error; + run_error = "decode failed"; + break; + } + if (info.frame_bytes > 0) { + input_start += (size_t)info.frame_bytes; + input_count -= (size_t)info.frame_bytes; + if (!input_count) input_start = 0; + } else if (eof) { + if (!decoded_any) { + result = LuaAudioRunResult::Error; + run_error = "unsupported format"; + } + break; + } else if (input_start + input_count == sizeof work->input) { + result = LuaAudioRunResult::Error; + run_error = "decode failed"; + break; + } else { + continue; + } + + if (samples <= 0) continue; + if (info.layer != 3 || (info.channels != 1 && info.channels != 2) || + info.hz < 8000 || info.hz > 48000) { + result = LuaAudioRunResult::Error; + run_error = "unsupported format"; + break; + } + if (!sink.open) { + const char* sink_error = nullptr; + if (!luaAudioSinkBegin((uint32_t)info.hz, &sink, &sink_error)) { + result = LuaAudioRunResult::Error; + run_error = sink_error ? sink_error : "audio unavailable"; + break; + } + source_rate = (uint32_t)info.hz; + } else if (source_rate != (uint32_t)info.hz) { + result = LuaAudioRunResult::Error; + run_error = "sample rate changed"; + break; + } + + decoded_any = true; + for (int i = 0; i < samples; ++i) { + int32_t sample = info.channels == 2 + ? ((int32_t)work->pcm[2 * i] + work->pcm[2 * i + 1]) / 2 + : work->pcm[i]; + sample = (int32_t)((float)sample * sink.gain); + if (sample > 32767) sample = 32767; + else if (sample < -32768) sample = -32768; + + resample_phase += sink.output_rate; + while (resample_phase >= source_rate) { + output[output_count++] = (int16_t)sample; + resample_phase -= source_rate; + if (output_count == sizeof output / sizeof output[0]) { + if (!luaAudioSinkWrite(&sink, output, output_count)) { + result = LuaAudioRunResult::Error; + run_error = "output failed"; + interrupted = true; + break; + } + output_count = 0; + } + } + if (interrupted) break; + } + } + + if (!interrupted && result == LuaAudioRunResult::Ended && output_count && + !luaAudioSinkWrite(&sink, output, output_count)) { + result = LuaAudioRunResult::Error; + run_error = "output failed"; + } + + luaAudioSinkEnd(&sink); + s_wada_mp3_scratch = nullptr; + heap_caps_free(work); + file.close(); + __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); + + if (result == LuaAudioRunResult::Ended) + luaAudioSetState(command.owner, LuaAudioState::Ended); + else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released) + luaAudioSetState(command.owner, LuaAudioState::Stopped); + else if (result == LuaAudioRunResult::Error) + luaAudioSetState(command.owner, LuaAudioState::Error, + run_error ? run_error : "playback failed"); + return result; +} + +static void luaAudioWorker(void*) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + bool release = false; + while (!release) { + LuaAudioCommand command; + if (xQueueReceive(s_lua_audio_queue, &command, portMAX_DELAY) != pdTRUE) continue; + if (command.kind == LuaAudioCommandKind::Release) { + luaAudioSetState(command.owner, LuaAudioState::Stopped); + break; + } + if (command.kind == LuaAudioCommandKind::Stop) { + luaAudioSetState(command.owner, LuaAudioState::Stopped); + continue; + } + if (command.kind != LuaAudioCommandKind::Play) continue; + + for (;;) { + LuaAudioCommand replacement; + const LuaAudioRunResult result = command.format == LuaAudioFormat::Mp3 + ? luaAudioRunMp3Track(command, &replacement) + : luaAudioRunTrack(command, &replacement); + if (result == LuaAudioRunResult::Replaced) { + command = replacement; + continue; + } + release = result == LuaAudioRunResult::Released; + break; + } + } + + __atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE); + __atomic_store_n(&s_lua_audio_storage_pending, 0u, __ATOMIC_RELEASE); + __atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE); + xQueueReset(s_lua_audio_queue); + portENTER_CRITICAL(&s_lua_audio_mux); + s_lua_audio_task = nullptr; + s_lua_audio_task_state = LuaAudioTaskState::Stopped; + portEXIT_CRITICAL(&s_lua_audio_mux); + vTaskDelete(nullptr); +} + +static bool luaAudioReturnError(char* error, size_t cap, const char* message) { + luaAudioCopy(error, cap, message); + return false; +} + +static bool luaAudioPathEndsWith(const char* path, const char* suffix) { + if (!path || !suffix) return false; + const size_t path_len = strlen(path), suffix_len = strlen(suffix); + if (path_len < suffix_len) return false; + path += path_len - suffix_len; + for (size_t i = 0; i < suffix_len; ++i) { + char a = path[i], b = suffix[i]; + if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a'); + if (a != b) return false; + } + return true; +} +} // namespace + +bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, const char* source, + uint32_t owner, char* error, size_t error_cap) { + if (!fs) return luaAudioReturnError(error, error_cap, "no storage"); + if (!path || !path[0] || strlen(path) >= 224) + return luaAudioReturnError(error, error_cap, "bad path"); + if (!g_lv.task || g_lv.task->isBuzzerQuiet() || touchPrefsGetSoundVolume() == 0) + return luaAudioReturnError(error, error_cap, "muted"); + + File probe = fs->open(path, "r"); + if (!probe || probe.isDirectory()) { + if (probe) probe.close(); + return luaAudioReturnError(error, error_cap, "not found"); + } + uint16_t channels = 0; + uint32_t rate = 0, data_len = 0; + LuaAudioFormat format; + bool supported = false; + if (luaAudioPathEndsWith(shown, ".wav")) { + format = LuaAudioFormat::Wav; + supported = wavParse(probe, &channels, &rate, &data_len) && data_len > 0; + } else if (luaAudioPathEndsWith(shown, ".mp3")) { + format = LuaAudioFormat::Mp3; + supported = probe.size() > 0; + } + probe.close(); + if (!supported) return luaAudioReturnError(error, error_cap, "unsupported format"); + + if (!s_lua_audio_queue) s_lua_audio_queue = xQueueCreate(4, sizeof(LuaAudioCommand)); + if (!s_lua_audio_queue) return luaAudioReturnError(error, error_cap, "out of memory"); + + bool start_task = false; + portENTER_CRITICAL(&s_lua_audio_mux); + if (s_lua_audio_task_state == LuaAudioTaskState::Stopped) { + s_lua_audio_task_state = LuaAudioTaskState::Starting; + start_task = true; + } else if (s_lua_audio_task_state != LuaAudioTaskState::Running) { + portEXIT_CRITICAL(&s_lua_audio_mux); + return luaAudioReturnError(error, error_cap, "busy"); + } + portEXIT_CRITICAL(&s_lua_audio_mux); + + TaskHandle_t task = nullptr; + if (start_task) { + if (xTaskCreate(luaAudioWorker, "lua-audio", 8192, nullptr, 3, &task) != pdPASS) { + portENTER_CRITICAL(&s_lua_audio_mux); + s_lua_audio_task_state = LuaAudioTaskState::Stopped; + portEXIT_CRITICAL(&s_lua_audio_mux); + return luaAudioReturnError(error, error_cap, "out of memory"); + } + portENTER_CRITICAL(&s_lua_audio_mux); + s_lua_audio_task = task; + s_lua_audio_task_state = LuaAudioTaskState::Running; + portEXIT_CRITICAL(&s_lua_audio_mux); + } + + LuaAudioCommand command; + command.kind = LuaAudioCommandKind::Play; + command.fs = fs; + command.owner = owner; + command.format = format; + luaAudioCopy(command.path, sizeof command.path, path); + luaAudioCopy(command.shown, sizeof command.shown, shown); + luaAudioCopy(command.source, sizeof command.source, source); + __atomic_fetch_add(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); + if (xQueueSend(s_lua_audio_queue, &command, 0) != pdPASS) { + __atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL); + if (start_task) xTaskNotifyGive(task); + return luaAudioReturnError(error, error_cap, "busy"); + } + luaAudioSetTrackStatus(command, LuaAudioState::Playing); + if (start_task) xTaskNotifyGive(task); + return true; +} + +bool luaHostAudioPause(uint32_t owner, bool pause) { + LuaAudioState state; + TaskHandle_t task; + LuaAudioTaskState task_state; + portENTER_CRITICAL(&s_lua_audio_mux); + state = s_lua_audio_status.owner == owner ? s_lua_audio_status.state : LuaAudioState::Stopped; + task = s_lua_audio_task; + task_state = s_lua_audio_task_state; + portEXIT_CRITICAL(&s_lua_audio_mux); + if (!task || task_state != LuaAudioTaskState::Running || + (pause ? state != LuaAudioState::Playing : state != LuaAudioState::Paused)) return false; + LuaAudioCommand command; + command.kind = pause ? LuaAudioCommandKind::Pause : LuaAudioCommandKind::Resume; + command.owner = owner; + return xQueueSend(s_lua_audio_queue, &command, 0) == pdPASS; +} + +bool luaHostAudioStop(uint32_t owner, bool release) { + TaskHandle_t task; + LuaAudioTaskState task_state; + portENTER_CRITICAL(&s_lua_audio_mux); + task = s_lua_audio_task; + task_state = s_lua_audio_task_state; + if (release && task && task_state == LuaAudioTaskState::Running) + s_lua_audio_task_state = LuaAudioTaskState::Stopping; + portEXIT_CRITICAL(&s_lua_audio_mux); + if (!task || task_state == LuaAudioTaskState::Stopped) { + luaAudioSetState(owner, LuaAudioState::Stopped); + return true; + } + if (task_state != LuaAudioTaskState::Running) return false; + + LuaAudioCommand command; + command.kind = release ? LuaAudioCommandKind::Release : LuaAudioCommandKind::Stop; + command.owner = owner; + const BaseType_t queued = release + ? xQueueSendToFront(s_lua_audio_queue, &command, pdMS_TO_TICKS(100)) + : xQueueSend(s_lua_audio_queue, &command, 0); + if (queued != pdPASS) { + if (release) { + portENTER_CRITICAL(&s_lua_audio_mux); + if (s_lua_audio_task_state == LuaAudioTaskState::Stopping) + s_lua_audio_task_state = LuaAudioTaskState::Running; + portEXIT_CRITICAL(&s_lua_audio_mux); + } + return false; + } + if (!release) return true; + + for (int i = 0; i < 160; ++i) { + vTaskDelay(pdMS_TO_TICKS(5)); + portENTER_CRITICAL(&s_lua_audio_mux); + task = s_lua_audio_task; + portEXIT_CRITICAL(&s_lua_audio_mux); + if (!task) return true; + } + return false; +} + +void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap, + char* path, size_t path_cap, char* source, size_t source_cap, + char* format, size_t format_cap, char* error, size_t error_cap) { + LuaAudioStatus status; + portENTER_CRITICAL(&s_lua_audio_mux); + status = s_lua_audio_status; + portEXIT_CRITICAL(&s_lua_audio_mux); + if (status.owner != owner) status = LuaAudioStatus(); + + const char* state_name = "stopped"; + if (status.state == LuaAudioState::Playing) state_name = "playing"; + else if (status.state == LuaAudioState::Paused) state_name = "paused"; + else if (status.state == LuaAudioState::Ended) state_name = "ended"; + else if (status.state == LuaAudioState::Error) state_name = "error"; + luaAudioCopy(state, state_cap, state_name); + luaAudioCopy(path, path_cap, status.path); + luaAudioCopy(source, source_cap, status.source); + luaAudioCopy(format, format_cap, status.format); + luaAudioCopy(error, error_cap, status.error); +} +#endif + #if CAP_LUA_SD_LIST // Return the physical removable card, never the app-data filesystem. Mounting // stays in UITask so Lua cannot bypass shared-bus coordination or create a @@ -52546,6 +53357,9 @@ static bool sdRuntimeLifecycleBusy() { bool busy = s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request || s_sdinfo_busy || touchPrefsIoBusy(); +#if CAP_LUA_AUDIO + busy = busy || luaAudioStorageBusy(); +#endif #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) busy = busy || s_notify_playing; #endif diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index 879d43f..b005ffc 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -273,6 +273,15 @@ #define CAP_SOUND_FILES 0 #endif +// Sustained PCM output for media playback. This is deliberately independent +// of storage: Lua app files may live on internal flash, SD, or SD_MMC. +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || \ + defined(HAS_TDISPLAY_P4) || defined(HAS_TANMATSU) + #define CAP_AUDIO_STREAM 1 +#else + #define CAP_AUDIO_STREAM 0 +#endif + // ---- On-device web browser (the "Web" reader app) --------------------------- // The reader fetches pages over on-device HTTPS, and a TLS handshake needs ~30 KB of // free INTERNAL heap. Only the 8 MB-PSRAM boards (T-Deck, Tanmatsu, ThinkNode M9, RAK @@ -294,6 +303,14 @@ #define CAP_LUA_APPS 1 #endif +#ifndef CAP_LUA_AUDIO + #if CAP_LUA_APPS && CAP_AUDIO_STREAM + #define CAP_LUA_AUDIO 1 + #else + #define CAP_LUA_AUDIO 0 + #endif +#endif + // ---- Extended Lua SDK ------------------------------------------------------ // The BASE SDK (drawing, timers, key/value store, read-only mesh + radio stats, // http_get) is board-agnostic and ships everywhere CAP_LUA_APPS is on. diff --git a/src/ui-touch/lua_builtin.h b/src/ui-touch/lua_builtin.h index ecbcc82..ec20023 100644 --- a/src/ui-touch/lua_builtin.h +++ b/src/ui-touch/lua_builtin.h @@ -340,6 +340,8 @@ static const char kLuaSrc_sdktest[] = R"WADALUA(-- SDK self-test. Exercises the -- windowed wada.fs.read. -- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root -- metadata, and a nested directory listing when the card contains one. +-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback +-- stays manual so opening the bench test never emits sound unexpectedly. -- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the -- firmware has it. That call was added because of the 1.3 bug below: an app -- could ask how tall a line is but not how wide, so laying out rows meant @@ -396,6 +398,21 @@ function app.on_open(w, h) row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) .. " input=" .. yn(c.input) .. " rx_identity=" .. yn(c.rx_identity), C.accent) + row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) .. + " mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent) + if c.audio then + local api_ok = wada.audio and type(wada.audio.play) == "function" and + type(wada.audio.pause) == "function" and + type(wada.audio.resume) == "function" and + type(wada.audio.stop) == "function" and + type(wada.audio.status) == "function" + local status = api_ok and wada.audio.status() or nil + api_ok = api_ok and type(status) == "table" and type(status.state) == "string" + row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"), + api_ok and C.good or C.bad) + else + row("wada.audio: unavailable on this board", C.sub) + end row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"), ui.text_lines and C.good or C.sub) -- crypto: published test vectors, so this is checkable rather than merely alive @@ -1775,7 +1792,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = { { "monitor", "RF Monitor", "1.3", kLuaSrc_monitor }, { "airtime", "Airtime", "1.4", kLuaSrc_airtime }, { "snake", "Snake", "1.0", kLuaSrc_snake }, - { "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest }, + { "sdktest", "SDK Test", "1.7", kLuaSrc_sdktest }, { "2048", "2048", "1.2", kLuaSrc_2048 }, { "wardrive", "Wardrive", "1.0", kLuaSrc_wardrive }, { "nearby", "Nearby", "1.0", kLuaSrc_nearby }, diff --git a/test/test_minimp3_decoder.cpp b/test/test_minimp3_decoder.cpp new file mode 100644 index 0000000..93f4faf --- /dev/null +++ b/test/test_minimp3_decoder.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include + +static void* testMp3Scratch(); +#define MINIMP3_ONLY_MP3 +#define MINIMP3_NO_SIMD +#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)testMp3Scratch()) +#define MINIMP3_IMPLEMENTATION +#include "../lib/minimp3/minimp3.h" + +static mp3dec_scratch_t g_scratch; +static void* testMp3Scratch() { return &g_scratch; } + +struct DecodeBuffer { + uint8_t input[16 * 1024]; + mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME]; +}; + +static uint32_t readLe32(const uint8_t* value) { + return (uint32_t)value[0] | ((uint32_t)value[1] << 8) | + ((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24); +} + +static long mp3DataEnd(FILE* file) { + assert(fseek(file, 0, SEEK_END) == 0); + long end = ftell(file); + assert(end >= 0); + uint8_t footer[128]; + if (end >= 128 && fseek(file, end - 128, SEEK_SET) == 0 && + fread(footer, 1, 3, file) == 3 && !memcmp(footer, "TAG", 3)) { + end -= 128; + } + if (end >= 32 && fseek(file, end - 32, SEEK_SET) == 0 && + fread(footer, 1, 32, file) == 32 && !memcmp(footer, "APETAGEX", 8)) { + const uint32_t version = readLe32(footer + 8); + const uint32_t tag_size = readLe32(footer + 12); + const uint32_t flags = readLe32(footer + 20); + const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u); + if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= (uint64_t)end) + end -= (long)total_size; + } + rewind(file); + return end; +} + +static bool decodeFile(const char* path) { + FILE* file = fopen(path, "rb"); + if (!file) { + fprintf(stderr, "%s: could not open\n", path); + return false; + } + const long data_end = mp3DataEnd(file); + if (data_end <= 0) { fclose(file); return false; } + + DecodeBuffer buffer; + mp3dec_t decoder; + mp3dec_init(&decoder); + size_t input_start = 0, input_count = 0; + uint64_t samples_total = 0; + int frames = 0, sample_rate = 0, channels = 0, bitrate_changes = 0; + int last_bitrate = 0; + bool eof = false, ok = true; + + while (ok) { + if (!eof && input_count < 4096) { + if (input_start && input_start + input_count + 4096 > sizeof buffer.input) { + memmove(buffer.input, buffer.input + input_start, input_count); + input_start = 0; + } + size_t room = sizeof buffer.input - (input_start + input_count); + const long position = ftell(file); + const size_t remaining = position < data_end ? (size_t)(data_end - position) : 0; + if (room > remaining) room = remaining; + const size_t got = fread(buffer.input + input_start + input_count, 1, room, file); + if (got > 0) { + input_count += got; + eof = ftell(file) >= data_end; + } + else if (ferror(file)) ok = false; + else eof = true; + } + + if (!ok || !input_count) break; + + mp3dec_frame_info_t info = {}; + const int samples = mp3dec_decode_frame(&decoder, buffer.input + input_start, + (int)input_count, buffer.pcm, &info); + if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) { + ok = false; + break; + } + if (info.frame_bytes > 0) { + input_start += (size_t)info.frame_bytes; + input_count -= (size_t)info.frame_bytes; + if (!input_count) input_start = 0; + } else if (eof) { + break; + } else if (input_start + input_count == sizeof buffer.input) { + ok = false; + break; + } else { + continue; + } + + if (samples <= 0) continue; + if (info.layer != 3 || (info.channels != 1 && info.channels != 2) || + info.hz < 8000 || info.hz > 48000) { + ok = false; + break; + } + if (!sample_rate) { + sample_rate = info.hz; + channels = info.channels; + } else if (sample_rate != info.hz) { + ok = false; + break; + } + if (last_bitrate && last_bitrate != info.bitrate_kbps) ++bitrate_changes; + last_bitrate = info.bitrate_kbps; + samples_total += (uint64_t)samples; + ++frames; + } + + fclose(file); + if (!ok || frames == 0 || samples_total == 0) { + fprintf(stderr, "%s: decode failed after %d frames\n", path, frames); + return false; + } + printf("%s: %d frames, %llu samples/channel, %d Hz, %d channel(s), %d bitrate changes\n", + path, frames, (unsigned long long)samples_total, sample_rate, channels, + bitrate_changes); + return true; +} + +int main(int argc, char** argv) { + if (argc < 2) { + fprintf(stderr, "usage: %s file.mp3 [file.mp3 ...]\n", argv[0]); + return 2; + } + for (int i = 1; i < argc; ++i) assert(decodeFile(argv[i])); + return 0; +} diff --git a/variants/tdisplay_p4/P4Audio.cpp b/variants/tdisplay_p4/P4Audio.cpp index c0a5a9e..a2658b8 100644 --- a/variants/tdisplay_p4/P4Audio.cpp +++ b/variants/tdisplay_p4/P4Audio.cpp @@ -89,6 +89,7 @@ bool p4AudioReady() { if (s_ready) return true; if (s_failed) return false; if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); + if (!s_mtx) return false; xSemaphoreTake(s_mtx, portMAX_DELAY); if (s_ready || s_failed) { xSemaphoreGive(s_mtx); return s_ready; } @@ -125,6 +126,9 @@ bool p4AudioReady() { if (i2s_channel_init_std_mode(s_tx, &std_cfg) != ESP_OK || i2s_channel_enable(s_tx) != ESP_OK) { printf("[P4AUDIO] I2S init failed\n"); + i2s_channel_disable(s_tx); + i2s_del_channel(s_tx); + s_tx = nullptr; s_failed = true; xSemaphoreGive(s_mtx); return false; @@ -139,7 +143,7 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) { if (amplitude <= 0 || freq_hz <= 0 || duration_ms <= 0) return; if (!p4AudioReady()) return; if (amplitude > 30000) amplitude = 30000; - xSemaphoreTake(s_mtx, portMAX_DELAY); + if (xSemaphoreTake(s_mtx, 0) != pdTRUE) return; const int total = P4A_RATE * duration_ms / 1000; const int fade = P4A_RATE * 4 / 1000; // 4 ms in/out fade kills the click static int16_t buf[256 * 2]; // stereo frames, chunked @@ -163,4 +167,36 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) { xSemaphoreGive(s_mtx); } +uint32_t p4AudioStreamRate() { return P4A_RATE; } + +bool p4AudioStreamBegin() { + if (!p4AudioReady()) return false; + return xSemaphoreTake(s_mtx, portMAX_DELAY) == pdTRUE; +} + +bool p4AudioStreamWrite(const int16_t* samples, size_t frames) { + if (!samples || !frames || !s_tx) return false; + static int16_t stereo[256 * 2]; + while (frames) { + const size_t count = frames > 256 ? 256 : frames; + for (size_t i = 0; i < count; ++i) + stereo[2 * i] = stereo[2 * i + 1] = samples[i]; + size_t written = 0; + const size_t bytes = count * 2 * sizeof(int16_t); + if (i2s_channel_write(s_tx, stereo, bytes, &written, pdMS_TO_TICKS(300)) != ESP_OK || + written != bytes) return false; + samples += count; + frames -= count; + } + return true; +} + +void p4AudioStreamEnd() { + if (!s_mtx) return; + static const int16_t silence[256 * 2] = {}; + size_t written = 0; + i2s_channel_write(s_tx, silence, sizeof silence, &written, pdMS_TO_TICKS(300)); + xSemaphoreGive(s_mtx); +} + #endif // HAS_TDISPLAY_P4 diff --git a/variants/tdisplay_p4/P4Audio.h b/variants/tdisplay_p4/P4Audio.h index 46d9681..e1d1418 100644 --- a/variants/tdisplay_p4/P4Audio.h +++ b/variants/tdisplay_p4/P4Audio.h @@ -5,6 +5,7 @@ // DOUT=10. Fixed 16 kHz / 16-bit stereo — plenty for notification chimes, tiny buffers. // All calls are safe from any task; init is lazy on first use (~ms, I²C + I2S bring-up). #include +#include // Bring the codec + I2S up (idempotent). Returns false if the codec never answered. bool p4AudioReady(); @@ -12,3 +13,10 @@ bool p4AudioReady(); // Blocking sine tone: `amplitude` is the 16-bit peak (0..~30000; the UI uses pct*130). // No-op (fast) when amplitude <= 0 or the codec is absent. void p4AudioTone(int freq_hz, int duration_ms, int amplitude); + +// Exclusive fixed-rate PCM stream used by media playback. Samples are mono; +// the backend duplicates them to the codec's stereo I2S slots. +uint32_t p4AudioStreamRate(); +bool p4AudioStreamBegin(); +bool p4AudioStreamWrite(const int16_t* samples, size_t frames); +void p4AudioStreamEnd(); From 9d7d1f2df7df49177e7ac610db95af39249a0f1b Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Sun, 23 Aug 2026 21:40:52 -0400 Subject: [PATCH 04/22] Add SD home page to web reader Signed-off-by: Michael A. Cojocari --- src/ui-touch/ReaderContent.cpp | 287 +++++++++++++++++++++ src/ui-touch/ReaderContent.h | 24 ++ src/ui-touch/UITask.cpp | 443 +++++++++++++++++++-------------- test/test_reader_content.cpp | 50 ++++ 4 files changed, 612 insertions(+), 192 deletions(-) create mode 100644 src/ui-touch/ReaderContent.cpp create mode 100644 src/ui-touch/ReaderContent.h create mode 100644 test/test_reader_content.cpp diff --git a/src/ui-touch/ReaderContent.cpp b/src/ui-touch/ReaderContent.cpp new file mode 100644 index 0000000..2f1a385 --- /dev/null +++ b/src/ui-touch/ReaderContent.cpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ReaderContent.h" + +#include +#include + +namespace ReaderContent { +namespace { + +bool ciPrefix(const char* text, size_t text_len, const char* prefix) { + const size_t prefix_len = strlen(prefix); + if (text_len < prefix_len) return false; + for (size_t i = 0; i < prefix_len; ++i) { + char actual = text[i]; + char expected = prefix[i]; + if (actual >= 'A' && actual <= 'Z') actual += 32; + if (expected >= 'A' && expected <= 'Z') expected += 32; + if (actual != expected) return false; + } + return true; +} + +size_t decodeEntity(const char* text, size_t text_len, char* out, int* wrote) { + static const struct { const char* name; const char* utf8; } named[] = { + {"amp;","&"},{"lt;","<"},{"gt;",">"},{"quot;","\""},{"apos;","'"},{"nbsp;"," "}, + {"mdash;","\xe2\x80\x94"},{"ndash;","\xe2\x80\x93"},{"hellip;","\xe2\x80\xa6"}, + {"lsquo;","\xe2\x80\x98"},{"rsquo;","\xe2\x80\x99"},{"ldquo;","\xe2\x80\x9c"}, + {"rdquo;","\xe2\x80\x9d"},{"copy;","\xc2\xa9"},{"reg;","\xc2\xae"},{"euro;","\xe2\x82\xac"}, + {"deg;","\xc2\xb0"},{"middot;","\xc2\xb7"},{"bull;","\xe2\x80\xa2"},{"trade;","\xe2\x84\xa2"}, + }; + for (const auto& entity : named) { + const size_t name_len = strlen(entity.name); + if (text_len > name_len && strncmp(text + 1, entity.name, name_len) == 0) { + const int width = strlen(entity.utf8); + memcpy(out, entity.utf8, width); + *wrote = width; + return name_len + 1; + } + } + if (text_len > 3 && text[1] == '#') { + long codepoint = 0; + const bool hex = text[2] == 'x' || text[2] == 'X'; + size_t i = hex ? 3 : 2; + const size_t start = i; + for (; i < text_len && text[i] != ';'; ++i) { + const char c = text[i]; + int digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (hex && c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (hex && c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else { i = start; break; } + codepoint = codepoint * (hex ? 16 : 10) + digit; + } + if (i > start && i < text_len && text[i] == ';' && codepoint > 0 && codepoint <= 0x10FFFF) { + int width = 0; + if (codepoint < 0x80) out[width++] = static_cast(codepoint); + else if (codepoint < 0x800) { + out[width++] = 0xC0 | (codepoint >> 6); + out[width++] = 0x80 | (codepoint & 0x3F); + } else if (codepoint < 0x10000) { + out[width++] = 0xE0 | (codepoint >> 12); + out[width++] = 0x80 | ((codepoint >> 6) & 0x3F); + out[width++] = 0x80 | (codepoint & 0x3F); + } else { + out[width++] = 0xF0 | (codepoint >> 18); + out[width++] = 0x80 | ((codepoint >> 12) & 0x3F); + out[width++] = 0x80 | ((codepoint >> 6) & 0x3F); + out[width++] = 0x80 | (codepoint & 0x3F); + } + *wrote = width; + return i + 1; + } + } + return 0; +} + +bool tagAttribute(const char* tag, size_t tag_len, const char* attribute, + char* out, size_t cap) { + const size_t attribute_len = strlen(attribute); + for (size_t i = 0; i + attribute_len + 1 < tag_len; ++i) { + if (!ciPrefix(tag + i, tag_len - i, attribute)) continue; + if (i > 0 && tag[i - 1] != ' ' && tag[i - 1] != '\t' && + tag[i - 1] != '\r' && tag[i - 1] != '\n') continue; + size_t cursor = i + attribute_len; + while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor; + if (cursor >= tag_len || tag[cursor] != '=') continue; + ++cursor; + while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor; + char quote = 0; + if (cursor < tag_len && (tag[cursor] == '"' || tag[cursor] == '\'')) { + quote = tag[cursor++]; + } + size_t written = 0; + while (cursor < tag_len && written + 1 < cap) { + const char c = tag[cursor]; + if (quote ? c == quote : (c == ' ' || c == '>' || c == '\t')) break; + out[written++] = c; + ++cursor; + } + out[written] = 0; + return written > 0; + } + return false; +} + +} // namespace + +bool resolveUrl(const char* base, const char* href, char* out, size_t cap) { + if (!base || !href || !out || cap == 0) return false; + while (*href == ' ') ++href; + const size_t href_len = strlen(href); + if (!href_len || href[0] == '#') return false; + if (ciPrefix(href, href_len, "javascript:") || ciPrefix(href, href_len, "mailto:") || + ciPrefix(href, href_len, "tel:") || ciPrefix(href, href_len, "data:")) return false; + + if (ciPrefix(href, href_len, "http://") || ciPrefix(href, href_len, "https://") || + ciPrefix(href, href_len, "sd:/")) { + snprintf(out, cap, "%s", href); + } else if (ciPrefix(base, strlen(base), "sd:/")) { + if (href[0] == '/' && href[1] == '/') { + snprintf(out, cap, "https:%s", href); + } else if (href[0] == '/') { + snprintf(out, cap, "sd:%s", href); + } else { + const char* last_slash = strrchr(base + 3, '/'); + if (last_slash) snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href); + else snprintf(out, cap, "sd:/%s", href); + } + } else { + const char* scheme_end = strstr(base, "://"); + if (!scheme_end) return false; + const int scheme_len = static_cast(scheme_end - base); + const char* host = scheme_end + 3; + const char* host_end = strchr(host, '/'); + const int host_len = host_end ? static_cast(host_end - host) : static_cast(strlen(host)); + if (href[0] == '/' && href[1] == '/') { + snprintf(out, cap, "%.*s:%s", scheme_len, base, href); + } else if (href[0] == '/') { + snprintf(out, cap, "%.*s://%.*s%s", scheme_len, base, host_len, host, href); + } else { + const char* last_slash = strrchr(base, '/'); + if (last_slash && last_slash > scheme_end + 2) + snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href); + else + snprintf(out, cap, "%.*s://%.*s/%s", scheme_len, base, host_len, host, href); + } + } + char* fragment = strchr(out, '#'); + if (fragment) *fragment = 0; + return out[0] != 0; +} + +size_t htmlToText(const char* html, size_t html_len, + char* out, size_t out_cap, const char* base, + Link* links, size_t link_capacity, size_t* link_count) { + if (link_count) *link_count = 0; + if (!html || !out || out_cap == 0) return 0; + + size_t out_len = 0; + size_t links_len = 0; + int pending_newlines = 0; + bool pending_space = false; + bool started = false; + bool in_anchor = false; + uint32_t anchor_start = 0; + char anchor_href[HREF_CAPACITY] = ""; + auto emit = [&](char c) { if (out_len + 1 < out_cap) out[out_len++] = c; }; + auto flush = [&]() { + if (!started) { + pending_newlines = 0; + pending_space = false; + return; + } + while (pending_newlines > 0) { emit('\n'); --pending_newlines; } + if (pending_space) { emit(' '); pending_space = false; } + }; + auto finishAnchor = [&]() { + if (in_anchor && out_len > anchor_start && links && links_len < link_capacity) { + links[links_len].start = anchor_start; + links[links_len].end = static_cast(out_len); + snprintf(links[links_len].href, sizeof links[links_len].href, "%s", anchor_href); + ++links_len; + } + in_anchor = false; + }; + static const char* blocks[] = { + "p","div","br","li","ul","ol","tr","h1","h2","h3","h4","h5","h6", + "section","article","header","footer","table","blockquote","pre","hr","nav","title","body", nullptr + }; + + size_t i = 0; + while (i < html_len && out_len + 5 < out_cap) { + const char c = html[i]; + if (c == '<') { + if (html_len - i >= 4 && html[i + 1] == '!' && html[i + 2] == '-' && html[i + 3] == '-') { + size_t end = i + 4; + while (end + 2 < html_len && !(html[end] == '-' && html[end + 1] == '-' && html[end + 2] == '>')) ++end; + i = end + 2 < html_len ? end + 3 : html_len; + continue; + } + const bool script = ciPrefix(html + i, html_len - i, "= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || + (value >= '0' && value <= '9')) { + name[name_len++] = value >= 'A' && value <= 'Z' ? value + 32 : value; + ++name_cursor; + } else { + break; + } + } + name[name_len] = 0; + size_t tag_end = i + 1; + while (tag_end < html_len && html[tag_end] != '>') ++tag_end; + if (name[0] == 'a' && name[1] == 0) { + finishAnchor(); + if (!closing) { + char raw_href[300]; + if (base && links && + tagAttribute(html + i, (tag_end < html_len ? tag_end : html_len) - i, + "href", raw_href, sizeof raw_href) && + resolveUrl(base, raw_href, anchor_href, sizeof anchor_href)) { + flush(); + in_anchor = true; + anchor_start = static_cast(out_len); + } + } + } + i = tag_end < html_len ? tag_end + 1 : html_len; + for (int block = 0; blocks[block]; ++block) { + if (strcmp(name, blocks[block]) == 0) { + pending_space = false; + if (pending_newlines < 2) ++pending_newlines; + break; + } + } + continue; + } + if (c == '&') { + char entity[8]; + int width = 0; + const size_t used = decodeEntity(html + i, html_len - i, entity, &width); + if (used) { + for (int byte = 0; byte < width; ++byte) { + if (entity[byte] == ' ') { + if (started) pending_space = true; + } else { + flush(); emit(entity[byte]); started = true; + } + } + i += used; + continue; + } + flush(); emit('&'); started = true; ++i; + continue; + } + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (started) pending_space = true; + ++i; + continue; + } + flush(); emit(c); started = true; ++i; + } + finishAnchor(); + out[out_len < out_cap ? out_len : out_cap - 1] = 0; + if (link_count) *link_count = links_len; + return out_len; +} + +} // namespace ReaderContent \ No newline at end of file diff --git a/src/ui-touch/ReaderContent.h b/src/ui-touch/ReaderContent.h new file mode 100644 index 0000000..2c442fc --- /dev/null +++ b/src/ui-touch/ReaderContent.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ReaderContent { + +constexpr size_t HREF_CAPACITY = 240; + +struct Link { + uint32_t start; + uint32_t end; + char href[HREF_CAPACITY]; +}; + +bool resolveUrl(const char* base, const char* href, char* out, size_t cap); + +size_t htmlToText(const char* html, size_t html_len, + char* out, size_t out_cap, const char* base, + Link* links, size_t link_capacity, size_t* link_count); + +} // namespace ReaderContent \ No newline at end of file diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..faa25d7 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -111,6 +111,7 @@ #include "LuaAppHost.h" // sandboxed Lua apps (LUA_APPS.md Phase 1; self-gated on CAP_LUA_APPS) #include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) +#include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution #if defined(HAS_TOUCH_UI) #include @@ -22162,6 +22163,7 @@ static void openSignalInfoPopup() { // runs on a core-0 worker task so LVGL never blocks; the UI loop polls s_reader_dirty. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static const char kReaderDefaultUrl[] = "wadamesh.com"; +static const char kReaderHomeUrl[] = "sd:/home.htm"; static lv_obj_t* s_reader_root = nullptr; static lv_obj_t* s_reader_url_ta = nullptr; static lv_obj_t* s_reader_go = nullptr; @@ -22173,13 +22175,19 @@ static char* s_reader_text = nullptr; // PSRAM: extracted text (NUL static volatile bool s_reader_dirty = false; // worker -> UI: text/status changed static volatile bool s_reader_busy = false; // a fetch is in flight static volatile bool s_reader_ok = false; // last fetch produced a page (worker -> UI) +static volatile bool s_reader_home_missing = false; // initial SD probe failed; restore URL-entry state +static volatile bool s_reader_sd_busy = false; // open reader File owns the removable-card VFS +static TaskHandle_t s_reader_sd_owner = nullptr; +static volatile uint32_t s_reader_generation = 0; +static volatile uint32_t s_reader_task_generation = 0; +static char s_reader_pending_url[300] = ""; static char s_reader_msg[110] = ""; // status text (worker writes, UI shows) static TaskHandle_t s_reader_task = nullptr; static bool s_reader_pristine = false; // URL field still holds the untouched default static const size_t kReaderRawCap = 192 * 1024; // max raw HTML we buffer (PSRAM) static const size_t kReaderTextCap = 60 * 1024; // max extracted text we keep // Links found on the page: byte range in s_reader_text + resolved absolute href. -struct ReaderLink { uint32_t start, end; char href[240]; }; +using ReaderLink = ReaderContent::Link; static const int kReaderMaxLinks = 280; static ReaderLink* s_reader_links = nullptr; // PSRAM array, lazily allocated static int s_reader_nlinks = 0; @@ -22194,7 +22202,7 @@ static lv_obj_t* s_reader_back = nullptr; static lv_obj_t* s_reader_fwd = nullptr; static void closeReaderPage(); -static void readerNavigate(const char* in, bool push); +static bool readerNavigate(const char* in, bool push); static void readerUpdateNavButtons(); static void readerRenderBody(); static void readerSetAddrExpanded(bool exp); @@ -22207,135 +22215,107 @@ static bool readerCiPrefix(const char* s, size_t n, const char* pfx) { if (a >= 'A' && a <= 'Z') a += 32; if (b >= 'A' && b <= 'Z') b += 32; if (a != b) return false; } return true; } -// Decode one HTML entity at s[0]=='&'. Writes UTF-8 to out (>=4 bytes), sets *wrote, -// returns consumed input length; 0 if unrecognised (caller emits '&' literally). -static size_t readerEntity(const char* s, size_t n, char* out, int* wrote) { - static const struct { const char* name; const char* utf8; } named[] = { - {"amp;","&"},{"lt;","<"},{"gt;",">"},{"quot;","\""},{"apos;","'"},{"nbsp;"," "}, - {"mdash;","\xe2\x80\x94"},{"ndash;","\xe2\x80\x93"},{"hellip;","\xe2\x80\xa6"}, - {"lsquo;","\xe2\x80\x98"},{"rsquo;","\xe2\x80\x99"},{"ldquo;","\xe2\x80\x9c"}, - {"rdquo;","\xe2\x80\x9d"},{"copy;","\xc2\xa9"},{"reg;","\xc2\xae"},{"euro;","\xe2\x82\xac"}, - {"deg;","\xc2\xb0"},{"middot;","\xc2\xb7"},{"bull;","\xe2\x80\xa2"},{"trade;","\xe2\x84\xa2"}, +// A bounded Stream lets HTTPClient remove chunk framing while writing directly into +// PSRAM. Short-writing at the cap stops oversized pages without allocating a String. +class ReaderBufferStream final : public Stream { + public: + ReaderBufferStream(uint8_t* data, size_t capacity, uint32_t generation) + : data_(data), capacity_(capacity), generation_(generation) {} + using Print::write; + size_t write(uint8_t byte) override { return write(&byte, 1); } + size_t write(const uint8_t* data, size_t size) override { + const size_t before = size_; + const size_t room = size_ < capacity_ ? capacity_ - size_ : 0; + const size_t copied = size < room ? size : room; + if (copied) { + memcpy(data_ + size_, data, copied); + size_ += copied; + if (generation_ == s_reader_generation && (before >> 14) != (size_ >> 14)) { + snprintf(s_reader_msg, sizeof s_reader_msg, "Loading\xe2\x80\xa6 %uk", (unsigned)(size_ / 1024)); + s_reader_dirty = true; + } + } + return copied; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} + size_t size() const { return size_; } + bool full() const { return size_ == capacity_; } + + private: + uint8_t* data_; + size_t capacity_; + uint32_t generation_; + size_t size_ = 0; +}; + +enum class ReaderLocalResult : uint8_t { Ok, NoStorage, NotFound, ReadFailed }; + +static ReaderLocalResult readerReadLocal(const char* url, uint8_t* raw, size_t capacity, + size_t* total, uint32_t generation) { + fs::FS* storage = nullptr; + bool storage_claimed = false; + auto releaseStorage = [&]() { + if (!storage_claimed) return; + s_reader_sd_owner = nullptr; + s_reader_sd_busy = false; + storage_claimed = false; }; - for (auto& e : named) { size_t ln = strlen(e.name); - if (n > ln && strncmp(s + 1, e.name, ln) == 0) { int w = strlen(e.utf8); memcpy(out, e.utf8, w); *wrote = w; return ln + 1; } } - if (n > 3 && s[1] == '#') { // &#NNN; or &#xHH; - long cp = 0; bool hex = (s[2] == 'x' || s[2] == 'X'); size_t i = hex ? 3 : 2, start = i; - for (; i < n && s[i] != ';'; i++) { char c = s[i]; int d; - if (c >= '0' && c <= '9') d = c - '0'; - else if (hex && c >= 'a' && c <= 'f') d = c - 'a' + 10; - else if (hex && c >= 'A' && c <= 'F') d = c - 'A' + 10; else { i = start; break; } - cp = cp * (hex ? 16 : 10) + d; } - if (i > start && i < n && s[i] == ';' && cp > 0 && cp <= 0x10FFFF) { - int w = 0; - if (cp < 0x80) out[w++] = (char)cp; - else if (cp < 0x800) { out[w++] = 0xC0 | (cp >> 6); out[w++] = 0x80 | (cp & 0x3F); } - else if (cp < 0x10000) { out[w++] = 0xE0 | (cp >> 12); out[w++] = 0x80 | ((cp >> 6) & 0x3F); out[w++] = 0x80 | (cp & 0x3F); } - else { out[w++] = 0xF0 | (cp >> 18); out[w++] = 0x80 | ((cp >> 12) & 0x3F); out[w++] = 0x80 | ((cp >> 6) & 0x3F); out[w++] = 0x80 | (cp & 0x3F); } - *wrote = w; return i + 1; - } +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + s_reader_sd_busy = true; + s_reader_sd_owner = xTaskGetCurrentTaskHandle(); + storage_claimed = true; + if (sdAdoptLiveMount()) storage = &SD; + else if (!sdRuntimeLifecycleBusy() && fmSdTryMount()) storage = &SD; +#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + s_reader_sd_busy = true; + s_reader_sd_owner = xTaskGetCurrentTaskHandle(); + storage_claimed = true; + if (tanSdTryMount()) storage = &SD_MMC; +#endif + if (!storage) { releaseStorage(); return ReaderLocalResult::NoStorage; } + + char path[300]; + snprintf(path, sizeof path, "%s", url + 3); + char* query = strchr(path, '?'); + if (query) *query = 0; + if (path[0] != '/') { releaseStorage(); return ReaderLocalResult::NotFound; } + + markSdIo(); + File file = storage->open(path, "r"); + if (!file || file.isDirectory()) { + if (file) file.close(); + releaseStorage(); + return ReaderLocalResult::NotFound; } - return 0; -} -// Extract attribute `attr` (e.g. "href") from a tag body h[0..len) into out. Handles -// quoted ("..", '..') and unquoted values. Returns true if found. -static bool readerTagAttr(const char* h, size_t len, const char* attr, char* out, size_t cap) { - size_t al = strlen(attr); - for (size_t i = 0; i + al + 1 < len; i++) { - if (!readerCiPrefix(h + i, len - i, attr)) continue; - size_t j = i + al; while (j < len && (h[j] == ' ' || h[j] == '\t')) j++; - if (j >= len || h[j] != '=') continue; - j++; while (j < len && (h[j] == ' ' || h[j] == '\t')) j++; - char q = 0; if (j < len && (h[j] == '"' || h[j] == '\'')) { q = h[j]; j++; } - size_t o = 0; - while (j < len && o + 1 < cap) { char c = h[j]; if (q ? (c == q) : (c == ' ' || c == '>' || c == '\t')) break; out[o++] = c; j++; } - out[o] = 0; return o > 0; - } - return false; -} -// Resolve href against base into out (absolute). Returns false for unusable schemes -// (#fragment, javascript:, mailto:, tel:, data:). -static bool readerResolveUrl(const char* base, const char* href, char* out, size_t cap) { - while (*href == ' ') href++; - size_t hl = strlen(href); - if (!hl || href[0] == '#') return false; - if (readerCiPrefix(href, hl, "javascript:") || readerCiPrefix(href, hl, "mailto:") || - readerCiPrefix(href, hl, "tel:") || readerCiPrefix(href, hl, "data:")) return false; - if (readerCiPrefix(href, hl, "http://") || readerCiPrefix(href, hl, "https://")) { snprintf(out, cap, "%s", href); } - else { - const char* se = strstr(base, "://"); if (!se) return false; - int sl = (int)(se - base); const char* host = se + 3; - const char* he = strchr(host, '/'); int hlen = he ? (int)(he - host) : (int)strlen(host); - if (href[0] == '/' && href[1] == '/') snprintf(out, cap, "%.*s:%s", sl, base, href); // //host/path - else if (href[0] == '/') snprintf(out, cap, "%.*s://%.*s%s", sl, base, hlen, host, href); // root-relative - else { // relative to base dir - const char* ls = strrchr(base, '/'); - if (ls && ls > se + 2) snprintf(out, cap, "%.*s%s", (int)(ls - base + 1), base, href); - else snprintf(out, cap, "%.*s://%.*s/%s", sl, base, hlen, host, href); + + bool read_failed = false; + while (*total < capacity && file.available()) { + const size_t request = ((capacity - *total) < 4096) ? (capacity - *total) : 4096; + const int read_count = file.read(raw + *total, request); + if (read_count <= 0) { read_failed = true; break; } + *total += (size_t)read_count; + if (generation == s_reader_generation && ((*total) & 0x3FFF) == 0) { + snprintf(s_reader_msg, sizeof s_reader_msg, "Opening SD page\xe2\x80\xa6 %uk", (unsigned)(*total / 1024)); + s_reader_dirty = true; } + vTaskDelay(1); } - char* frag = strchr(out, '#'); if (frag) *frag = 0; - return out[0] != 0; + file.close(); + releaseStorage(); + return read_failed ? ReaderLocalResult::ReadFailed : ReaderLocalResult::Ok; } -// One-pass HTML -> text. Drops comments + "; + char text[256]; + ReaderContent::Link links[4] = {}; + size_t link_count = 0; + const size_t text_len = ReaderContent::htmlToText( + html, strlen(html), text, sizeof text, "sd:/home.htm", + links, 4, &link_count); + + assert(text_len == strlen(text)); + assert(strstr(text, "Bookmarks") != nullptr); + assert(strstr(text, "CNN") != nullptr); + assert(strstr(text, "Local news & weather") != nullptr); + assert(strstr(text, "12345") == nullptr); + assert(link_count == 2); + assert(strcmp(links[0].href, "https://lite.cnn.com") == 0); + assert(strcmp(links[1].href, "sd:/news.htm") == 0); + assert(strncmp(text + links[0].start, "CNN", links[0].end - links[0].start) == 0); + assert(strncmp(text + links[1].start, "Local news", links[1].end - links[1].start) == 0); + + char resolved[240]; + assert(ReaderContent::resolveUrl("sd:/bookmarks/home.htm", "../index.htm", + resolved, sizeof resolved)); + assert(strcmp(resolved, "sd:/bookmarks/../index.htm") == 0); + assert(ReaderContent::resolveUrl("sd:/home.htm", "//text.npr.org", + resolved, sizeof resolved)); + assert(strcmp(resolved, "https://text.npr.org") == 0); + assert(!ReaderContent::resolveUrl("sd:/home.htm", "javascript:alert(1)", + resolved, sizeof resolved)); + + static const char attribute_html[] = + "Right"; + link_count = 0; + ReaderContent::htmlToText(attribute_html, strlen(attribute_html), text, sizeof text, + "sd:/home.htm", links, 4, &link_count); + assert(link_count == 1); + assert(strcmp(links[0].href, "sd:/right.htm") == 0); + return 0; +} \ No newline at end of file From 3307d87e9cdeb39cc6b9b22e7fe2de28940ea6ff Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 24 Aug 2026 09:03:41 -0600 Subject: [PATCH 05/22] touch: add master "At a glance" toggle, move while-locked switch under it Options > Display previously only had "At a glance while locked" (opt-in, off by default). Add a master "At a glance" switch above it (on by default, matching the previously-unconditional behavior) that gates the whole feature, and move the while-locked switch out of Options > Lock screen to sit under it. The while-locked switch's own saved preference is left alone when the master is toggled off -- only its editability follows the master, so re-enabling the master always restores its true prior state rather than resetting it. Signed-off-by: Tesso M Costa --- src/helpers/esp32/TouchPrefsStore.cpp | 2 + src/helpers/esp32/TouchPrefsStore.h | 2 + src/ui-touch/UITask.cpp | 91 ++++++++++++++++++++------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index f2c8a2a..133ae40 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1835,6 +1835,8 @@ bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_ void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); } bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; } void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); } +bool touchPrefsGetGlanceEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_en", 1) != 0; } +void touchPrefsSetGlanceEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_en", on ? 1 : 0); } #if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 9be91d3..4d7205f 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -430,6 +430,8 @@ bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; on void touchPrefsSetLockOnScreenOff(bool on); bool touchPrefsGetGlanceWhenLocked(); // "at a glance" also fires while manually/idle locked, not just unlocked+dimmed (default false) void touchPrefsSetGlanceWhenLocked(bool on); +bool touchPrefsGetGlanceEnabled(); // master "at a glance" feature toggle (default true) +void touchPrefsSetGlanceEnabled(bool on); /** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 = * mute @-mentions. Suppresses the notification SOUND for that channel (the diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b902cc4..0ca8f6b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2105,6 +2105,8 @@ struct SettingsModalState { lv_obj_t* exp_boost_sw; lv_obj_t* exp_dc_sw; lv_obj_t* wifi_sw; + lv_obj_t* glance_en_sw; + lv_obj_t* glance_locked_sw; lv_obj_t* wifi_ssid_ta; lv_obj_t* wifi_pwd_ta; /** Transports modal: live STA line (IP when connected, else Arduino WiFi status). */ @@ -10851,6 +10853,23 @@ static void glanceWhenLockedToggleCb(lv_event_t* e) { } } +// Master "At a glance" toggle (Options > Display): gates the whole feature. +// The secondary "while locked" switch's own saved preference is left +// alone -- only its editability follows the master, so re-enabling always +// restores its true prior state. +static void glanceEnabledToggleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); +#if defined(ESP32) + touchPrefsSetGlanceEnabled(on); +#endif + if (g_set_modal.glance_locked_sw) { + if (on) lv_obj_clear_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); + else lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); + } + if (g_lv.task) g_lv.task->showAlert(on ? TR("At a glance enabled") : TR("At a glance disabled"), 1200); +} + #if CAP_TRACKBALL // Invert the scrollball direction everywhere it drives motion (cursor, map pan, // emoji selector). Cached in s_tb_reverse so the per-tick poll never hits NVS. @@ -11927,6 +11946,41 @@ static void buildDeviceSettings(int sec) { y += LV_MAX(40, h + 12); } + /* At a glance: master enable for the "at a glance" message-preview overlay + (see atGlanceShow()/newMsgImpl()) -- on by default, matching the + previously-unconditional behavior. Gates the "while locked" switch below. */ + bool glance_on = true; + { + int h = settingsRowLabel(body, y, 6, TR("At a glance"), COLOR_SUB, nullptr, 56); + g_set_modal.glance_en_sw = lv_switch_create(body); + lv_obj_align(g_set_modal.glance_en_sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + glance_on = touchPrefsGetGlanceEnabled(); + if (glance_on) lv_obj_add_state(g_set_modal.glance_en_sw, LV_STATE_CHECKED); +#endif + lv_obj_add_event_cb(g_set_modal.glance_en_sw, glanceEnabledToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } + +#if !defined(HAS_TDISPLAY_P4) + /* "At a glance" also fires while manually/idle locked, not just while + unlocked+dimmed -- off by default since it's a real trade-off (message + text becomes readable off a locked device). Disabled (not cleared) while + the master switch above is off. Moved here from Options > Lock screen so + it sits with the feature it modifies. */ + { + int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); + g_set_modal.glance_locked_sw = lv_switch_create(body); + lv_obj_align(g_set_modal.glance_locked_sw, LV_ALIGN_TOP_RIGHT, 0, y); +#if defined(ESP32) + if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_CHECKED); + if (!glance_on) lv_obj_add_state(g_set_modal.glance_locked_sw, LV_STATE_DISABLED); +#endif + lv_obj_add_event_cb(g_set_modal.glance_locked_sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } +#endif + #if defined(HAS_EXPANSION_KIT) /* Show Sensors tab (V4 Expansion Kit): toggles the bottom Sensors tab + the Home env widget. Also auto-hidden when no environment sensor is attached. @@ -12416,21 +12470,6 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } - - // "At a glance" (see atGlanceShow()/newMsgImpl()) normally only fires while - // unlocked-but-idle-dimmed; this opts in to it firing while manually locked - // too, i.e. message previews become readable off a locked device -- off by - // default since that's a real trade-off, not just a convenience toggle. - { - int h = settingsRowLabel(body, y, 6, TR("At a glance while locked"), COLOR_SUB, nullptr, 56); - lv_obj_t* sw = lv_switch_create(body); - lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); -#if defined(ESP32) - if (touchPrefsGetGlanceWhenLocked()) lv_obj_add_state(sw, LV_STATE_CHECKED); -#endif - lv_obj_add_event_cb(sw, glanceWhenLockedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); - y += LV_MAX(40, h + 12); - } #endif @@ -45933,20 +45972,24 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex #if defined(HAS_TOUCH_UI) // "At a glance": device unlocked but idle-dimmed to off -- light the plain // black glance overlay instead of the full app (see atGlanceShow() above). - // Skipped if DND is on. Manually locked is ALSO skipped unless the user - // opted in via Options > Lock screen > "At a glance while locked" - // (touchPrefsGetGlanceWhenLocked()) -- off by default since it's a real - // trade-off (message text becomes readable off a locked device). `_screen_off` - // covers the first message of a burst (wake + show); `s_glance_lit_ms` - // (already showing, from an earlier message in the same window) covers later - // ones -- update the text and restart the 5 s window instead of dropping - // them, since our own wake already cleared _screen_off by then. + // Master-disabled via Options > Display > "At a glance" + // (touchPrefsGetGlanceEnabled()) skips this entirely. Skipped if DND is on. + // Manually locked is ALSO skipped unless the user opted in via Options > + // Display > "At a glance while locked" (touchPrefsGetGlanceWhenLocked()) + // -- off by default since it's a real trade-off (message text becomes + // readable off a locked device). `_screen_off` covers the first message of + // a burst (wake + show); `s_glance_lit_ms` (already showing, from an + // earlier message in the same window) covers later ones -- update the text + // and restart the 5 s window instead of dropping them, since our own wake + // already cleared _screen_off by then. #if defined(ESP32) + const bool glance_enabled_ok = touchPrefsGetGlanceEnabled(); const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked(); #else + const bool glance_enabled_ok = true; const bool glance_locked_ok = !_manual_lock; #endif - if (!dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { + if (glance_enabled_ok && !dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) { const bool was_off = _screen_off; atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst if (was_off) { From 5c029e752608acd6a4b876f129f893f60e2ce843 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Mon, 24 Aug 2026 15:09:53 -0400 Subject: [PATCH 06/22] Latch keyboard symbol modifiers Signed-off-by: Michael A. Cojocari --- DEVICES.md | 6 + TLORA_PAGER_PORT.md | 12 +- TLORA_PAGER_SHORTCUTS.md | 37 +++--- src/helpers/input/LatchedModifier.h | 91 ++++++++++++++ src/helpers/input/PagerKeyboard.cpp | 141 ++++++--------------- src/helpers/input/PagerKeyboard.h | 17 ++- src/helpers/input/PagerKeyboardState.h | 113 +++++++++++++++++ src/helpers/input/TDeckKeyboard.cpp | 147 ++++++++++++++++++++-- src/helpers/input/TDeckKeyboard.h | 18 ++- src/helpers/input/TDeckKeyboardState.h | 111 +++++++++++++++++ src/ui-touch/UITask.cpp | 61 +++------ test/test_latched_modifier.cpp | 62 ++++++++++ test/test_pager_keyboard_state.cpp | 87 +++++++++++++ test/test_tdeck_keyboard_state.cpp | 164 +++++++++++++++++++++++++ 14 files changed, 866 insertions(+), 201 deletions(-) create mode 100644 src/helpers/input/LatchedModifier.h create mode 100644 src/helpers/input/PagerKeyboardState.h create mode 100644 src/helpers/input/TDeckKeyboardState.h create mode 100644 test/test_latched_modifier.cpp create mode 100644 test/test_pager_keyboard_state.cpp create mode 100644 test/test_tdeck_keyboard_state.cpp diff --git a/DEVICES.md b/DEVICES.md index a863ef6..4820503 100644 --- a/DEVICES.md +++ b/DEVICES.md @@ -23,6 +23,10 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases - **T-Deck**: the everything device: touch, physical keyboard, trackball cursor or d-pad navigation, microSD (deep 5000-message chat history, map tile packs, data storage), GPS on the Plus, notification sounds through the I2S speaker. + Tap Sym or Alt for one symbol, or double-tap either to lock the symbol layer; + this needs [LilyGO keyboard-controller firmware with raw matrix mode](https://github.com/Xinyuan-LilyGO/T-Deck/tree/master/examples/Keyboard_ESP32C3) + (June 2025 or newer). Older controller firmware keeps normal typing and + reports the unavailable latch mode on Serial. - **Heltec V4 + TFT**: touch UI with the on-screen keyboard; the optional Expansion Kit adds environment sensors (home-screen chart) and a piezo buzzer. V4.3 boards get the switchable high-gain receive LNA toggle. @@ -33,6 +37,8 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases - **T-Lora Pager**: no touchscreen at all — the QWERTY keyboard and the rotary encoder drive everything (Alt+turn free-scrolls a page; see [TLORA_PAGER_SHORTCUTS.md](TLORA_PAGER_SHORTCUTS.md) for the full key map). + Tap Fn/Alt for one symbol or double-tap it to lock the symbol layer; physical + hold combinations keep their existing behavior. GPS, microSD, keyboard backlight, lock screen and notification sound through the onboard codec and amp all work. Two builds, one per radio: LR1121 and SX1262 — flashing the wrong one leaves you with no radio, so check the label diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md index 08c1d8e..89916f2 100644 --- a/TLORA_PAGER_PORT.md +++ b/TLORA_PAGER_PORT.md @@ -225,8 +225,8 @@ WASD-panning). `updatePagerAltShiftChord()` toggles Caps Lock ONLY while a text field is actually being edited (same "ta" derivation `handleHwKey()`'s TLORA_PAGER branch already uses to tell a bound-but-unfocused field apart from one - actually being edited), and jumps straight to Home (`navGoToMainTab`) - everywhere else. + actually being edited), and is a no-op everywhere else. Alt+Backspace is + the context-independent Home shortcut. 3. **@-mention contact picker made encoder-navigable** (commit `57802eb`) — the same unreachable-without-touch problem the accent box had, fixed the same way (`mentionNavRestyle()`/`mentionNavConfirm()` mirror @@ -622,9 +622,9 @@ existing boards. Notes: `pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s `updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text field is actually being edited (same "ta" derivation as `handleHwKey()`'s - TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere - else — toggling Caps Lock with no field to see it change in was reported as - surprising/purposeless outside of typing. + TLORA_PAGER branch), and is a no-op everywhere else — toggling Caps Lock + with no field to see it change in was reported as surprising/purposeless + outside of typing. Alt+Backspace owns the Home shortcut. See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the @@ -751,7 +751,7 @@ existing boards. Notes: | Accent-variant popup keyboard nav | Fn+Space arms it; encoder walks variants; encoder click/Enter confirms; Backspace cancels (`s_accentnav_active`/`accentNavRestyle()`/`accentNavConfirm()`) | medium | done | | @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done | | Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done | -| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done | +| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; no-op everywhere else. Fn+Backspace jumps Home — `PagerKeyboard.cpp` only reports the chords, `UITask.cpp` decides the effects | small | done | | Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done | | App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done | | SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) | diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index 0b4abe3..3519edf 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -40,7 +40,7 @@ while editing a text field, where the keys type normally. | Gesture | Action | Keyboard equivalent | |---|---|---| -| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) | +| Turn | Move focus to the next/previous item on screen | none — encoder only | | Short click | Select / confirm the focused item | **Enter** | | Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** | | **Fn (Alt) + turn**, on a main tab | Move between the 5 main tabs (Mail / Contacts / Home / Map / Settings) | **M / C / H / A / S** jumps directly | @@ -93,29 +93,19 @@ text box → Send**, in that order, in both directions — nothing is skipped. Turning past the △ chip (toward the messages) always jumps straight to the newest message, regardless of channel or DM. -### Backspace and Fn (Alt) — leaving vs. catching up +### Backspace — catching up -These two keys are your shortcuts in and out of "reading mode" on this -screen, and they do different things depending on whether you've got unread -messages: +**Backspace (tap)** jumps to whichever message needs your attention: -- **Fn (Alt) tapped alone** — jumps straight to the **newest** message and - moves focus into the text box, ready to type a reply. This is the - deliberate "I'm done reading, let me respond" gesture — it works regardless - of where your focus currently is in the chat. -- **Backspace (tap)** — jumps to whichever message needs your attention: - - If there are unread messages, it jumps to the first one — right below the - **"NEW ----"** divider — and selects it, so you can then turn forward - through your unread messages in order, oldest-of-the-unread first. - - If nothing is unread, it jumps to the newest message instead (same - destination as Fn+Alt, but leaves focus on the message itself rather than - the text box). +- If there are unread messages, it jumps to the first one — right below the + **"NEW ----"** divider — and selects it, so you can then turn forward + through your unread messages in order, oldest-of-the-unread first. +- If nothing is unread, it jumps to the newest message instead (same + destination, with focus left on the message). Backspace's override above only applies while you're inside an open chat and *not* actively editing the text box — the usual rule from the top of this guide; with a field focused, Backspace deletes a character as normal. -Fn (Alt) tap's override fires the same way whether or not the text box -already has focus, since landing there is the whole point of the gesture. ## Keyboard layout @@ -128,7 +118,9 @@ a s d f g h j k l [Enter] [Space] ``` -Hold **Fn (Alt)** for numbers/symbols instead: +Hold **Fn (Alt)** for numbers/symbols, or tap it once to apply this layer to +the next key only. Double-tap Fn to lock the symbol layer; tap it again to +return to letters. ``` 1 2 3 4 5 6 7 8 9 0 @@ -162,7 +154,7 @@ Hold **Fn (Alt)** for numbers/symbols instead: | **Space** (tap) | Types a space | — | | **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — | | **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) | -| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder. In an open chat: jumps to the newest message and focuses the text box instead — see [Chat screen](#chat-screen) | +| **Fn (Alt)** (tap / double-tap) | Next key uses symbols / lock symbols until tapped again | Same | The **BOOT** button (top of the device) instantly wakes the screen from idle-dim. It does *not* unlock a screen you've manually locked with the @@ -249,13 +241,14 @@ that changes keyboard language. | Input | Action | |---|---| | M / C / H / A / S (top-level main screens only) | Open Mail / Contacts / Home / Map / Settings | -| Turn encoder (or Fn tap = NEXT only) | Move focus | +| Turn encoder | Move focus | | Turn, at the loaded edge of a chat | Load more history, keep moving — only exits the list at the true oldest/newest message | | Click encoder (or Enter) | Select / confirm | | Hold encoder ~1s (or hold Backspace ~1s) | Back | | Fn + turn (main tab) | Switch tabs | | Fn + turn (page/chat) | Scroll | -| Fn tap alone | Next field (in a chat: jump to newest message + focus the text box) | +| Fn tap alone | Use the symbol layer for the next key | +| Fn double-tap | Lock the symbol layer; tap Fn again to unlock | | Hold Shift + letter | Momentary uppercase | | Fn + Shift (editing a field) | Toggle Caps Lock | | Fn + Shift (not editing a field) | Nothing | diff --git a/src/helpers/input/LatchedModifier.h b/src/helpers/input/LatchedModifier.h new file mode 100644 index 0000000..4a30c8a --- /dev/null +++ b/src/helpers/input/LatchedModifier.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +class LatchedModifier { + public: + static constexpr uint32_t DOUBLE_TAP_MS = 400; + + void press() { + held_ = true; + used_while_held_ = false; + } + + void release(uint32_t now_ms) { + held_ = false; + if (discard_release_) { + discard_release_ = false; + used_while_held_ = false; + return; + } + if (used_while_held_) return; + tap(now_ms); + } + + void tap(uint32_t now_ms) { + if (locked_) { + clearLatched(); + return; + } + if (one_shot_ && (uint32_t)(now_ms - last_tap_ms_) <= DOUBLE_TAP_MS) { + one_shot_ = false; + locked_ = true; + last_tap_ms_ = 0; + return; + } + one_shot_ = true; + last_tap_ms_ = now_ms; + } + + bool consumeForKey() { + const bool active_now = held_ || one_shot_ || locked_; + if (held_) used_while_held_ = true; + if (one_shot_) { + one_shot_ = false; + last_tap_ms_ = 0; + } + return active_now; + } + + void markHeldUsed() { + if (!held_) return; + used_while_held_ = true; + one_shot_ = false; + last_tap_ms_ = 0; + } + + void clearLatched() { + one_shot_ = false; + locked_ = false; + last_tap_ms_ = 0; + } + + void discard() { + clearLatched(); + if (held_) { + used_while_held_ = true; + discard_release_ = true; + } + } + + void baseline(bool physically_held) { + clearLatched(); + held_ = physically_held; + used_while_held_ = physically_held; + discard_release_ = physically_held; + } + + bool held() const { return held_; } + bool latched() const { return one_shot_ || locked_; } + bool active() const { return held_ || one_shot_ || locked_; } + bool locked() const { return locked_; } + + private: + bool held_ = false; + bool used_while_held_ = false; + bool one_shot_ = false; + bool locked_ = false; + bool discard_release_ = false; + uint32_t last_tap_ms_ = 0; +}; \ No newline at end of file diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index e357368..4e39a1d 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include "PagerKeyboardState.h" #ifndef KB_INT #define KB_INT 6 @@ -14,30 +14,10 @@ #ifndef KB_BACKLIGHT #define KB_BACKLIGHT 46 #endif -#define KB_ROWS 4 -#define KB_COLS 10 - -// Matrix legend, s_keymap[row][col] — same physical keyboard PCB as -// trail-mate's working LR1121 pager build, cross-checked there. '\0' = no -// character at that position (dead cell, or intercepted as a modifier below). -static constexpr char s_keymap[KB_ROWS][KB_COLS] = { - {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, - {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'}, - {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'}, - {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, -}; -// Alt layer (symbols/numbers) — this hardware has no separate physical Symbol -// key, so Alt alone drives it (matches trail-mate's has_symbol_key=false path). -static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { - {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, - {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'}, - {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'}, - {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, -}; - // Modifier/special-key positions: 0-based (row*KB_COLS + col), matching the -// TCA8418 raw event's (code & 0x7F) - 1. Alt is a hold (symbol layer while -// held); Shift is a hold too (momentary uppercase on the base layer, real +// TCA8418 raw event's (code & 0x7F) - 1. Alt selects symbols while held, a +// solo tap selects them for one key, and a double tap locks the layer. Shift +// is a hold too (momentary uppercase on the base layer, real // Shift-key semantics) — held Alt THEN a Shift press instead chords into // Alt+Shift, reported via s_alt_shift_chord_pending. What that chord DOES is // a UI-level decision (UITask.cpp): Caps Lock toggle while editing a text @@ -55,29 +35,21 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { // harmless in practice since the intended gesture is // hold-Alt-tap-Shift-release-both, not holding all three simultaneously. // Backspace (row2,col9) sits one column over from Shift, so Alt+Backspace -// doesn't share this exact ghosting risk with any base-layer letter. -static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) -static constexpr uint8_t kShiftPos = 2 * KB_COLS + 8; // row2,col8 ('\0' in both layers) -static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in both layers) -static constexpr uint8_t kSpacePos = 3 * KB_COLS + 0; // row3,col0 (' ' in both layers) +// doesn't share this exact ghosting risk with any base-layer letter. The maps +// and positions live in PagerKeyboardState so host tests exercise the exact +// production translation logic. static Adafruit_TCA8418 s_kb; static bool s_inited = false; -static bool s_alt = false; -static bool s_alt_used = false; // Alt consumed as a modifier since it was last pressed -static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile -static bool s_caps = false; -static bool s_shift_held = false; // momentary Shift, mirrors s_backspace_held/s_space_held -static bool s_alt_shift_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltShiftChord() -static bool s_alt_backspace_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltBackspaceChord() -static bool s_backspace_held = false; -static bool s_space_held = false; +static PagerKeyboardState s_state; -// Single-producer (poll) / single-consumer (UI thread) ring — same pattern as -// TDeckKeyboard.cpp; byte indices are atomic enough for SPSC without a lock. -static volatile uint8_t s_ring[16]; -static volatile uint8_t s_head = 0; -static volatile uint8_t s_tail = 0; +// Single-producer (poll) / single-consumer ring. Polling currently happens on +// the UI task, but the short critical section preserves the header's contract +// if a future board moves I2C polling to another core. +static portMUX_TYPE s_ring_mux = portMUX_INITIALIZER_UNLOCKED; +static uint8_t s_ring[16]; +static uint8_t s_head = 0; +static uint8_t s_tail = 0; static bool s_bl_ready = false; // This framework's Arduino-ESP32 core only has the channel-based LEDC API @@ -88,16 +60,19 @@ static bool s_bl_ready = false; static constexpr uint8_t kKbBacklightPwmChannel = 0; static void ringPush(uint8_t c) { + portENTER_CRITICAL(&s_ring_mux); const uint8_t nh = (uint8_t)((s_head + 1) & 15); if (nh != s_tail) { // drop if the ring is full s_ring[s_head] = c; s_head = nh; } + portEXIT_CRITICAL(&s_ring_mux); } void pagerKeyboardBegin() { if (s_inited) return; - s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && s_kb.matrix(KB_ROWS, KB_COLS); + s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && + s_kb.matrix(PagerKeyboardState::ROWS, PagerKeyboardState::COLS); if (!s_inited) return; s_kb.flush(); pinMode(KB_INT, INPUT_PULLUP); // TCA8418 INT is open-drain active-low; not ISR-driven here (see .h) @@ -115,58 +90,20 @@ void pagerKeyboardPoll() { const bool pressed = (raw & 0x80) != 0; const uint8_t code = (uint8_t)((raw & 0x7F) - 1); - if (code == kAltPos) { - // Solo tap (press+release, nothing else in between) vs. a modifier hold - // (symbol-layer typing, or the rotary encoder's Alt+turn via - // pagerKeyboardMarkAltUsed()) — only the former queues a pending tap. - if (pressed) { s_alt = true; s_alt_used = false; } - else { if (!s_alt_used) s_alt_tap_pending = true; s_alt = false; } - continue; - } - // Any other key event while Alt is held means Alt is being used as a - // modifier, not tapped solo — cancels the pending-tap interpretation. - if (s_alt && pressed) s_alt_used = true; - if (code == kShiftPos) { - if (pressed) { - if (s_alt) s_alt_shift_chord_pending = true; // Alt (Fn) held + Shift press = chord (UI decides the effect) - else s_shift_held = true; - } else { - s_shift_held = false; - } - continue; - } - if (code == kBackspacePos) { - if (pressed) { - // Alt+Backspace is a distinct chord (jump Home, everywhere -- see - // pagerKeyboardConsumeAltBackspaceChord()), not a delete: suppress - // both the '\b' ring-push AND s_backspace_held, so the plain- - // Backspace hold gestures (back / unlock) never also see this press. - if (s_alt) s_alt_backspace_chord_pending = true; - else { s_backspace_held = true; ringPush('\b'); } - } else { - s_backspace_held = false; - } - continue; - } - if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; } - if (!pressed) continue; // base/symbol keys only emit on press - - const uint8_t row = code / KB_COLS; - const uint8_t col = code % KB_COLS; - if (row >= KB_ROWS) continue; // a GPIO event outside the matrix, not a key - - char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col]; - if (c == '\0') continue; - // Caps Lock and a held Shift both mean "uppercase," base layer only. - if ((s_caps || s_shift_held) && !s_alt) c = (char)toupper((unsigned char)c); - ringPush((uint8_t)c); + const uint8_t key = s_state.event(code, pressed, millis()); + if (key) ringPush(key); } } int pagerKeyboardReadKey() { - if (s_tail == s_head) return 0; + portENTER_CRITICAL(&s_ring_mux); + if (s_tail == s_head) { + portEXIT_CRITICAL(&s_ring_mux); + return 0; + } const uint8_t c = s_ring[s_tail]; s_tail = (uint8_t)((s_tail + 1) & 15); + portEXIT_CRITICAL(&s_ring_mux); return c; } @@ -179,32 +116,26 @@ void pagerKeyboardSetBacklight(uint8_t level) { ledcWrite(kKbBacklightPwmChannel, level); } -bool pagerKeyboardAltHeld() { return s_alt; } +bool pagerKeyboardAltHeld() { return s_state.altHeld(); } -void pagerKeyboardMarkAltUsed() { s_alt_used = true; } +void pagerKeyboardMarkAltUsed() { s_state.markAltUsed(); } -bool pagerKeyboardConsumeAltTap() { - if (!s_alt_tap_pending) return false; - s_alt_tap_pending = false; - return true; +void pagerKeyboardDiscardAlt() { + s_state.discardAlt(); } -bool pagerKeyboardBackspaceHeld() { return s_backspace_held; } +bool pagerKeyboardBackspaceHeld() { return s_state.backspaceHeld(); } -bool pagerKeyboardSpaceHeld() { return s_space_held; } +bool pagerKeyboardSpaceHeld() { return s_state.spaceHeld(); } bool pagerKeyboardConsumeAltShiftChord() { - if (!s_alt_shift_chord_pending) return false; - s_alt_shift_chord_pending = false; - return true; + return s_state.consumeAltShiftChord(); } -void pagerKeyboardToggleCaps() { s_caps = !s_caps; } +void pagerKeyboardToggleCaps() { s_state.toggleCaps(); } bool pagerKeyboardConsumeAltBackspaceChord() { - if (!s_alt_backspace_chord_pending) return false; - s_alt_backspace_chord_pending = false; - return true; + return s_state.consumeAltBackspaceChord(); } #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 774e552..7d3eca4 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -3,8 +3,8 @@ // T-LoRa Pager physical QWERTY keyboard: a TCA8418 I2C matrix controller (4 // rows x 10 cols, addr 0x34) on the shared I2C bus (SDA 3 / SCL 2). Unlike -// the T-Deck's keyboard (a second MCU that resolves ASCII itself before we -// ever see a byte), the TCA8418 only reports raw row/col matrix events — the +// the T-Deck's keyboard (a second MCU, with raw mode only on newer firmware), +// the TCA8418 always reports raw row/col matrix events — the // keymap + shift/sym/alt state machine lives HERE, so pagerKeyboardReadKey() // produces the same final ASCII/control-code stream handleHwKey() already // expects from the T-Deck; no UI-side changes needed to consume it. @@ -13,7 +13,7 @@ // must be called from a single, consistent context each tick (whichever task // ends up owning it — wired in a later milestone; this board has no // pre-existing shared-bus task the way the T-Deck's touch poll does). -// pagerKeyboardReadKey() only pops from a lock-free ring and is safe to call +// pagerKeyboardReadKey() only pops from a critical-section-protected ring and is safe to call // from the UI thread regardless of which context polls. #if defined(HAS_PAGER_KEYBOARD) && defined(ESP32) @@ -46,15 +46,12 @@ bool pagerKeyboardAltHeld(); /** Mark the currently-held Alt as "used as a modifier" — call this when some * other gesture (e.g. the rotary encoder's Alt+turn) consumes the hold, so - * releasing Alt afterward isn't also read as a solo tap by - * pagerKeyboardConsumeAltTap(). */ + * releasing Alt afterward does not arm the one-shot symbol layer. */ void pagerKeyboardMarkAltUsed(); -/** 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 - * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */ -bool pagerKeyboardConsumeAltTap(); +/** Cancel one-shot/locked Alt, suppress a later release from a held Alt, and + * discard pending Alt chords. Used when input is intentionally discarded. */ +void pagerKeyboardDiscardAlt(); /** True while Backspace is physically held WITHOUT Alt (raw state, mirrors * pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b' diff --git a/src/helpers/input/PagerKeyboardState.h b/src/helpers/input/PagerKeyboardState.h new file mode 100644 index 0000000..f2c77fc --- /dev/null +++ b/src/helpers/input/PagerKeyboardState.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +#include "LatchedModifier.h" + +class PagerKeyboardState { + public: + static constexpr uint8_t ROWS = 4; + static constexpr uint8_t COLS = 10; + static constexpr uint8_t ALT_POS = 2 * COLS; + static constexpr uint8_t SHIFT_POS = 2 * COLS + 8; + static constexpr uint8_t BACKSPACE_POS = 2 * COLS + 9; + static constexpr uint8_t SPACE_POS = 3 * COLS; + + uint8_t event(uint8_t code, bool pressed, uint32_t now_ms) { + static const char base[ROWS][COLS] = { + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'}, + {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, + }; + static const char symbols[ROWS][COLS] = { + {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, + {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'}, + {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, + }; + + if (code == ALT_POS) { + if (pressed) alt_.press(); + else alt_.release(now_ms); + return 0; + } + if (code == SHIFT_POS) { + if (pressed) { + if (alt_.held()) { + alt_.markHeldUsed(); + alt_shift_chord_pending_ = true; + } else { + shift_held_ = true; + } + } else { + shift_held_ = false; + } + return 0; + } + if (code == BACKSPACE_POS) { + if (pressed) { + if (alt_.held()) { + alt_.markHeldUsed(); + alt_backspace_chord_pending_ = true; + } else { + alt_.consumeForKey(); + backspace_held_ = true; + return '\b'; + } + } else { + backspace_held_ = false; + } + return 0; + } + if (code == SPACE_POS) { + space_held_ = pressed; + if (pressed) { + alt_.consumeForKey(); + return ' '; + } + return 0; + } + if (!pressed) return 0; + + const uint8_t row = code / COLS; + const uint8_t col = code % COLS; + if (row >= ROWS) return 0; + const bool symbol_layer = alt_.consumeForKey(); + char key = symbol_layer ? symbols[row][col] : base[row][col]; + if (key == '\0') return 0; + if ((caps_ || shift_held_) && !symbol_layer && key >= 'a' && key <= 'z') key -= 32; + return (uint8_t)key; + } + + bool altHeld() const { return alt_.held(); } + void markAltUsed() { alt_.markHeldUsed(); } + void discardAlt() { + alt_.discard(); + alt_shift_chord_pending_ = false; + alt_backspace_chord_pending_ = false; + } + bool backspaceHeld() const { return backspace_held_; } + bool spaceHeld() const { return space_held_; } + bool consumeAltShiftChord() { + const bool pending = alt_shift_chord_pending_; + alt_shift_chord_pending_ = false; + return pending; + } + void toggleCaps() { caps_ = !caps_; } + bool consumeAltBackspaceChord() { + const bool pending = alt_backspace_chord_pending_; + alt_backspace_chord_pending_ = false; + return pending; + } + + private: + LatchedModifier alt_; + bool caps_ = false; + bool shift_held_ = false; + bool alt_shift_chord_pending_ = false; + bool alt_backspace_chord_pending_ = false; + bool backspace_held_ = false; + bool space_held_ = false; +}; \ No newline at end of file diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 091b6b3..8e911e8 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -2,6 +2,7 @@ #if defined(HAS_TDECK_KEYBOARD) && defined(ESP32) #include "TDeckKeyboard.h" +#include "TDeckKeyboardState.h" #include #include @@ -9,19 +10,91 @@ #define PIN_KB_ADDR 0x55 #endif -// Single-producer (core-0 poll) / single-consumer (UI thread) ring. Byte indices -// on a 32-bit MCU are atomic enough for SPSC without a lock. -static volatile uint8_t s_ring[16]; -static volatile uint8_t s_head = 0; // written by producer -static volatile uint8_t s_tail = 0; // written by consumer +// Core-0 produces and the UI task consumes. One short cross-core critical +// section publishes each byte together with its index and also protects the +// desired modifier-input mode below. +static portMUX_TYPE s_keyboard_mux = portMUX_INITIALIZER_UNLOCKED; +static uint8_t s_ring[16]; +static uint8_t s_head = 0; +static uint8_t s_tail = 0; static bool s_inited = false; +enum class KeyboardMode : uint8_t { Probe, Raw, Legacy }; +static KeyboardMode s_mode = KeyboardMode::Probe; +static TDeckKeyboardState s_raw_state; +static bool s_modifier_input_allowed = true; // guarded by s_keyboard_mux +static uint32_t s_modifier_mode_generation = 0; // guarded by s_keyboard_mux +static uint32_t s_modifier_generation_applied = 0; // core-0 owner only // Backlight: the UI thread requests a level; the actual I2C write happens in the // poll (core 0). The keyboard's C3 firmware sets the backlight on an I2C write. static volatile uint8_t s_bl_desired = 0; static volatile bool s_bl_dirty = false; +static void ringPushLocked(uint8_t key) { + if (!key) return; + const uint8_t next = (uint8_t)((s_head + 1) & 15); + if (next != s_tail) { + s_ring[s_head] = key; + s_head = next; + } +} + +static void keyboardCommand(uint8_t command) { + Wire.beginTransmission(PIN_KB_ADDR); + Wire.write(command); + Wire.endTransmission(); +} + +static int keyboardRead(uint8_t* out, size_t count) { + Wire.requestFrom((int)PIN_KB_ADDR, (int)count); + int read = 0; + while (Wire.available() && read < (int)count) out[read++] = (uint8_t)Wire.read(); + while (Wire.available()) Wire.read(); + return read; +} + +static void processRawFrame(const uint8_t frame[TDeckKeyboardState::COLS], uint32_t now_ms) { + uint8_t keys[16]; + portENTER_CRITICAL(&s_keyboard_mux); + const uint32_t generation = s_modifier_mode_generation; + if (generation != s_modifier_generation_applied) { + // Transition frame: establish current physical state, publish nothing. + // The mode setter already cleared older queued bytes under this same lock. + s_raw_state.baseline(frame); + s_modifier_generation_applied = generation; + } else { + const size_t key_count = s_raw_state.update(frame, now_ms, keys, sizeof keys, + s_modifier_input_allowed); + for (size_t i = 0; i < key_count; ++i) ringPushLocked(keys[i]); + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + +static void processLegacyKey(uint8_t key) { + portENTER_CRITICAL(&s_keyboard_mux); + const uint32_t generation = s_modifier_mode_generation; + if (generation != s_modifier_generation_applied) { + // No raw modifier state exists, and the C3 exposes one latest-byte mailbox + // (comdata/comdata_flag), not one response per host mode transition. Any + // number of transitions before this read therefore coalesce deliberately: + // drop the sole pending byte once so it cannot cross into the final mode. + s_modifier_generation_applied = generation; + } else { + ringPushLocked(key); + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + void tdeckKeyboardBegin() { + s_mode = KeyboardMode::Probe; + s_raw_state = TDeckKeyboardState{}; + portENTER_CRITICAL(&s_keyboard_mux); + s_head = s_tail = 0; + // Keep any desired mode the UI published immediately after starting this + // task. Setting applied one generation behind forces the first response to + // baseline (or drain the legacy controller's one-byte mailbox). + s_modifier_generation_applied = s_modifier_mode_generation - 1; + portEXIT_CRITICAL(&s_keyboard_mux); s_inited = true; // Wire was configured (18/8, 400k, 20ms timeout) by the touch driver } @@ -49,21 +122,69 @@ void tdeckKeyboardFlushBacklight() { void tdeckKeyboardPoll() { if (!s_inited) return; tdeckKeyboardFlushBacklight(); - if (Wire.requestFrom((int)PIN_KB_ADDR, 1) != 1) return; - uint8_t key = Wire.read(); - if (key == 0) return; // no key this scan - const uint8_t nh = (uint8_t)((s_head + 1) & 15); - if (nh != s_tail) { // drop if the ring is full - s_ring[s_head] = key; - s_head = nh; + if (s_mode == KeyboardMode::Probe) { + // LilyGO keyboard firmware from June 2025 onward accepts 0x03 and returns + // five column-state bytes. Older controllers ignore it and keep returning + // one resolved ASCII byte; restore 0x04 key mode immediately in that case. + keyboardCommand(0x03); + uint8_t frame[TDeckKeyboardState::COLS] = {}; + const int count = keyboardRead(frame, sizeof frame); + bool valid_raw = count == (int)sizeof frame; + for (size_t col = 0; col < sizeof frame && valid_raw; ++col) + valid_raw = (frame[col] & 0x80) == 0; + if (valid_raw) { + s_mode = KeyboardMode::Raw; + Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled"); + processRawFrame(frame, millis()); + return; + } + keyboardCommand(0x04); + s_mode = KeyboardMode::Legacy; + Serial.println("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier latching"); + if (count == 1) processLegacyKey(frame[0]); + return; } + if (s_mode == KeyboardMode::Raw) { + uint8_t frame[TDeckKeyboardState::COLS] = {}; + if (keyboardRead(frame, sizeof frame) != (int)sizeof frame) return; + for (size_t col = 0; col < sizeof frame; ++col) if (frame[col] & 0x80) return; + processRawFrame(frame, millis()); + return; + } + uint8_t key = 0; + if (keyboardRead(&key, 1) == 1) processLegacyKey(key); } int tdeckKeyboardReadKey() { - if (s_tail == s_head) return 0; // empty + portENTER_CRITICAL(&s_keyboard_mux); + if (s_tail == s_head) { + portEXIT_CRITICAL(&s_keyboard_mux); + return 0; + } const uint8_t key = s_ring[s_tail]; s_tail = (uint8_t)((s_tail + 1) & 15); + portEXIT_CRITICAL(&s_keyboard_mux); return key; } +void tdeckKeyboardDiscardModifiers() { + portENTER_CRITICAL(&s_keyboard_mux); + if (s_modifier_input_allowed) { + s_modifier_input_allowed = false; + ++s_modifier_mode_generation; + s_tail = s_head; + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + +void tdeckKeyboardAllowModifiers() { + portENTER_CRITICAL(&s_keyboard_mux); + if (!s_modifier_input_allowed) { + s_modifier_input_allowed = true; + ++s_modifier_mode_generation; + s_tail = s_head; + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + #endif diff --git a/src/helpers/input/TDeckKeyboard.h b/src/helpers/input/TDeckKeyboard.h index cf64910..4030baf 100644 --- a/src/helpers/input/TDeckKeyboard.h +++ b/src/helpers/input/TDeckKeyboard.h @@ -1,14 +1,16 @@ #pragma once // LilyGo T-Deck physical keyboard. It's a separate ESP32-C3 running the -// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55): each -// 1-byte read returns the ASCII code of the last key press (0 = none). The C3 -// resolves shift / sym layers itself, so we just receive final characters. +// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55). +// Current controller firmware supports a five-byte raw matrix mode, which lets +// this driver preserve held modifiers and add tap/double-tap latching. Older +// firmware returns one resolved ASCII byte; startup detects that and restores +// the original controller-side key mode automatically. // // CRITICAL: the keyboard shares the I2C bus with the GT911 touch controller, // which is polled from a core-0 task. To avoid two cores hitting Wire at once, // tdeckKeyboardPoll() must be called from THAT task; the UI thread only drains -// the lock-free ring via tdeckKeyboardReadKey(). +// the critical-section-protected ring via tdeckKeyboardReadKey(). #if defined(HAS_TDECK_KEYBOARD) && defined(ESP32) #include @@ -23,6 +25,14 @@ void tdeckKeyboardPoll(); /** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */ int tdeckKeyboardReadKey(); +/** Cancel one-shot/locked modifiers and suppress a later release from a + * currently held modifier. Call when queued input is intentionally discarded. */ +void tdeckKeyboardDiscardModifiers(); + +/** Stop continuously discarding raw modifier state after the UI returns to a + * mode where keyboard input is meaningful. */ +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. */ diff --git a/src/helpers/input/TDeckKeyboardState.h b/src/helpers/input/TDeckKeyboardState.h new file mode 100644 index 0000000..3848046 --- /dev/null +++ b/src/helpers/input/TDeckKeyboardState.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +#include "LatchedModifier.h" + +class TDeckKeyboardState { + public: + static constexpr size_t COLS = 5; + + size_t update(const uint8_t current[COLS], uint32_t now_ms, + uint8_t* out, size_t out_capacity, bool modifiers_enabled = true) { + static const char base[COLS][7] = { + {'q', 'w', '\0', 'a', '\0', ' ', '\0'}, + {'e', 's', 'd', 'p', 'x', 'z', '\0'}, + {'r', 'g', 't', '\0', 'v', 'c', 'f'}, + {'u', 'h', 'y', '\0', 'b', 'n', 'j'}, + {'o', 'l', 'i', '\0', '$', 'm', 'k'}, + }; + static const char symbols[COLS][7] = { + {'#', '1', '\0', '*', '\0', '\0', '0'}, + {'2', '4', '5', '@', '8', '7', '\0'}, + {'3', '/', '(', '\0', '?', '9', '6'}, + {'_', ':', ')', '\0', '!', ',', ';'}, + {'+', '"', '-', '\0', '\0', '.', '\''}, + }; + auto down = [&](size_t col, uint8_t row) { + return (current[col] & (uint8_t)(1U << row)) != 0; + }; + auto pressed = [&](size_t col, uint8_t row) { + return down(col, row) && (previous_[col] & (uint8_t)(1U << row)) == 0; + }; + auto released = [&](size_t col, uint8_t row) { + return !down(col, row) && (previous_[col] & (uint8_t)(1U << row)) != 0; + }; + + if (!modifiers_enabled) { + symbol_.baseline(down(0, 2)); + alt_.baseline(down(0, 4)); + } + if (modifiers_enabled && pressed(0, 2)) symbol_.press(); + if (modifiers_enabled && pressed(0, 4)) alt_.press(); + if (modifiers_enabled && released(0, 2)) symbol_.release(now_ms); + if (modifiers_enabled && released(0, 4)) alt_.release(now_ms); + + const bool shift = modifiers_enabled && (down(1, 6) || down(2, 3)); + size_t written = 0; + auto emit = [&](uint8_t key) { + if (written < out_capacity) out[written++] = key; + }; + for (uint8_t row = 0; row < 7; ++row) { + for (size_t col = 0; col < COLS; ++col) { + if (!pressed(col, row)) continue; + if ((col == 0 && (row == 2 || row == 4)) || + (col == 1 && row == 6) || (col == 2 && row == 3)) continue; + const bool physical_alt = modifiers_enabled && alt_.held(); + if (physical_alt) alt_.markHeldUsed(); + // Preserve LilyGO controller shortcuts while raw mode moves ordinary + // matrix translation into the host. The C3 still handles Alt+B's + // backlight toggle; suppress its character exactly as key mode does. + if (physical_alt && col == 3 && row == 4) { + symbol_.consumeForKey(); + continue; + } + if (physical_alt && col == 2 && row == 5) { + symbol_.consumeForKey(); + emit(0x0C); + continue; + } + if (col == 3 && row == 3) { + symbol_.consumeForKey(); alt_.consumeForKey(); emit('\r'); continue; + } + if (col == 4 && row == 3) { + symbol_.consumeForKey(); alt_.consumeForKey(); emit('\b'); continue; + } + + const bool use_symbols = modifiers_enabled && (symbol_.active() || alt_.latched()); + const char key = use_symbols ? symbols[col][row] : base[col][row]; + if (modifiers_enabled) { + symbol_.consumeForKey(); + if (!physical_alt) alt_.consumeForKey(); + } + if (key == '\0') continue; + char resolved = key; + if (!use_symbols && shift && resolved >= 'a' && resolved <= 'z') resolved -= 32; + emit((uint8_t)resolved); + } + } + + for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F; + return written; + } + + void baseline(const uint8_t current[COLS]) { + symbol_.baseline((current[0] & (uint8_t)(1U << 2)) != 0); + alt_.baseline((current[0] & (uint8_t)(1U << 4)) != 0); + for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F; + } + + void discardModifiers() { + symbol_.discard(); + alt_.discard(); + } + + private: + uint8_t previous_[COLS] = {}; + LatchedModifier symbol_; + LatchedModifier alt_; +}; \ No newline at end of file diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..1e6e455 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -119,7 +119,7 @@ #include #endif #if defined(HAS_TDECK_KEYBOARD) - #include + #include "../helpers/input/TDeckKeyboard.h" #elif defined(HAS_M9_KEYBOARD) #include #endif @@ -130,7 +130,7 @@ #include // wada.sys.accel() source (luaHostAccel) #endif #if defined(HAS_PAGER_KEYBOARD) - #include + #include "../helpers/input/PagerKeyboard.h" #endif #if defined(HAS_PAGER_ENCODER) #include @@ -36172,30 +36172,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 @@ -36410,9 +36386,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(); @@ -36583,8 +36557,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) { @@ -38430,9 +38404,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 @@ -52834,6 +52808,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; @@ -52842,7 +52818,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 @@ -53445,12 +53421,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) @@ -53492,6 +53471,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) { @@ -53507,7 +53487,7 @@ void UITask::loop() { if (key <= 0) break; remotePhysicalKey(key); } - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); } else if (g_lv.task && g_lv.task->isScreenOff()) { @@ -53526,10 +53506,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 @@ -53554,7 +53534,6 @@ void UITask::loop() { if (key <= 0) break; handleHwKey(key); } - updatePagerAltTapNext(); updatePagerAltShiftChord(); updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); diff --git a/test/test_latched_modifier.cpp b/test/test_latched_modifier.cpp new file mode 100644 index 0000000..c7ade51 --- /dev/null +++ b/test/test_latched_modifier.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "helpers/input/LatchedModifier.h" + +int main() { + LatchedModifier modifier; + + modifier.press(); + assert(modifier.consumeForKey()); + modifier.release(100); + assert(!modifier.consumeForKey()); + + modifier.press(); + modifier.release(200); + assert(modifier.consumeForKey()); + assert(!modifier.consumeForKey()); + + modifier.press(); + modifier.release(400); + modifier.press(); + modifier.release(700); + assert(modifier.locked()); + assert(modifier.consumeForKey()); + assert(modifier.consumeForKey()); + + modifier.press(); + modifier.release(800); + assert(!modifier.active()); + + modifier.tap(1000); + modifier.tap(1500); + assert(!modifier.locked()); + assert(modifier.consumeForKey()); + + modifier.tap(2000); + modifier.clearLatched(); + assert(!modifier.active()); + + modifier.tap(2100); + modifier.discard(); + assert(!modifier.active()); + + modifier.press(); + modifier.discard(); + modifier.release(2200); + assert(!modifier.active()); + + LatchedModifier wrapped; + wrapped.tap(UINT32_MAX - 100); + wrapped.tap(100); + assert(wrapped.locked()); + + wrapped.baseline(true); + assert(wrapped.held()); + wrapped.release(200); + assert(!wrapped.active()); + wrapped.baseline(false); + assert(!wrapped.active()); + return 0; +} \ No newline at end of file diff --git a/test/test_pager_keyboard_state.cpp b/test/test_pager_keyboard_state.cpp new file mode 100644 index 0000000..8d428ae --- /dev/null +++ b/test/test_pager_keyboard_state.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "helpers/input/PagerKeyboardState.h" + +int main() { + PagerKeyboardState keyboard; + + assert(keyboard.event(0, true, 10) == 'q'); + keyboard.event(0, false, 20); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 100); + assert(keyboard.altHeld()); + assert(keyboard.event(0, true, 110) == '1'); + keyboard.event(0, false, 120); + keyboard.event(PagerKeyboardState::ALT_POS, false, 130); + assert(keyboard.event(0, true, 140) == 'q'); + keyboard.event(0, false, 150); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 200); + keyboard.event(PagerKeyboardState::ALT_POS, false, 220); + assert(keyboard.event(1, true, 230) == '2'); + keyboard.event(1, false, 240); + assert(keyboard.event(1, true, 250) == 'w'); + keyboard.event(1, false, 260); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 300); + keyboard.event(PagerKeyboardState::ALT_POS, false, 320); + keyboard.event(PagerKeyboardState::ALT_POS, true, 400); + keyboard.event(PagerKeyboardState::ALT_POS, false, 420); + assert(keyboard.event(0, true, 430) == '1'); + keyboard.event(0, false, 440); + assert(keyboard.event(1, true, 450) == '2'); + keyboard.event(1, false, 460); + keyboard.event(PagerKeyboardState::ALT_POS, true, 470); + keyboard.event(PagerKeyboardState::ALT_POS, false, 480); + assert(keyboard.event(0, true, 490) == 'q'); + keyboard.event(0, false, 500); + + keyboard.event(PagerKeyboardState::SHIFT_POS, true, 510); + assert(keyboard.event(0, true, 520) == 'Q'); + keyboard.event(0, false, 530); + keyboard.event(PagerKeyboardState::SHIFT_POS, false, 540); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 600); + keyboard.event(PagerKeyboardState::SHIFT_POS, true, 610); + assert(keyboard.consumeAltShiftChord()); + assert(!keyboard.consumeAltShiftChord()); + keyboard.toggleCaps(); + keyboard.event(PagerKeyboardState::SHIFT_POS, false, 620); + keyboard.event(PagerKeyboardState::ALT_POS, false, 630); + assert(keyboard.event(0, true, 640) == 'Q'); + keyboard.event(0, false, 650); + keyboard.toggleCaps(); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 700); + assert(keyboard.event(PagerKeyboardState::BACKSPACE_POS, true, 710) == 0); + assert(keyboard.consumeAltBackspaceChord()); + assert(!keyboard.backspaceHeld()); + keyboard.event(PagerKeyboardState::BACKSPACE_POS, false, 720); + keyboard.event(PagerKeyboardState::ALT_POS, false, 730); + assert(keyboard.event(0, true, 740) == 'q'); + keyboard.event(0, false, 750); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 800); + keyboard.discardAlt(); + keyboard.event(PagerKeyboardState::ALT_POS, false, 810); + assert(keyboard.event(0, true, 820) == 'q'); + keyboard.event(0, false, 830); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 900); + keyboard.event(PagerKeyboardState::ALT_POS, false, 920); + keyboard.markAltUsed(); // no effect without a physical hold + assert(keyboard.event(PagerKeyboardState::SPACE_POS, true, 930) == ' '); + assert(keyboard.spaceHeld()); + keyboard.event(PagerKeyboardState::SPACE_POS, false, 940); + assert(!keyboard.spaceHeld()); + assert(keyboard.event(0, true, 950) == 'q'); + keyboard.event(0, false, 960); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 1000); + keyboard.markAltUsed(); // physical Alt+encoder equivalent + keyboard.event(PagerKeyboardState::ALT_POS, false, 1010); + assert(keyboard.event(0, true, 1020) == 'q'); + return 0; +} \ No newline at end of file diff --git a/test/test_tdeck_keyboard_state.cpp b/test/test_tdeck_keyboard_state.cpp new file mode 100644 index 0000000..7357435 --- /dev/null +++ b/test/test_tdeck_keyboard_state.cpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "helpers/input/TDeckKeyboardState.h" + +static size_t update(TDeckKeyboardState& keyboard, uint8_t state[5], uint32_t now, + uint8_t* out) { + return keyboard.update(state, now, out, 8); +} + +static void setKey(uint8_t state[5], int col, int row, bool down) { + if (down) state[col] |= (uint8_t)(1U << row); + else state[col] &= (uint8_t)~(1U << row); +} + +int main() { + TDeckKeyboardState keyboard; + uint8_t state[5] = {}; + uint8_t out[8] = {}; + + setKey(state, 0, 2, true); // hold Sym + assert(update(keyboard, state, 10, out) == 0); + setKey(state, 0, 0, true); // q -> # + assert(update(keyboard, state, 20, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 30, out); + setKey(state, 0, 2, false); + update(keyboard, state, 40, out); + setKey(state, 0, 0, true); // hold use must not latch + assert(update(keyboard, state, 50, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 60, out); + + setKey(state, 0, 2, true); // tap Sym + update(keyboard, state, 100, out); + setKey(state, 0, 2, false); + update(keyboard, state, 120, out); + setKey(state, 0, 1, true); // w -> 1 once + assert(update(keyboard, state, 130, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 140, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 150, out) == 1 && out[0] == 'w'); + setKey(state, 0, 1, false); + update(keyboard, state, 160, out); + + setKey(state, 0, 2, true); // an unmapped symbol still uses the hold + update(keyboard, state, 170, out); + setKey(state, 0, 5, true); // Space has no T-Deck symbol mapping + assert(update(keyboard, state, 175, out) == 0); + setKey(state, 0, 5, false); + update(keyboard, state, 180, out); + setKey(state, 0, 2, false); + update(keyboard, state, 185, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 190, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 195, out); + + setKey(state, 0, 4, true); // held Alt preserves base layer + update(keyboard, state, 200, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 210, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 215, out); + setKey(state, 3, 4, true); // Alt+B stays controller-only + assert(update(keyboard, state, 216, out) == 0); + setKey(state, 3, 4, false); + update(keyboard, state, 217, out); + setKey(state, 2, 5, true); // Alt+C keeps its legacy control code + assert(update(keyboard, state, 218, out) == 1 && out[0] == 0x0C); + setKey(state, 2, 5, false); + update(keyboard, state, 219, out); + setKey(state, 0, 4, false); + update(keyboard, state, 220, out); + + setKey(state, 0, 4, true); // solo Alt is a one-shot symbol latch + update(keyboard, state, 225, out); + setKey(state, 0, 4, false); + update(keyboard, state, 230, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 235, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 240, out); + + setKey(state, 0, 4, true); // double-tap Alt locks symbols + update(keyboard, state, 250, out); + setKey(state, 0, 4, false); + update(keyboard, state, 270, out); + setKey(state, 0, 4, true); + update(keyboard, state, 300, out); + setKey(state, 0, 4, false); + update(keyboard, state, 320, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 330, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 340, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 350, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 360, out); + setKey(state, 0, 4, true); // tap Alt unlocks + update(keyboard, state, 400, out); + setKey(state, 0, 4, false); + update(keyboard, state, 420, out); + + setKey(state, 0, 2, true); // double-tap Sym locks symbols + update(keyboard, state, 430, out); + setKey(state, 0, 2, false); + update(keyboard, state, 440, out); + setKey(state, 0, 2, true); + update(keyboard, state, 450, out); + setKey(state, 0, 2, false); + update(keyboard, state, 460, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 470, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 475, out); + setKey(state, 0, 2, true); + update(keyboard, state, 480, out); + setKey(state, 0, 2, false); + update(keyboard, state, 490, out); + + setKey(state, 1, 6, true); // held Shift still uppercases base + update(keyboard, state, 500, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 510, out) == 1 && out[0] == 'Q'); + + setKey(state, 0, 0, false); + setKey(state, 1, 6, false); + update(keyboard, state, 520, out); + setKey(state, 0, 2, true); // discarded physical hold + update(keyboard, state, 600, out); + keyboard.discardModifiers(); + setKey(state, 0, 2, false); + update(keyboard, state, 620, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 630, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 640, out); + + setKey(state, 0, 4, true); // discarded Alt hold cannot latch on release + update(keyboard, state, 650, out); + keyboard.discardModifiers(); + setKey(state, 0, 4, false); + update(keyboard, state, 660, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 670, out) == 1 && out[0] == 'q'); + + setKey(state, 0, 0, false); + update(keyboard, state, 680, out); + setKey(state, 0, 2, true); // modifiers disabled: base key still routes + update(keyboard, state, 690, out); + setKey(state, 0, 0, true); + assert(keyboard.update(state, 700, out, 8, false) == 1 && out[0] == 'q'); + + uint8_t baseline[5] = {}; + setKey(baseline, 0, 1, true); // transition frame produces no edge later + keyboard.baseline(baseline); + assert(keyboard.update(baseline, 710, out, 8) == 0); + return 0; +} \ No newline at end of file From 417a52caa1e5e7f32bd00354966b621c62784486 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 15:52:26 -0400 Subject: [PATCH 07/22] Fix Wardrive UTF-8 text handling Signed-off-by: Michael A. Cojocari --- deploy/apps/apps.json | 2 +- deploy/apps/wardrive/1.1/wardrive.json | 1 + deploy/apps/wardrive/1.1/wardrive.lua | 216 +++++++++++++++++++++++++ scripts/lua-harness/README.md | 2 +- scripts/lua-harness/harness.lua | 65 +++++++- src/ui-touch/LuaAppHost.cpp | 70 ++++++-- src/ui-touch/UITask.cpp | 61 +++---- src/ui-touch/Utf8Text.h | 78 +++++++++ src/ui-touch/lua_builtin.h | 32 +++- test/test_utf8_text.cpp | 32 ++++ 10 files changed, 495 insertions(+), 64 deletions(-) create mode 100644 deploy/apps/wardrive/1.1/wardrive.json create mode 100644 deploy/apps/wardrive/1.1/wardrive.lua create mode 100644 src/ui-touch/Utf8Text.h create mode 100644 test/test_utf8_text.cpp diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index ee2da03..138d7f2 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -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." }, { diff --git a/deploy/apps/wardrive/1.1/wardrive.json b/deploy/apps/wardrive/1.1/wardrive.json new file mode 100644 index 0000000..d467e18 --- /dev/null +++ b/deploy/apps/wardrive/1.1/wardrive.json @@ -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."} \ No newline at end of file diff --git a/deploy/apps/wardrive/1.1/wardrive.lua b/deploy/apps/wardrive/1.1/wardrive.lua new file mode 100644 index 0000000..a2f8377 --- /dev/null +++ b/deploy/apps/wardrive/1.1/wardrive.lua @@ -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 diff --git a/scripts/lua-harness/README.md b/scripts/lua-harness/README.md index 08cfa7b..771b411 100644 --- a/scripts/lua-harness/README.md +++ b/scripts/lua-harness/README.md @@ -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 ``` diff --git a/scripts/lua-harness/harness.lua b/scripts/lua-harness/harness.lua index dc7adc2..4aa09eb 100644 --- a/scripts/lua-harness/harness.lua +++ b/scripts/lua-harness/harness.lua @@ -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) diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index dd51781..38f11f2 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -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); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..1e6e455 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -119,7 +119,7 @@ #include #endif #if defined(HAS_TDECK_KEYBOARD) - #include + #include "../helpers/input/TDeckKeyboard.h" #elif defined(HAS_M9_KEYBOARD) #include #endif @@ -130,7 +130,7 @@ #include // wada.sys.accel() source (luaHostAccel) #endif #if defined(HAS_PAGER_KEYBOARD) - #include + #include "../helpers/input/PagerKeyboard.h" #endif #if defined(HAS_PAGER_ENCODER) #include @@ -36172,30 +36172,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 @@ -36410,9 +36386,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(); @@ -36583,8 +36557,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) { @@ -38430,9 +38404,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 @@ -52834,6 +52808,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; @@ -52842,7 +52818,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 @@ -53445,12 +53421,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) @@ -53492,6 +53471,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) { @@ -53507,7 +53487,7 @@ void UITask::loop() { if (key <= 0) break; remotePhysicalKey(key); } - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); } else if (g_lv.task && g_lv.task->isScreenOff()) { @@ -53526,10 +53506,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 @@ -53554,7 +53534,6 @@ void UITask::loop() { if (key <= 0) break; handleHwKey(key); } - updatePagerAltTapNext(); updatePagerAltShiftChord(); updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); diff --git a/src/ui-touch/Utf8Text.h b/src/ui-touch/Utf8Text.h new file mode 100644 index 0000000..90571fe --- /dev/null +++ b/src/ui-touch/Utf8Text.h @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +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(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(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 \ No newline at end of file diff --git a/src/ui-touch/lua_builtin.h b/src/ui-touch/lua_builtin.h index ecbcc82..4a43f4d 100644 --- a/src/ui-touch/lua_builtin.h +++ b/src/ui-touch/lua_builtin.h @@ -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])); diff --git a/test/test_utf8_text.cpp b/test/test_utf8_text.cpp new file mode 100644 index 0000000..3ccecf0 --- /dev/null +++ b/test/test_utf8_text.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#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; +} \ No newline at end of file From 979123806663159d0ac8ab8ff27df5a3537ce05d Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 16:12:37 -0400 Subject: [PATCH 08/22] Add keyboard modifier transition hooks Signed-off-by: Michael A. Cojocari --- src/helpers/input/PagerKeyboard.cpp | 8 ++++++++ src/helpers/input/PagerKeyboard.h | 4 ++++ src/helpers/input/TDeckKeyboard.cpp | 4 ++++ src/helpers/input/TDeckKeyboard.h | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index e357368..e1cc6fb 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -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; diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 774e552..4a42040 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -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 diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 091b6b3..a82ee14 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -66,4 +66,8 @@ int tdeckKeyboardReadKey() { return key; } +void tdeckKeyboardDiscardModifiers() {} + +void tdeckKeyboardAllowModifiers() {} + #endif diff --git a/src/helpers/input/TDeckKeyboard.h b/src/helpers/input/TDeckKeyboard.h index cf64910..8e352bb 100644 --- a/src/helpers/input/TDeckKeyboard.h +++ b/src/helpers/input/TDeckKeyboard.h @@ -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. */ From 2a6aa57978c7d4a345773b3f1ea0b65e3f8c09fb Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 16:29:47 -0400 Subject: [PATCH 09/22] Reopen chat keyboard on composer tap Signed-off-by: Michael A. Cojocari --- src/ui-touch/UITask.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..a913c53 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -7015,7 +7015,12 @@ static void keyboardCb(lv_event_t* e) { } static void composerFocusCb(lv_event_t* e) { - if (lv_event_get_code(e) != LV_EVENT_FOCUSED) return; + const lv_event_code_t code = lv_event_get_code(e); + if (code != LV_EVENT_FOCUSED && code != LV_EVENT_CLICKED) return; + if (code == LV_EVENT_CLICKED) { + lv_indev_t* act = lv_indev_get_act(); + if (act && (lv_indev_get_scroll_obj(act) || lv_indev_get_scroll_dir(act) != LV_DIR_NONE)) return; + } auto* p = static_cast(lv_event_get_user_data(e)); if (p && p->detail_open) { showKb(p); noteKbActivity(); } } @@ -31256,6 +31261,11 @@ static void makeChatDetail(LvChatPanel& p) { } // Tap on composer → show keyboard lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_FOCUSED, &p); +#if CAP_TOUCH && !CAP_KEYBOARD + // A composer may remain group-focused after the keyboard is dismissed, so + // a later tap emits CLICKED without another FOCUSED event. + lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_CLICKED, &p); +#endif lv_obj_add_event_cb(p.composer_ta, kbActivityPressCb, LV_EVENT_PRESSED, nullptr); // Grow / shrink the composer row as the message wraps (every text change). lv_obj_add_event_cb(p.composer_ta, composerAutoGrowCb, LV_EVENT_VALUE_CHANGED, &p); From b5a8c99f556b0a9913329af270652c1228105c6d Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 16:59:15 -0400 Subject: [PATCH 10/22] Complete ThinkNode M9 keyboard parity Signed-off-by: Michael A. Cojocari --- include/lv_conf_m9.h | 25 ++++++++++++++ platformio.ini | 11 +++--- scripts/build/patch_meshcore_rv3028_define.py | 34 +++++++++++++++++++ src/ui-touch/SnakeGame.cpp | 7 ++-- src/ui-touch/SnakeGame.h | 7 ++-- src/ui-touch/UITask.cpp | 5 +++ 6 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 include/lv_conf_m9.h create mode 100644 scripts/build/patch_meshcore_rv3028_define.py diff --git a/include/lv_conf_m9.h b/include/lv_conf_m9.h new file mode 100644 index 0000000..db9237a --- /dev/null +++ b/include/lv_conf_m9.h @@ -0,0 +1,25 @@ +#pragma once + +// M9-specific LVGL config wrapper. +// +// Why this exists: +// - LVGL 8.4's lv_hal_indev.h unconditionally defines LV_INDEV_DEF_SCROLL_*. +// - Our shared lv_conf defines LV_INDEV_DEF_SCROLL_LIMIT for touch tuning, and +// M9's build flags define LV_INDEV_DEF_SCROLL_THROW. That combination emits +// macro-redefinition warnings, even though M9 has no touch input path. +// +// For M9, include the shared project LV config, then drop touch-scroll tuning +// macros so LVGL's keypad-only defaults can apply without warnings. +#ifndef LV_CONF_H +#define LV_CONF_H +#endif + +#include "lv_conf.h" + +#ifdef LV_INDEV_DEF_SCROLL_LIMIT +#undef LV_INDEV_DEF_SCROLL_LIMIT +#endif + +#ifdef LV_INDEV_DEF_SCROLL_THROW +#undef LV_INDEV_DEF_SCROLL_THROW +#endif diff --git a/platformio.ini b/platformio.ini index 0d67afa..46e4bbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1143,13 +1143,17 @@ board_build.partitions = variants/thinknode_m9/partitions_m9_touch.csv ; DriveDiosInSleepMode (0x012A, FW 0x0308+) with CMD_PERR -> begin() = -706. ; The pre-script makes RadioLib's config() skip it on old FW (idempotent, ; per-env libdeps copy). See scripts/build/patch_radiolib_lr11x0.py. -extra_scripts = pre:scripts/build/pre_gen_baked.py, pre:scripts/inject_wifi_env.py, pre:scripts/build/patch_radiolib_lr11x0.py, merge-bin.py +extra_scripts = pre:scripts/build/pre_gen_baked.py, pre:scripts/inject_wifi_env.py, pre:scripts/build/patch_radiolib_lr11x0.py, pre:scripts/build/patch_meshcore_rv3028_define.py, merge-bin.py + +build_unflags = + -std=gnu++11 + -std=gnu++14 build_flags = ; 1.17 core: ESP32Board.cpp includes -> MicroNMEALocationProvider.h (header-only ; provider), so MicroNMEA never chains into the core lib's LDF scope -- hand it the path. -I"${platformio.libdeps_dir}/${this.__env__}/MicroNMEA/src" - -Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 + -Wno-deprecated-declarations -Wno-unused-parameter -Wno-cpp -Wno-overflow -Wno-return-type -std=gnu++17 -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 -D MC_VENDORED_TOUCH_APP -D LORA_FREQ=869.618 -D LORA_BW=62.5 @@ -1331,9 +1335,8 @@ build_flags = -D HAS_TOUCH_UI=1 -D FIRMWARE_OTA_ENV='"ThinkNode_M9_companion_radio_touch"' -D ENABLE_ADVERT_ON_BOOT=0 - -D LV_CONF_PATH=lv_conf.h + -D LV_CONF_PATH=lv_conf_m9.h -D LV_CONF_INCLUDE_SIMPLE=1 - -D LV_INDEV_DEF_SCROLL_THROW=7 -D MAX_CONTACTS=2000 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7789LCDDisplay diff --git a/scripts/build/patch_meshcore_rv3028_define.py b/scripts/build/patch_meshcore_rv3028_define.py new file mode 100644 index 0000000..ab88690 --- /dev/null +++ b/scripts/build/patch_meshcore_rv3028_define.py @@ -0,0 +1,34 @@ +Import("env") +import os + +MARKER = "wadamesh-rv3028-guard-patch" +OLD = "#define RV3028_ADDRESS 0x52" +NEW = """#ifndef RV3028_ADDRESS +#define RV3028_ADDRESS 0x52 +#endif // wadamesh-rv3028-guard-patch""" + +path = os.path.join( + env.subst("$PROJECT_LIBDEPS_DIR"), + env.subst("$PIOENV"), + "MeshCore", + "src", + "helpers", + "AutoDiscoverRTCClock.cpp", +) + +if not os.path.isfile(path): + print("[patch_meshcore_rv3028_define] WARNING: %s not found (libdeps not fetched yet?)" % path) + print("[patch_meshcore_rv3028_define] WARNING: MeshCore NOT patched - re-run build once libdeps exist") +else: + with open(path) as f: + src = f.read() + + if MARKER in src: + print("[patch_meshcore_rv3028_define] already patched") + elif OLD in src: + with open(path, "w") as f: + f.write(src.replace(OLD, NEW, 1)) + print("[patch_meshcore_rv3028_define] patched RV3028_ADDRESS guard in AutoDiscoverRTCClock.cpp") + else: + print("[patch_meshcore_rv3028_define] WARNING: pattern not found - MeshCore version drift?") + print("[patch_meshcore_rv3028_define] WARNING: check AutoDiscoverRTCClock.cpp by hand, NOT patched") \ No newline at end of file diff --git a/src/ui-touch/SnakeGame.cpp b/src/ui-touch/SnakeGame.cpp index f83b429..4ba731b 100644 --- a/src/ui-touch/SnakeGame.cpp +++ b/src/ui-touch/SnakeGame.cpp @@ -25,9 +25,12 @@ SnakeGame* SnakeGame::s_active = nullptr; bool SnakeGame::isOpen() { return s_active != nullptr && s_active->root_ != nullptr; } void SnakeGame::steer(int dx, int dy) { - if (!s_active || !s_active->started_ || s_active->over_) return; + if (!s_active || s_active->over_) return; // Trackball deltas -> one cardinal direction (dominant axis). if (dx == 0 && dy == 0) return; + // Keyboard-only boards (M9) have no touch "New game" tap target; first + // directional input should start immediately. + if (!s_active->started_) s_active->startGame(); const int adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; if (adx >= ady) s_active->setDir(dx > 0 ? 1 : -1, 0); else s_active->setDir(0, dy > 0 ? 1 : -1); @@ -105,7 +108,7 @@ void SnakeGame::updateScoreLabel() { if (over_) lv_label_set_text_fmt(score_, TR("Game over \xe2\x80\x94 score %d (tap to restart)"), score_val_); else if (paused_) lv_label_set_text_fmt(score_, TR("Paused \xe2\x80\x94 score %d"), score_val_); else if (started_) lv_label_set_text_fmt(score_, TR("score %d"), score_val_); - else lv_label_set_text(score_, TR("Snake \xe2\x80\x94 swipe or roll the trackball")); + else lv_label_set_text(score_, TR("Snake \xe2\x80\x94 swipe, roll, or press a direction")); } void SnakeGame::step() { diff --git a/src/ui-touch/SnakeGame.h b/src/ui-touch/SnakeGame.h index 426ffe7..7bc63ed 100644 --- a/src/ui-touch/SnakeGame.h +++ b/src/ui-touch/SnakeGame.h @@ -6,9 +6,10 @@ // Self-contained Snake mini-game launched from the Apps drawer. // // Owns a full-screen overlay on lv_layer_top: a canvas playfield filling the -// unoccupied screen, a score line, and a close button. The game waits on a -// "New game" button (doesn't start moving on open). Steer by swipe OR trackball -// (UITask routes the trackball here via isOpen()/steer() and hides the cursor); +// unoccupied screen, a score line, and a close button. The game can start from +// the "New game" button OR from the first directional steer input. Steer by +// swipe/gesture, trackball, or hardware directional keys (UITask routes board +// input here via isOpen()/steer()); // tap to restart after a game over; the X closes it. One instance at a time. // // Decoupled from UITask internals — depends only on LVGL and lvglPsramAlloc. diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..1e2a389 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -38103,6 +38103,7 @@ static bool m9HandleNavKey(int key) { s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true; case M9_KEY_HOME: if (s_setup_root) return true; + s_nav_ta_editing = false; // Home is global, including while a textarea is being edited if (s_apppage_close) { // A full-screen app page (Lua app, Snake, Web…) isn't a popup-registry // row — close it and stop: one action per press, so HOME on the Home @@ -38408,6 +38409,10 @@ if (g_lv.task && g_lv.task->isManualLock()) { lv_obj_t* ta = ta_focused; #endif #if defined(HAS_M9_KEYBOARD) + // Unlike directional keys and Back, the dedicated Home key is global. Route + // it before the textarea split so edit mode cannot swallow it as an unknown + // non-printable byte. + if (key == M9_KEY_HOME && m9HandleNavKey(key)) return; if (m9HandleArrowKey(key, ta)) return; // ← runs BEFORE the if(!ta) split #endif if (!ta) { From 020c1839370f22649ecb63f428038763d06cce13 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 18:31:14 -0400 Subject: [PATCH 11/22] Add M9 and Pager screenshot controls Signed-off-by: Michael A. Cojocari --- src/ui-touch/UITask.cpp | 87 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..bf82abe 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2723,6 +2723,7 @@ static lv_obj_t* s_cc_sys_label = nullptr; // CPU/RAM/PSRAM/IP line; refreshed static void ccBuildSysInfo(char* buf, size_t n); // fwd-decl; defined with the CC helpers below static void closeControlCenter(); // defined in the control-center section below static void openControlCenter(); // defined in the control-center section below (early decl for the M9 CTRL key) +static void takeScreenshotToSd(); // shared by the status-bar hold and M9 Control Center action static lv_obj_t* s_power_menu = nullptr; // power off / reboot menu (control center) static void closePowerMenu(); // defined in the control-center section below static void openPowerMenu(); // hold the red ✕ (F1) on the Tanmatsu → power off / reboot @@ -39209,6 +39210,49 @@ static void ccThemeCb(lv_event_t* e) { openAccentPicker(); } +#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) +static uint8_t s_cc_screenshot_delay_s = 3; +static lv_timer_t* s_cc_screenshot_timer = nullptr; + +static const char* ccScreenshotDelayText() { + return s_cc_screenshot_delay_s == 0 ? "now" : + s_cc_screenshot_delay_s == 3 ? "3s" : "10s"; +} + +static void ccScreenshotTimerCb(lv_timer_t*) { + s_cc_screenshot_timer = nullptr; + takeScreenshotToSd(); +} + +static void ccScreenshotDelayCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + s_cc_screenshot_delay_s = s_cc_screenshot_delay_s == 0 ? 3 : + s_cc_screenshot_delay_s == 3 ? 10 : 0; + openControlCenter(); +} + +static void ccScreenshotCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + if (s_cc_screenshot_timer) { + lv_timer_del(s_cc_screenshot_timer); + s_cc_screenshot_timer = nullptr; + } + closeControlCenter(); + const uint32_t wait_ms = (uint32_t)s_cc_screenshot_delay_s * 1000u + 150u; + s_cc_screenshot_timer = lv_timer_create(ccScreenshotTimerCb, wait_ms, nullptr); + if (!s_cc_screenshot_timer) { + if (g_lv.task) g_lv.task->showAlert(TR("Screenshot: low memory"), 1800); + return; + } + lv_timer_set_repeat_count(s_cc_screenshot_timer, 1); + if (s_cc_screenshot_delay_s && g_lv.task) { + char msg[28]; + snprintf(msg, sizeof msg, "Screenshot in %us", (unsigned)s_cc_screenshot_delay_s); + g_lv.task->showAlert(msg, 1200); + } +} +#endif + #if CAP_KEYBOARD static void ccKbBacklightCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; @@ -39750,7 +39794,13 @@ static void openControlCenter() { lv_obj_remove_style_all(card); #if defined(HAS_TANMATSU) const int card_h = 384; // bigger: header + 3 roomier sliders + toggle grid + sysinfo -#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) +#elif defined(HAS_THINKNODE_M9) + // The panel may report a 240px portrait viewport even though the physical + // display is mounted above the keyboard. Eight chips need three rows there; + // use the available height instead of clipping the last row in the old + // T-Deck-sized 200px card. Landscape still needs only two rows. + const int card_h = portrait ? (sh - STATUSBAR_H - 10) : 210; +#elif defined(HAS_TDECK_GT911) const int card_h = 200; // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen) #elif defined(TLORA_PAGER) // 222-px-tall screen: the shared V4/T-Deck-landscape 212px card below is @@ -39855,7 +39905,9 @@ static void openControlCenter() { // Brightness slider takes the first row (just under the date / battery) and the // GPS line follows it, so the slider sits ABOVE the GPS line. Boards without a // backlight PWM have no slider, so GPS reclaims that first row. -#if defined(HAS_TANMATSU) +#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) + const int bl_y = row_y; // keyboard-only boards use everything below this slider for controls +#elif defined(HAS_TANMATSU) const int bl_y = row_y; // three stacked sliders: Screen / Keys / Sound const int gps_y = row_y + SC(100); // GPS line sits below all three (scaled so it clears taller sliders) #elif defined(HAS_TDISPLAY_P4) @@ -39868,6 +39920,7 @@ static void openControlCenter() { const int gps_y = row_y; #endif +#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // ---- GPS fix status (live; refreshed while the panel is open) ---- s_cc_gps_label = lv_label_create(card); lv_label_set_long_mode(s_cc_gps_label, LV_LABEL_LONG_DOT); @@ -39890,6 +39943,7 @@ static void openControlCenter() { lv_obj_align(s_cc_env_label, LV_ALIGN_TOP_LEFT, 0, gps_y + 12); } #endif +#endif // !HAS_THINKNODE_M9 && !TLORA_PAGER #if defined(HAS_TANMATSU) // ---- Three stacked sliders: Screen brightness, Keyboard backlight, Volume. @@ -39968,6 +40022,7 @@ static void openControlCenter() { lv_obj_add_event_cb(bl, ccBrightnessReleaseCb, LV_EVENT_RELEASED, nullptr); #endif +#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // ---- System info line (CPU + RAM% + PSRAM% + IP) — one thin line, pinned to // the very bottom of the card. Memory is shown as % used so it stays // compact and all four facts fit on one row. @@ -39987,6 +40042,7 @@ static void openControlCenter() { lv_obj_set_style_text_color(sysl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(sysl, LV_ALIGN_BOTTOM_MID, 0, 0); } +#endif // ---- Quick toggles (bottom row) ---- bool wifi_on = false, ble_on = false; @@ -40001,7 +40057,16 @@ static void openControlCenter() { lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(row, 10, LV_PART_MAIN); lv_obj_set_style_pad_column(row, 10, LV_PART_MAIN); -#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) +#elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) + // Reclaim everything below brightness: no GPS/status line and no bottom + // system-info line. Five compact cells fit across the Pager landscape view; + // M9 portrait wraps to three rows. Spread the rows through the reclaimed band. + const int controls_y = bl_y + 16; + lv_obj_set_size(row, card_w - 20, card_h - controls_y - 20); + lv_obj_align(row, LV_ALIGN_TOP_MID, 0, controls_y); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_row(row, 4, LV_PART_MAIN); +#elif defined(HAS_TDECK_GT911) // 2-row grid: 4 chips per row, so chip 5 (Lock) wraps onto a 2nd row. Sits // ABOVE the bottom system-info line (-16 offset leaves room for it). lv_obj_set_size(row, card_w - 20, 80); @@ -40016,9 +40081,15 @@ static void openControlCenter() { lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_column(row, 5, LV_PART_MAIN); #endif +#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER) // Bottom-anchored but lifted clear of the 1-line sysinfo (~16 px) plus a gap. lv_obj_align(row, LV_ALIGN_BOTTOM_MID, 0, -20); +#endif +#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_SPACE_EVENLY); +#else lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +#endif lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); const bool gps_on = g_lv.task && g_lv.task->getGPSState(); // T-Deck: chips in a 2-row grid. V4/pager: chips sized to fit the card width @@ -40028,7 +40099,9 @@ static void openControlCenter() { tw = (card_w - 20 - 20) / 3; // 3 chips per row (Wi-Fi/BT/GPS/Theme/Keys/Sound → 2×3 grid) if (tw > 175) tw = 175; th = 80; -#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) +#elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) + tw = 56; th = 34; +#elif defined(HAS_TDECK_GT911) tw = 58; th = 36; #else // Count only the chips this board/session will actually add below (which chips @@ -40098,6 +40171,11 @@ static void openControlCenter() { const bool dnd_on = touchPrefsGetDndEnabled(); ccToggle(row, dnd_on ? TOUCH_SYM_BELL_SLASH : TOUCH_SYM_BELL, TR("DND"), dnd_on, ccDndCb, tw, th, CAT_SOUND); } +#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER) + ccToggle(row, LV_SYMBOL_REFRESH, "Timer", s_cc_screenshot_delay_s != 0, + ccScreenshotDelayCb, tw, th, -1, ccScreenshotDelayText()); + ccToggle(row, LV_SYMBOL_IMAGE, "Screenshot", false, ccScreenshotCb, tw, th); +#endif // (Power is the round icon in the card's top-right corner, not a grid chip.) } @@ -42033,7 +42111,6 @@ static bool s_sb_shot_done = false; // detector aborts it), and a sticky bool would then eat the NEXT genuine bar tap. A stale // one simply expires. static uint32_t s_sb_back_ms = 0; -static void takeScreenshotToSd(); // fwd static void statusBarHoldCb(lv_event_t* e) { switch (lv_event_get_code(e)) { case LV_EVENT_PRESSED: s_sb_press_ms = millis(); s_sb_shot_done = false; break; From 8084955e04daaff4e51dbfb688fe25706642ee0f Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Wed, 26 Aug 2026 19:58:30 -0400 Subject: [PATCH 12/22] Merge --- src/MyMesh.h | 13 +++++++++++++ src/ui-touch/UITask.cpp | 20 +++++++------------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/MyMesh.h b/src/MyMesh.h index 4fb8a51..fe7449e 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -415,6 +415,17 @@ public: return sendTelemetryRequestForUI(recipient); } + /** Interactive logins must rediscover the route. MeshCore 1.16 repeaters + * answer a direct login by flooding the response without refreshing their + * return path; a flooded login returns PATH + LOGIN_OK and refreshes both + * sides before the deferred request or first admin command is sent. */ + void uiResetPathForLogin(ContactInfo& recipient) { + if (recipient.out_path_len == OUT_PATH_UNKNOWN) return; + uiResetContactPath(recipient.id.pub_key); + recipient.out_path_len = OUT_PATH_UNKNOWN; + memset(recipient.out_path, 0, sizeof(recipient.out_path)); + } + /** Touch-UI manual STATUS/TELEMETRY request that DEFERS the REQ until the * guest LOGIN is acknowledged. The chained helpers above fire LOGIN and REQ * back-to-back, but a repeater drops a PAYLOAD_TYPE_REQ from a sender it @@ -435,6 +446,7 @@ public: return (kind == UiReqKind::Telemetry) ? sendTelemetryRequestForUI(recipient) : sendStatusPingForUI(recipient); } + uiResetPathForLogin(recipient); uint32_t login_est = 0; int r = sendLogin(recipient, "", login_est); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { @@ -453,6 +465,7 @@ public: * AbstractUITask::onAdminLoginResult so the UI can flip from "logging * in…" to "logged in" (or "failed"). */ int uiSendAdminLogin(ContactInfo& recipient, const char* password) { + uiResetPathForLogin(recipient); uint32_t est = 0; int r = sendLogin(recipient, password ? password : "", est); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index bf82abe..352599f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -15480,16 +15480,10 @@ static void actionSheetPingCb(lv_event_t* e) { bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c); closeActionSheet(); if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; } - /* sendStatusPingWithGuestLoginForUI() pipelines a blank-password LOGIN - * before the STATUS REQ. Repeaters refuse to decrypt a PAYLOAD_TYPE_REQ - * from a sender that isn't already in their ACL, and the ACL is only - * populated by a successful sendLogin. A blank-password login matches - * repeaters with guest_password = "" (typical default) and adds us as a - * guest; subsequent REQs from this device then decrypt cleanly. The - * follow-up STATUS REQ also registers _ui_pending_status so the reply - * routes back via UITask::onPingReply (not eaten by the companion-serial - * pending_status branch). */ - int r = the_mesh.sendStatusPingWithGuestLoginForUI(c); + /* Wait for the guest LOGIN response before sending STATUS. Besides avoiding + * the first-contact ACL race, the interactive login re-discovers the path so + * older repeaters can return LOGIN_OK before the request is sent. */ + int r = the_mesh.uiSendRequestAfterGuestLogin(c, MyMesh::UiReqKind::Status); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { copyUtf8ReplacingMissingGlyphs(&g_font_14, s_ui_ping_target_name, sizeof(s_ui_ping_target_name), @@ -15554,9 +15548,8 @@ static void actionSheetTelemetryCb(lv_event_t* e) { openTelemetryWindow(s_telem_node, s_telem_name, TELEM_HISTORY); #else // No telemetry window on this board — send straight away and toast the result. - // Chain a guest LOGIN ahead of the REQ (repeaters/sensors need us in their ACL - // before they decrypt a PAYLOAD_TYPE_REQ; see sendStatusPingWithGuestLoginForUI). - int r = the_mesh.sendTelemetryRequestWithGuestLoginForUI(c); + // Wait for guest LOGIN + path discovery before sending the telemetry REQ. + int r = the_mesh.uiSendRequestAfterGuestLogin(c, MyMesh::UiReqKind::Telemetry); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) g_lv.task->showAlert(TR("Telemetry req\xe2\x80\xa6"), 1400); else @@ -53112,6 +53105,7 @@ void UITask::loop() { * doesn't fire later. */ if (s_ui_ping_deadline_ms != 0 && now >= s_ui_ping_deadline_ms) { s_ui_ping_deadline_ms = 0; + the_mesh.cancelUIDeferredLogin(); the_mesh.cancelUIPingPending(); char msg[64]; snprintf(msg, sizeof(msg), TR("No reply from %s"), s_ui_ping_target_name); From 1ca1eab219a65ce081abc33ad97a41fb7099a78b Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 10:16:25 +0200 Subject: [PATCH 13/22] touch: a widget's last blur is its destructor, not a user (#330, #314) dreirund's beta_69 core dump decodes cleanly. Open Radio & mesh, go to Known regions, add and remove a few, leave the page: the device crashes and reboots. obj_del_core sends LV_EVENT_DELETE, then runs the destructor, and the destructor's lv_group_remove_obj refocuses the group. That delivers LV_EVENT_DEFOCUSED to the object which is already half torn down. Seven handlers in this file save on blur, and the one bound to the signal-poll interval field did exactly what it is supposed to: read the textarea's text, clamp it, write it back, and raise a toast -- on memory the destructor was in the middle of freeing. The regions overlay is deleted asynchronously, which is why it takes leaving that page rather than opening it, and why it looked like the page itself was broken. pisti87 reported the same screen as impossible to exit in #314, which is the same crash seen from the other side. DELETE always arrives before the destructor, so the fix is to record the object in a DELETE handler and have every save-on-blur handler ignore a blur for it. The handler goes on every settings field via attachSettingsTaEvents rather than only the ones that save, since that is what makes the signal reliable, and the recorded pointer is only ever compared, never dereferenced. Decoded against a beta_69 T-Deck ELF rebuilt from the tag: radioSigPollSaveCb <- event_send_core(LV_EVENT_DEFOCUSED) <- focus_next_core <- lv_group_remove_obj <- lv_obj_destructor <- obj_del_core <- lv_obj_del_async_cb Co-Authored-By: Claude Opus 5 --- src/ui-touch/UITask.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..9d48e72 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -8717,6 +8717,29 @@ static void settingsFieldDefocusCb(lv_event_t* /*e*/) { accentBoxHide(); } // The full text-edit gestures — defined further down with the edit-menu code. static void composerEditClickedCb(lv_event_t* e); static void composerEditLongPressCb(lv_event_t* e); +// A widget being destroyed still gets one last LV_EVENT_DEFOCUSED, and that is a +// trap for every save-on-blur handler in this file. +// +// obj_del_core sends LV_EVENT_DELETE, then runs the destructor, and the +// destructor's lv_group_remove_obj refocuses the group -- which delivers +// DEFOCUSED to the object that is already half torn down. A blur handler then +// reads the textarea's text and writes it back, on memory the destructor is in +// the middle of freeing. +// +// That is the beta_69 crash in #330: open Radio & mesh, go to Known regions, add +// and remove a few, leave the page. The regions overlay is deleted +// asynchronously, its teardown refocuses, and the poll-interval field's blur +// save runs inside its own destructor. Decoded from the reporter's core dump. +// +// DELETE always arrives before the destructor, so recording the object here and +// checking it in the blur handlers closes the window. The pointer is only ever +// compared, never dereferenced. +static lv_obj_t* s_ta_deleting = nullptr; +static void settingsFieldDeleteCb(lv_event_t* e) { s_ta_deleting = lv_event_get_target(e); } +static inline bool blurFromDelete(lv_event_t* e) { + return lv_event_get_target(e) == s_ta_deleting; +} + static void attachSettingsTaEvents(lv_obj_t* ta) { lv_obj_add_event_cb(ta, settingsFieldFocusCb, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(ta, settingsFieldFocusCb, LV_EVENT_CLICKED, nullptr); @@ -8731,6 +8754,9 @@ static void attachSettingsTaEvents(lv_obj_t* ta) { lv_obj_add_event_cb(ta, composerEditClickedCb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(ta, composerEditLongPressCb, LV_EVENT_LONG_PRESSED, nullptr); lv_obj_add_event_cb(ta, settingsFieldDefocusCb, LV_EVENT_DEFOCUSED, nullptr); + // Must be on every settings field, not just the ones that save on blur: this is + // what tells those handlers the coming DEFOCUSED is a destructor, not a user. + lv_obj_add_event_cb(ta, settingsFieldDeleteCb, LV_EVENT_DELETE, nullptr); } // ===== Text selection + edit menu ========================================== @@ -9177,6 +9203,7 @@ static lv_obj_t* createSettingsModal(const char* title, SettingsModalKind kind) } static void saveProfileNameCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task || !g_set_modal.name_ta) return; kbMirrorSyncToReal(); @@ -9190,6 +9217,7 @@ static void saveProfileNameCb(lv_event_t* e) { } static void saveProfilePosCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: quiet if mid-edit @@ -9206,6 +9234,7 @@ static void saveProfilePosCb(lv_event_t* e) { } static void saveRadioParamsCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: apply quietly, no alerts @@ -9282,6 +9311,7 @@ static void saveRadioParamsCb(lv_event_t* e) { } static void saveAutoAddCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; const bool silent = (_c == LV_EVENT_DEFOCUSED); // blur auto-save: quiet if mid-edit @@ -10452,6 +10482,7 @@ static void radioRetryEchoToggleCb(lv_event_t* e) { the_mesh.setCompanionRetryEnabled(on); } static void radioSigPollSaveCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !s_radio_sig_poll_ta) return; kbMirrorSyncToReal(); @@ -11106,6 +11137,7 @@ static void buildQuickReplySettings() { } static void saveScreenTimeoutCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed const lv_event_code_t _c = lv_event_get_code(e); if ((_c != LV_EVENT_CLICKED && _c != LV_EVENT_DEFOCUSED) || !g_lv.task) return; if (!g_set_modal.screen_to_ta) return; @@ -13916,6 +13948,7 @@ static void bleEnableSwitchCb(lv_event_t* e) { // Pairing code: persist a 6-digit PIN on blur (applies next reboot, same contract as the // companion CMD_SET_DEVICE_PIN). Silent if the field is mid-edit (not exactly 6 digits). static void blePinSaveCb(lv_event_t* e) { + if (blurFromDelete(e)) return; // the widget is being destroyed if (lv_event_get_code(e) != LV_EVENT_DEFOCUSED || !g_lv.task) return; if (!s_ble_pin_ta || !lv_obj_is_valid(s_ble_pin_ta)) return; kbMirrorSyncToReal(); From cecede23be1c659ae6d92014cd3555bb000e74cb Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 10:23:09 +0200 Subject: [PATCH 14/22] i18n: undo the keys my extractor invented, and stop it inventing more pisti87 read v18 and called it chaos. He was right, and it was mine. Two defects in the extractor I added yesterday: - It joined every string literal in a call argument, so a ternary became one key. `cut ? "Paste (move)" : "Paste (copy)"` shipped as the single key "Paste (move)Paste (copy)", and with it "Unblock Block", "Unfav Favorite", "Other networksNetworks", "Batteryactivityon" and "Stop sharing loc Share my loc". None of those strings exist anywhere in the firmware. Literals are now grouped only when genuinely adjacent, which is what the compiler concatenates, so both branches become their own key. - It scanned raw source, comments included. That is how "Geblokkeerde gebruikers" -- Dutch, appearing only inside a comment about how a long translation degrades -- became a KEY in the Hungarian file. Comments are stripped now, string literals preserved. 202 invented rows removed across the thirteen files. Only rows that were both unknown to the extractor AND still untranslated were touched, so no translator's work could be lost either way. Also his: the curly quotes in the Hungarian credits render as boxes because no bundled font carries U+201E/U+201D. Five values de-curled. And five strings he found raw are wrapped: the Wi-Fi rescan and hidden-network rows, the update check, and the downgrade prompt. The two Wi-Fi rows build their label at runtime, since TR() returns a pointer and cannot join a glyph literal at compile time. Languages go to v19. v18 is deleted rather than left behind: it was only ever correct for about a day and everything in it is superseded. Co-Authored-By: Claude Opus 5 --- deploy/apps/lang/{18 => 19}/bg.lang | 30 ++++----- deploy/apps/lang/{18 => 19}/de.lang | 34 ++++------ deploy/apps/lang/{18 => 19}/el.lang | 28 ++++---- deploy/apps/lang/{18 => 19}/es.lang | 28 ++++---- deploy/apps/lang/{18 => 19}/fr.lang | 29 ++++----- deploy/apps/lang/{18 => 19}/hu.lang | 28 ++++---- deploy/apps/lang/{18 => 19}/it.lang | 29 ++++----- deploy/apps/lang/{18 => 19}/nl.lang | 31 ++++----- deploy/apps/lang/{18 => 19}/pt-br.lang | 29 ++++----- deploy/apps/lang/{18 => 19}/ro.lang | 27 ++++---- deploy/apps/lang/{18 => 19}/ru.lang | 28 ++++---- deploy/apps/lang/{18 => 19}/sr.lang | 30 ++++----- deploy/apps/lang/{18 => 19}/uk.lang | 28 ++++---- deploy/apps/lang/bg.lang | 30 ++++----- deploy/apps/lang/de.lang | 34 ++++------ deploy/apps/lang/el.lang | 28 ++++---- deploy/apps/lang/es.lang | 28 ++++---- deploy/apps/lang/fr.lang | 29 ++++----- deploy/apps/lang/hu.lang | 28 ++++---- deploy/apps/lang/it.lang | 29 ++++----- deploy/apps/lang/nl.lang | 31 ++++----- deploy/apps/lang/pt-br.lang | 29 ++++----- deploy/apps/lang/ro.lang | 27 ++++---- deploy/apps/lang/ru.lang | 28 ++++---- deploy/apps/lang/sr.lang | 30 ++++----- deploy/apps/lang/uk.lang | 28 ++++---- deploy/apps/langs.json | 26 ++++---- scripts/build/i18n-audit.py | 88 +++++++++++++++++++++----- src/ui-touch/UITask.cpp | 19 ++++-- src/ui-touch/i18n_builtin.h | 11 ++-- 30 files changed, 423 insertions(+), 479 deletions(-) rename deploy/apps/lang/{18 => 19}/bg.lang (97%) rename deploy/apps/lang/{18 => 19}/de.lang (97%) rename deploy/apps/lang/{18 => 19}/el.lang (97%) rename deploy/apps/lang/{18 => 19}/es.lang (97%) rename deploy/apps/lang/{18 => 19}/fr.lang (97%) rename deploy/apps/lang/{18 => 19}/hu.lang (96%) rename deploy/apps/lang/{18 => 19}/it.lang (97%) rename deploy/apps/lang/{18 => 19}/nl.lang (97%) rename deploy/apps/lang/{18 => 19}/pt-br.lang (97%) rename deploy/apps/lang/{18 => 19}/ro.lang (97%) rename deploy/apps/lang/{18 => 19}/ru.lang (97%) rename deploy/apps/lang/{18 => 19}/sr.lang (97%) rename deploy/apps/lang/{18 => 19}/uk.lang (97%) diff --git a/deploy/apps/lang/18/bg.lang b/deploy/apps/lang/19/bg.lang similarity index 97% rename from deploy/apps/lang/18/bg.lang rename to deploy/apps/lang/19/bg.lang index 92bcace..d824f0f 100644 --- a/deploy/apps/lang/18/bg.lang +++ b/deploy/apps/lang/19/bg.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: bg -# ver: 18 +# ver: 19 # name: Български # base: bg (device behind) (устройството изостава) @@ -826,7 +826,7 @@ TCP on TCP вкл TOUCH BETA TOUCH BETA TX TX Tab hotkeys — tap a row, then press a key Клавиши за раздела — докосни ред, после натисни клавиш -Tap Use to switch - the device reboots to apply. Натиснете „Избери“, за да превключите — устройството ще се рестартира. +Tap Use to switch - the device reboots to apply. Натиснете "Избери", за да превключите — устройството ще се рестартира. Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои. Tap a language; the device reboots to apply. Изберете език; устройството ще се рестартира. Taste the rainbow! Вкуси дъгата! @@ -977,8 +977,6 @@ tap to exit full screen докоснете за изход от цял екра zoom мащаб zoom %d мащаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. усилвател ~17 dB за тихи/отдалечени места; изключи в шумни. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/de.lang b/deploy/apps/lang/19/de.lang similarity index 97% rename from deploy/apps/lang/18/de.lang rename to deploy/apps/lang/19/de.lang index 60d5b56..4483ba4 100644 --- a/deploy/apps/lang/18/de.lang +++ b/deploy/apps/lang/19/de.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: de -# ver: 18 +# ver: 19 # name: Deutsch # base: de (device behind) (Gerät geht nach) @@ -452,7 +452,6 @@ Login OK (perms %u) Anmeldung OK (Rechte %u) Login failed Anmeldung fehlgeschlagen Logs Protokolle Longitude Längengrad -Lua Store Lua Store MHz MHz MQTT bridge MQTT-Brücke Map Karte @@ -694,7 +693,7 @@ SD card remounted SD-Karte neu eingebunden SD card removed SD-Karte entfernt SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden. SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden. -SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden. +SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; "Interne Daten auf SD kopieren" zum erneuten Versuch verwenden. SD download failed: %s SD-Download fehlgeschlagen: %s SD formatted - %s (MESHCOMOD) SD formatiert - %s (MESHCOMOD) SD is busy - close tools and retry SD ist belegt – Tools schließen und erneut versuchen @@ -797,7 +796,6 @@ Sleep ready Bereit zum Schlafen Slot %d cleared Slot %d gelöscht Slot empty Slot leer Small\nMedium\nLarge Klein\nMittel\nGroß -Snake Snake Snake — swipe or roll the trackball Snake — wischen oder Trackball rollen Sort Sortieren Sound Ton @@ -805,7 +803,6 @@ Sound off Ton aus Sound on Ton ein Sound: %s Ton: %s Start time Startzeit -Status Status Step 1 of 3 Schritt 1 von 3 Step 2 of 3 Schritt 2 von 3 Step 3 of 3 Schritt 3 von 3 @@ -818,7 +815,6 @@ Streams the device screen as a live picture you tap to control. Heavier; the pan Switch Wechseln Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot. Auf die externe Antenne wechseln?\n\nErst sicherstellen, dass wirklich eine Antenne an der Buchse steckt. Senden ohne Antenne kann das Funkmodul beschädigen.\n\nFällt bei jedem Neustart auf die interne Antenne zurück. Sync clock from system Uhr synchronisieren -System System System info Systeminfo TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s TCP off TCP aus @@ -977,8 +973,6 @@ tap to exit full screen tippen zum Verlassen des Vollbilds zoom Zoom zoom %d Zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +994,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1007,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1033,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1041,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1050,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1062,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1079,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/el.lang b/deploy/apps/lang/19/el.lang similarity index 97% rename from deploy/apps/lang/18/el.lang rename to deploy/apps/lang/19/el.lang index 203ba0b..9eb2146 100644 --- a/deploy/apps/lang/18/el.lang +++ b/deploy/apps/lang/19/el.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: el -# ver: 18 +# ver: 19 # name: Ελληνικά # base: el (device behind) (συσκευή πίσω) @@ -977,8 +977,6 @@ tap to exit full screen πατήστε για έξοδο από πλήρη οθ zoom ζουμ zoom %d ζουμ %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/es.lang b/deploy/apps/lang/19/es.lang similarity index 97% rename from deploy/apps/lang/18/es.lang rename to deploy/apps/lang/19/es.lang index fe56063..5b9d9dc 100644 --- a/deploy/apps/lang/18/es.lang +++ b/deploy/apps/lang/19/es.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: es -# ver: 18 +# ver: 19 # name: Español # base: es (device behind) (dispositivo atrasado) @@ -977,8 +977,6 @@ tap to exit full screen toca para salir de pantalla completa zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/fr.lang b/deploy/apps/lang/19/fr.lang similarity index 97% rename from deploy/apps/lang/18/fr.lang rename to deploy/apps/lang/19/fr.lang index 7d5a664..694b405 100644 --- a/deploy/apps/lang/18/fr.lang +++ b/deploy/apps/lang/19/fr.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: fr -# ver: 18 +# ver: 19 # name: Français # base: fr (device behind) (appareil en retard) @@ -452,7 +452,6 @@ Login OK (perms %u) Connexion OK (perms %u) Login failed Échec de connexion Logs Journaux Longitude Longitude -Lua Store Lua Store MHz MHz MQTT bridge Pont MQTT Map Carte @@ -977,8 +976,6 @@ tap to exit full screen toucher pour quitter le plein écran zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/hu.lang b/deploy/apps/lang/19/hu.lang similarity index 96% rename from deploy/apps/lang/18/hu.lang rename to deploy/apps/lang/19/hu.lang index 84dfac7..a2fd72d 100644 --- a/deploy/apps/lang/18/hu.lang +++ b/deploy/apps/lang/19/hu.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: hu -# ver: 18 +# ver: 19 # name: Magyar # base: hu (device behind) (készülék hátulján) @@ -703,7 +703,7 @@ SD card remounted SD kártya újracsatolva SD card removed SD-kártya eltávolítva SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig. SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig. -SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget. +SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a "Belső adatok másolása SD-kártyára" lehetőséget. SD download failed: %s SD-letöltés sikertelen: %s SD formatted - %s (MESHCOMOD) SD formázás - %s (MESHCOMOD) SD is busy - close tools and retry Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra. @@ -812,7 +812,6 @@ Sleep ready Készen áll az alvásra Slot %d cleared %d hely kiürítve Slot empty Üres hely Small\nMedium\nLarge Kicsi\nKözepes\nNagy -Snake Snake Snake — swipe or roll the trackball Kígyó – pöccintsd vagy görgesd a görgetőgolyót Sort Sort Sound Hang @@ -997,8 +996,6 @@ tap to exit full screen koppintson a teljes képernyős kilépéshez zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB erősítővel csendes/távoli helyeken; zajos helyeken kapcsold ki. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope Hatókör another region másik régió my region saját régió @@ -1065,11 +1062,10 @@ sensors érzékelők Auto-add on for %s — new %s land in Contacts automatically. Automatikus hozzáadás bekapcsolva: %s — az új %s automatikusan a Névjegyek közé kerülnek. Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed under the Open Database License (ODbL).\n\n Térkép adatok © OpenStreetMap közreműködői.\nopenstreetmap.org/copyright\nAz Open Database License (ODbL) licenc alatt.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Térkép stílus © OpenTopoMap (CC-BY-SA) — opentopomap.org\nTérkép adatok © OpenStreetMap közreműködői (ODbL) + SRTM.\n\n -How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg. +How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos "slippy" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg. "%s" wants to send discovery probes.\n\nEach probe asks every node in range to reply, so it uses airtime on the whole local mesh, not just yours.\n\nNothing is sent under your name and no message is transmitted. "%s" felfedezési probe-ok küldését kéri.\n\nMinden probe válaszra kéri a hatótávolságon belüli node-okat, ezért a teljes helyi mesh hálózat airtime-ját használja, nem csak a tiédet.\n\nSemmi sem kerül elküldésre a nevedben, és üzenet sem kerül továbbításra. ? ? Auth failed Auth failed -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Csak közvetlen (0-hop) @@ -1078,16 +1074,12 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Szöveg megadása Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... Link lost Link lost MESH MESH Name (A-Z) Név (A–Z) Nearest first Legközelebbi elöl -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Legutóbbi üzenet Reload tiles in view Csempék újratöltése Repeaters Repeaters @@ -1098,13 +1090,8 @@ Show contacts Névjegyek megjelenítése Show coordinates Koordináták Show tile z/x/y Csempe z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Csempe hiba keret -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite tap to cycle off / on / auto tap to cycle off / on / auto #7A7F87 Scanning… nothing has answered yet# #7A7F87 Scanning… nothing has answered yet# Export crash report (%uK) Hibajelentés exportálása (%uK) @@ -1116,3 +1103,12 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul. +Other (hidden) network… Other (hidden) network… +Paused Paused diff --git a/deploy/apps/lang/18/it.lang b/deploy/apps/lang/19/it.lang similarity index 97% rename from deploy/apps/lang/18/it.lang rename to deploy/apps/lang/19/it.lang index e0350d1..203cdf5 100644 --- a/deploy/apps/lang/18/it.lang +++ b/deploy/apps/lang/19/it.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: it -# ver: 18 +# ver: 19 # name: Italiano # base: it (device behind) (dispositivo indietro) @@ -452,7 +452,6 @@ Login OK (perms %u) Accesso OK (permessi %u) Login failed Accesso non riuscito Logs Log Longitude Longitudine -Lua Store Lua Store MHz MHz MQTT bridge Ponte MQTT Map Mappa @@ -977,8 +976,6 @@ tap to exit full screen tocca per uscire da schermo intero zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/nl.lang b/deploy/apps/lang/19/nl.lang similarity index 97% rename from deploy/apps/lang/18/nl.lang rename to deploy/apps/lang/19/nl.lang index a7f4f93..a04eb65 100644 --- a/deploy/apps/lang/18/nl.lang +++ b/deploy/apps/lang/19/nl.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: nl -# ver: 18 +# ver: 19 # name: Nederlands # base: nl (device behind) (apparaat loopt achter) @@ -452,7 +452,6 @@ Login OK (perms %u) Inloggen OK (rechten %u) Login failed Inloggen mislukt Logs Logboeken Longitude Lengtegraad -Lua Store Lua Store MHz MHz MQTT bridge MQTT-brug Map Kaart @@ -797,7 +796,6 @@ Sleep ready Klaar voor slaap Slot %d cleared Slot %d gewist Slot empty Slot leeg Small\nMedium\nLarge Klein\nMiddel\nGroot -Snake Snake Snake — swipe or roll the trackball Snake — veeg of rol de trackball Sort Sorteer Sound Geluid @@ -805,7 +803,6 @@ Sound off Geluid uit Sound on Geluid aan Sound: %s Geluid: %s Start time Starttijd -Status Status Step 1 of 3 Stap 1 van 3 Step 2 of 3 Stap 2 van 3 Step 3 of 3 Stap 3 van 3 @@ -977,8 +974,6 @@ tap to exit full screen tik om volledig scherm te verlaten zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +995,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1008,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1034,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1042,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1051,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1063,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1080,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/pt-br.lang b/deploy/apps/lang/19/pt-br.lang similarity index 97% rename from deploy/apps/lang/18/pt-br.lang rename to deploy/apps/lang/19/pt-br.lang index e39cb21..98ad61b 100644 --- a/deploy/apps/lang/18/pt-br.lang +++ b/deploy/apps/lang/19/pt-br.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: pt-br -# ver: 18 +# ver: 19 # name: Português (BR) # base: pt-br (device behind) (dispositivo atrasado) @@ -805,7 +805,6 @@ Sound off Som desligado Sound on Som ligado Sound: %s Som: %s Start time Hora de início -Status Status Step 1 of 3 Passo 1 de 3 Step 2 of 3 Passo 2 de 3 Step 3 of 3 Passo 3 de 3 @@ -977,8 +976,6 @@ tap to exit full screen toque para sair da tela cheia zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/ro.lang b/deploy/apps/lang/19/ro.lang similarity index 97% rename from deploy/apps/lang/18/ro.lang rename to deploy/apps/lang/19/ro.lang index 6967b9d..05cb60e 100644 --- a/deploy/apps/lang/18/ro.lang +++ b/deploy/apps/lang/19/ro.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: ro -# ver: 18 +# ver: 19 # name: Română # base: ro (device behind) (dispozitiv în urmă) @@ -978,7 +978,6 @@ zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificator ~17 dB pentru zone liniştite/izolate; opreşte-l în locuri zgomotoase. © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +999,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1012,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1038,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1046,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1055,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1067,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1084,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/ru.lang b/deploy/apps/lang/19/ru.lang similarity index 97% rename from deploy/apps/lang/18/ru.lang rename to deploy/apps/lang/19/ru.lang index ceb1ec3..04fa21c 100644 --- a/deploy/apps/lang/18/ru.lang +++ b/deploy/apps/lang/19/ru.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: ru -# ver: 18 +# ver: 19 # name: Русский # base: ru (device behind) (устройство отстаёт) @@ -977,8 +977,6 @@ tap to exit full screen нажмите для выхода из полноэкр zoom масштаб zoom %d масштаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/sr.lang b/deploy/apps/lang/19/sr.lang similarity index 97% rename from deploy/apps/lang/18/sr.lang rename to deploy/apps/lang/19/sr.lang index 0aab3e1..836e0b5 100644 --- a/deploy/apps/lang/18/sr.lang +++ b/deploy/apps/lang/19/sr.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: sr -# ver: 18 +# ver: 19 # name: Српски # base: sr (device behind) (уређај касни) @@ -826,7 +826,7 @@ TCP on TCP укљ TOUCH BETA TOUCH BETA TX TX Tab hotkeys — tap a row, then press a key Пречице картице — додирни ред, па притисни тастер -Tap Use to switch - the device reboots to apply. Додирните „Користи“ да промените — уређај ће се поново покренути. +Tap Use to switch - the device reboots to apply. Додирните "Користи" да промените — уређај ће се поново покренути. Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје. Tap a language; the device reboots to apply. Изаберите језик; уређај ће се рестартовати. Taste the rainbow! Окуси дугу! @@ -977,8 +977,6 @@ tap to exit full screen додирните за излаз из целог ек zoom зум zoom %d зум %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. појачало ~17 dB за тиха/удаљена места; искључи у бучним. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/18/uk.lang b/deploy/apps/lang/19/uk.lang similarity index 97% rename from deploy/apps/lang/18/uk.lang rename to deploy/apps/lang/19/uk.lang index 4ebd00f..cde3704 100644 --- a/deploy/apps/lang/18/uk.lang +++ b/deploy/apps/lang/19/uk.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: uk -# ver: 18 +# ver: 19 # name: Українська # base: uk (device behind) (пристрій відстає) @@ -977,8 +977,6 @@ tap to exit full screen торкніться, щоб вийти з повное zoom масштаб zoom %d масштаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/bg.lang b/deploy/apps/lang/bg.lang index 92bcace..d824f0f 100644 --- a/deploy/apps/lang/bg.lang +++ b/deploy/apps/lang/bg.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: bg -# ver: 18 +# ver: 19 # name: Български # base: bg (device behind) (устройството изостава) @@ -826,7 +826,7 @@ TCP on TCP вкл TOUCH BETA TOUCH BETA TX TX Tab hotkeys — tap a row, then press a key Клавиши за раздела — докосни ред, после натисни клавиш -Tap Use to switch - the device reboots to apply. Натиснете „Избери“, за да превключите — устройството ще се рестартира. +Tap Use to switch - the device reboots to apply. Натиснете "Избери", за да превключите — устройството ще се рестартира. Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои. Tap a language; the device reboots to apply. Изберете език; устройството ще се рестартира. Taste the rainbow! Вкуси дъгата! @@ -977,8 +977,6 @@ tap to exit full screen докоснете за изход от цял екра zoom мащаб zoom %d мащаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. усилвател ~17 dB за тихи/отдалечени места; изключи в шумни. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/de.lang b/deploy/apps/lang/de.lang index 60d5b56..4483ba4 100644 --- a/deploy/apps/lang/de.lang +++ b/deploy/apps/lang/de.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: de -# ver: 18 +# ver: 19 # name: Deutsch # base: de (device behind) (Gerät geht nach) @@ -452,7 +452,6 @@ Login OK (perms %u) Anmeldung OK (Rechte %u) Login failed Anmeldung fehlgeschlagen Logs Protokolle Longitude Längengrad -Lua Store Lua Store MHz MHz MQTT bridge MQTT-Brücke Map Karte @@ -694,7 +693,7 @@ SD card remounted SD-Karte neu eingebunden SD card removed SD-Karte entfernt SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden. SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden. -SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden. +SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; "Interne Daten auf SD kopieren" zum erneuten Versuch verwenden. SD download failed: %s SD-Download fehlgeschlagen: %s SD formatted - %s (MESHCOMOD) SD formatiert - %s (MESHCOMOD) SD is busy - close tools and retry SD ist belegt – Tools schließen und erneut versuchen @@ -797,7 +796,6 @@ Sleep ready Bereit zum Schlafen Slot %d cleared Slot %d gelöscht Slot empty Slot leer Small\nMedium\nLarge Klein\nMittel\nGroß -Snake Snake Snake — swipe or roll the trackball Snake — wischen oder Trackball rollen Sort Sortieren Sound Ton @@ -805,7 +803,6 @@ Sound off Ton aus Sound on Ton ein Sound: %s Ton: %s Start time Startzeit -Status Status Step 1 of 3 Schritt 1 von 3 Step 2 of 3 Schritt 2 von 3 Step 3 of 3 Schritt 3 von 3 @@ -818,7 +815,6 @@ Streams the device screen as a live picture you tap to control. Heavier; the pan Switch Wechseln Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot. Auf die externe Antenne wechseln?\n\nErst sicherstellen, dass wirklich eine Antenne an der Buchse steckt. Senden ohne Antenne kann das Funkmodul beschädigen.\n\nFällt bei jedem Neustart auf die interne Antenne zurück. Sync clock from system Uhr synchronisieren -System System System info Systeminfo TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s TCP off TCP aus @@ -977,8 +973,6 @@ tap to exit full screen tippen zum Verlassen des Vollbilds zoom Zoom zoom %d Zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +994,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1007,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1033,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1041,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1050,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1062,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1079,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/el.lang b/deploy/apps/lang/el.lang index 203ba0b..9eb2146 100644 --- a/deploy/apps/lang/el.lang +++ b/deploy/apps/lang/el.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: el -# ver: 18 +# ver: 19 # name: Ελληνικά # base: el (device behind) (συσκευή πίσω) @@ -977,8 +977,6 @@ tap to exit full screen πατήστε για έξοδο από πλήρη οθ zoom ζουμ zoom %d ζουμ %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/es.lang b/deploy/apps/lang/es.lang index fe56063..5b9d9dc 100644 --- a/deploy/apps/lang/es.lang +++ b/deploy/apps/lang/es.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: es -# ver: 18 +# ver: 19 # name: Español # base: es (device behind) (dispositivo atrasado) @@ -977,8 +977,6 @@ tap to exit full screen toca para salir de pantalla completa zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/fr.lang b/deploy/apps/lang/fr.lang index 7d5a664..694b405 100644 --- a/deploy/apps/lang/fr.lang +++ b/deploy/apps/lang/fr.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: fr -# ver: 18 +# ver: 19 # name: Français # base: fr (device behind) (appareil en retard) @@ -452,7 +452,6 @@ Login OK (perms %u) Connexion OK (perms %u) Login failed Échec de connexion Logs Journaux Longitude Longitude -Lua Store Lua Store MHz MHz MQTT bridge Pont MQTT Map Carte @@ -977,8 +976,6 @@ tap to exit full screen toucher pour quitter le plein écran zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/hu.lang b/deploy/apps/lang/hu.lang index 84dfac7..a2fd72d 100644 --- a/deploy/apps/lang/hu.lang +++ b/deploy/apps/lang/hu.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: hu -# ver: 18 +# ver: 19 # name: Magyar # base: hu (device behind) (készülék hátulján) @@ -703,7 +703,7 @@ SD card remounted SD kártya újracsatolva SD card removed SD-kártya eltávolítva SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig. SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig. -SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget. +SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a "Belső adatok másolása SD-kártyára" lehetőséget. SD download failed: %s SD-letöltés sikertelen: %s SD formatted - %s (MESHCOMOD) SD formázás - %s (MESHCOMOD) SD is busy - close tools and retry Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra. @@ -812,7 +812,6 @@ Sleep ready Készen áll az alvásra Slot %d cleared %d hely kiürítve Slot empty Üres hely Small\nMedium\nLarge Kicsi\nKözepes\nNagy -Snake Snake Snake — swipe or roll the trackball Kígyó – pöccintsd vagy görgesd a görgetőgolyót Sort Sort Sound Hang @@ -997,8 +996,6 @@ tap to exit full screen koppintson a teljes képernyős kilépéshez zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB erősítővel csendes/távoli helyeken; zajos helyeken kapcsold ki. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope Hatókör another region másik régió my region saját régió @@ -1065,11 +1062,10 @@ sensors érzékelők Auto-add on for %s — new %s land in Contacts automatically. Automatikus hozzáadás bekapcsolva: %s — az új %s automatikusan a Névjegyek közé kerülnek. Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed under the Open Database License (ODbL).\n\n Térkép adatok © OpenStreetMap közreműködői.\nopenstreetmap.org/copyright\nAz Open Database License (ODbL) licenc alatt.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Térkép stílus © OpenTopoMap (CC-BY-SA) — opentopomap.org\nTérkép adatok © OpenStreetMap közreműködői (ODbL) + SRTM.\n\n -How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg. +How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos "slippy" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg. "%s" wants to send discovery probes.\n\nEach probe asks every node in range to reply, so it uses airtime on the whole local mesh, not just yours.\n\nNothing is sent under your name and no message is transmitted. "%s" felfedezési probe-ok küldését kéri.\n\nMinden probe válaszra kéri a hatótávolságon belüli node-okat, ezért a teljes helyi mesh hálózat airtime-ját használja, nem csak a tiédet.\n\nSemmi sem kerül elküldésre a nevedben, és üzenet sem kerül továbbításra. ? ? Auth failed Auth failed -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Csak közvetlen (0-hop) @@ -1078,16 +1074,12 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Szöveg megadása Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... Link lost Link lost MESH MESH Name (A-Z) Név (A–Z) Nearest first Legközelebbi elöl -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Legutóbbi üzenet Reload tiles in view Csempék újratöltése Repeaters Repeaters @@ -1098,13 +1090,8 @@ Show contacts Névjegyek megjelenítése Show coordinates Koordináták Show tile z/x/y Csempe z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Csempe hiba keret -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite tap to cycle off / on / auto tap to cycle off / on / auto #7A7F87 Scanning… nothing has answered yet# #7A7F87 Scanning… nothing has answered yet# Export crash report (%uK) Hibajelentés exportálása (%uK) @@ -1116,3 +1103,12 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul. +Other (hidden) network… Other (hidden) network… +Paused Paused diff --git a/deploy/apps/lang/it.lang b/deploy/apps/lang/it.lang index e0350d1..203cdf5 100644 --- a/deploy/apps/lang/it.lang +++ b/deploy/apps/lang/it.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: it -# ver: 18 +# ver: 19 # name: Italiano # base: it (device behind) (dispositivo indietro) @@ -452,7 +452,6 @@ Login OK (perms %u) Accesso OK (permessi %u) Login failed Accesso non riuscito Logs Log Longitude Longitudine -Lua Store Lua Store MHz MHz MQTT bridge Ponte MQTT Map Mappa @@ -977,8 +976,6 @@ tap to exit full screen tocca per uscire da schermo intero zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/nl.lang b/deploy/apps/lang/nl.lang index a7f4f93..a04eb65 100644 --- a/deploy/apps/lang/nl.lang +++ b/deploy/apps/lang/nl.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: nl -# ver: 18 +# ver: 19 # name: Nederlands # base: nl (device behind) (apparaat loopt achter) @@ -452,7 +452,6 @@ Login OK (perms %u) Inloggen OK (rechten %u) Login failed Inloggen mislukt Logs Logboeken Longitude Lengtegraad -Lua Store Lua Store MHz MHz MQTT bridge MQTT-brug Map Kaart @@ -797,7 +796,6 @@ Sleep ready Klaar voor slaap Slot %d cleared Slot %d gewist Slot empty Slot leeg Small\nMedium\nLarge Klein\nMiddel\nGroot -Snake Snake Snake — swipe or roll the trackball Snake — veeg of rol de trackball Sort Sorteer Sound Geluid @@ -805,7 +803,6 @@ Sound off Geluid uit Sound on Geluid aan Sound: %s Geluid: %s Start time Starttijd -Status Status Step 1 of 3 Stap 1 van 3 Step 2 of 3 Stap 2 van 3 Step 3 of 3 Stap 3 van 3 @@ -977,8 +974,6 @@ tap to exit full screen tik om volledig scherm te verlaten zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +995,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1008,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1034,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1042,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1051,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1063,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1080,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/pt-br.lang b/deploy/apps/lang/pt-br.lang index e39cb21..98ad61b 100644 --- a/deploy/apps/lang/pt-br.lang +++ b/deploy/apps/lang/pt-br.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: pt-br -# ver: 18 +# ver: 19 # name: Português (BR) # base: pt-br (device behind) (dispositivo atrasado) @@ -805,7 +805,6 @@ Sound off Som desligado Sound on Som ligado Sound: %s Som: %s Start time Hora de início -Status Status Step 1 of 3 Passo 1 de 3 Step 2 of 3 Passo 2 de 3 Step 3 of 3 Passo 3 de 3 @@ -977,8 +976,6 @@ tap to exit full screen toque para sair da tela cheia zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +997,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1036,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1065,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1082,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/ro.lang b/deploy/apps/lang/ro.lang index 6967b9d..05cb60e 100644 --- a/deploy/apps/lang/ro.lang +++ b/deploy/apps/lang/ro.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: ro -# ver: 18 +# ver: 19 # name: Română # base: ro (device behind) (dispozitiv în urmă) @@ -978,7 +978,6 @@ zoom zoom zoom %d zoom %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificator ~17 dB pentru zone liniştite/izolate; opreşte-l în locuri zgomotoase. © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +999,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1012,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1038,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1046,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1055,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1067,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1084,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/ru.lang b/deploy/apps/lang/ru.lang index ceb1ec3..04fa21c 100644 --- a/deploy/apps/lang/ru.lang +++ b/deploy/apps/lang/ru.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: ru -# ver: 18 +# ver: 19 # name: Русский # base: ru (device behind) (устройство отстаёт) @@ -977,8 +977,6 @@ tap to exit full screen нажмите для выхода из полноэкр zoom масштаб zoom %d масштаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/sr.lang b/deploy/apps/lang/sr.lang index 0aab3e1..836e0b5 100644 --- a/deploy/apps/lang/sr.lang +++ b/deploy/apps/lang/sr.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: sr -# ver: 18 +# ver: 19 # name: Српски # base: sr (device behind) (уређај касни) @@ -826,7 +826,7 @@ TCP on TCP укљ TOUCH BETA TOUCH BETA TX TX Tab hotkeys — tap a row, then press a key Пречице картице — додирни ред, па притисни тастер -Tap Use to switch - the device reboots to apply. Додирните „Користи“ да промените — уређај ће се поново покренути. +Tap Use to switch - the device reboots to apply. Додирните "Користи" да промените — уређај ће се поново покренути. Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје. Tap a language; the device reboots to apply. Изаберите језик; уређај ће се рестартовати. Taste the rainbow! Окуси дугу! @@ -977,8 +977,6 @@ tap to exit full screen додирните за излаз из целог ек zoom зум zoom %d зум %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. појачало ~17 dB за тиха/удаљена места; искључи у бучним. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/lang/uk.lang b/deploy/apps/lang/uk.lang index 4ebd00f..cde3704 100644 --- a/deploy/apps/lang/uk.lang +++ b/deploy/apps/lang/uk.lang @@ -1,7 +1,7 @@ # wadamesh language file # canonical source - translators: edit this file and PR it # code: uk -# ver: 18 +# ver: 19 # name: Українська # base: uk (device behind) (пристрій відстає) @@ -977,8 +977,6 @@ tap to exit full screen торкніться, щоб вийти з повное zoom масштаб zoom %d масштаб %d ~17 dB amp for quiet/remote areas; turn off in noisy spots. підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих. -© OpenStreetMap © OpenStreetMap -© OpenTopoMap © OpenTopoMap Scope another region my region @@ -1000,7 +998,6 @@ Overwrite oldest non-favorite 1-character messages will be shown 1-character messages will be shown All All Allow Allow -Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Ignore 1-character messages Ignore 1-character messages Join w/ password Join w/ password Location no longer shared Location no longer shared @@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone No apps installed. No apps installed. No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Not supported on this board Not supported on this board -Read incoming messages Read incoming messages Reconnected to %.40s Reconnected to %.40s -Send messages as me Send messages as me Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password. Share my loc Share my loc @@ -1042,7 +1037,6 @@ App permissions App permissions About / credits About / credits Auth failed Auth failed Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically. -Batteryactivityon Batteryactivityon Console mode Console mode Console mode (experimental) Console mode (experimental) Direct (0-hop) only Direct (0-hop) only @@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman Enter text Enter text Entering console mode - rebooting... Entering console mode - rebooting... Favorite Favorite -Geblokkeerde gebruikers Geblokkeerde gebruikers -Hide system filesShow system files Hide system filesShow system files How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. Init… Init… Leaving console mode - rebooting... Leaving console mode - rebooting... @@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Name (A-Z) Name (A-Z) Nearest first Nearest first -Other networksNetworks Other networksNetworks -Paste (move)Paste (copy) Paste (move)Paste (copy) Recent message Recent message Reload tiles in view Reload tiles in view Repeaters Repeaters @@ -1076,13 +1066,8 @@ Show contacts Show contacts Show coordinates Show coordinates Show tile z/x/y Show tile z/x/y Starting… Starting… -Stop sharing loc Share my loc Stop sharing loc Share my loc -Supprimer Supprimer -Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Text console (experimental) Text console (experimental) Tile debug overlay Tile debug overlay -Unblock Block Unblock Block -Unfav Favorite Unfav Favorite chats chats repeaters repeaters rooms rooms @@ -1098,3 +1083,14 @@ channel busy %.1f%% channel busy %.1f%% sampling... sampling... teal = now blue = average teal = now blue = average window restarted window restarted +Filter Filter +Hide system files Hide system files +Networks Networks +Other networks Other networks +Show system files Show system files +Unfav Unfav +Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi… +Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots. +Other (hidden) network… Other (hidden) network… +Paused Paused +Scan again Scan again diff --git a/deploy/apps/langs.json b/deploy/apps/langs.json index 76bba5e..7467d82 100644 --- a/deploy/apps/langs.json +++ b/deploy/apps/langs.json @@ -3,67 +3,67 @@ { "code": "bg", "name": "Български", - "ver": "18" + "ver": "19" }, { "code": "de", "name": "Deutsch", - "ver": "18" + "ver": "19" }, { "code": "el", "name": "Ελληνικά", - "ver": "18" + "ver": "19" }, { "code": "es", "name": "Español", - "ver": "18" + "ver": "19" }, { "code": "fr", "name": "Français", - "ver": "18" + "ver": "19" }, { "code": "hu", "name": "Magyar", - "ver": "18" + "ver": "19" }, { "code": "it", "name": "Italiano", - "ver": "18" + "ver": "19" }, { "code": "nl", "name": "Nederlands", - "ver": "18" + "ver": "19" }, { "code": "pt-br", "name": "Português (BR)", - "ver": "18" + "ver": "19" }, { "code": "ro", "name": "Română", - "ver": "18" + "ver": "19" }, { "code": "ru", "name": "Русский", - "ver": "18" + "ver": "19" }, { "code": "sr", "name": "Српски", - "ver": "18" + "ver": "19" }, { "code": "uk", "name": "Українська", - "ver": "18" + "ver": "19" } ] } diff --git a/scripts/build/i18n-audit.py b/scripts/build/i18n-audit.py index 1466401..1f56efb 100644 --- a/scripts/build/i18n-audit.py +++ b/scripts/build/i18n-audit.py @@ -31,7 +31,7 @@ def source_keys(): keys = set() for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \ glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) # An optional LV_SYMBOL_* macro may precede the literal: TR(LV_SYMBOL_OK " Text"). # Requiring the group to START with a quote silently skipped every such # call - the audit's worst blind spot (the sheet rows never got checked). @@ -55,7 +55,7 @@ def source_keys(): # collecting keeps a translation alive, under-collecting deletes one. for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \ glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) for name in set(re.findall(r'TR\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[', src)): for m in re.finditer(r'\b' + re.escape(name) + r'\s*\[[^\]]*\]\s*=\s*\{', src): i, depth = m.end(), 1 @@ -80,24 +80,22 @@ def source_keys(): # parameters and note which parameter, then pull the literal out of that # argument position at every call site. for f in _sources(): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) for name, idx in _tr_wrapping_helpers(src): for arg in _call_args_at(src, name, idx): - parts = re.findall(r'"((?:[^"\\]|\\.)*)"', arg) - if not parts: - continue - k = unescape_c(''.join(parts)) - while k and 0xE000 <= ord(k[0]) <= 0xF8FF: - k = k[1:] - k = k.lstrip(' ') - if k.strip(): - keys.add(k) + for lit in literal_groups(arg): + k = unescape_c(lit) + while k and 0xE000 <= ord(k[0]) <= 0xF8FF: + k = k[1:] + k = k.lstrip(' ') + if k.strip(): + keys.add(k) # RANGE-FOR over a local table: `struct {...} rows[] = {{"Show contacts", ...}};` # then `for (auto& r : rows) TR(r.label)`. Same blind spot as the helper case -- # correct at runtime, invisible to a TR("literal") scan, so untranslatable. for f in _sources(): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) for m in re.finditer(r'for\s*\(\s*(?:const\s+)?auto\s*&?\s*(\w+)\s*:\s*(\w+)\s*\)', src): var, tbl = m.group(1), m.group(2) brace = src.find('{', m.end()) @@ -119,7 +117,7 @@ def source_keys(): # TR(someFunc(...)): a helper that RETURNS one of several literals, translated # by the caller. Every literal it can return is a key. for f in _sources(): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) for name in set(re.findall(r'TR\(\s*([A-Za-z_]\w*)\s*\(', src)): for d in re.finditer(r'\b' + re.escape(name) + r'\s*\([^;{}]*\)\s*\{', src): seg = src[d.end() - 1:_balanced(src, d.end() - 1)] @@ -130,7 +128,7 @@ def source_keys(): # LUA APPS: wada.sys.tr("...") (aliased to a local `tr` by convention). The # apps ship from the same catalog and their strings belong in the same files. for f in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/*/*/*.lua'))): - src = open(f, encoding='utf-8').read() + src = strip_comments(open(f, encoding='utf-8').read()) for lit in re.findall(r'\b(?:sys\.)?tr\(\s*"((?:[^"\\]|\\.)*)"', src): k = unescape_c(lit) if k.strip(): @@ -138,6 +136,66 @@ def source_keys(): return keys +def strip_comments(src): + """Blank out //... and /*...*/ while preserving string/char literals and line + count. The extractor scans raw text, so a quoted phrase inside a comment was + being collected as a translatable key -- that is how the Dutch string + "Geblokkeerde gebruikers", which only appears in a comment explaining how a + long translation degrades, ended up as a KEY in all thirteen files.""" + out, i, n = [], 0, len(src) + while i < n: + c = src[i] + if c == '"' or c == "'": + q = c; out.append(c); i += 1 + while i < n: + out.append(src[i]) + if src[i] == '\\' and i + 1 < n: + out.append(src[i + 1]); i += 2; continue + if src[i] == q: i += 1; break + i += 1 + continue + if c == '/' and i + 1 < n and src[i + 1] == '/': + while i < n and src[i] != '\n': i += 1 + continue + if c == '/' and i + 1 < n and src[i + 1] == '*': + i += 2 + while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'): + if src[i] == '\n': out.append('\n') + i += 1 + i += 2 + continue + out.append(c); i += 1 + return ''.join(out) + + +def literal_groups(arg): + """Keys from one call argument. + + A C compiler concatenates ADJACENT string literals, so `"a" "b"` is one + string. `cond ? "a" : "b"` is two, and joining them (which this used to do) + invented keys that exist nowhere: "Paste (move)Paste (copy)", + "Unblock Block", "Unfav Favorite". Those shipped to every translator in + v18. Only whitespace and an LV_SYMBOL_* macro may sit between literals of + one group; anything else starts a new one.""" + groups, cur, i, n = [], [], 0, len(arg) + while i < n: + c = arg[i] + if c == '"': + j, buf = i + 1, [] + while j < n: + if arg[j] == '\\' and j + 1 < n: buf.append(arg[j:j + 2]); j += 2; continue + if arg[j] == '"': break + buf.append(arg[j]); j += 1 + cur.append(''.join(buf)); i = j + 1; continue + if c.isspace(): i += 1; continue + m = re.match(r'LV_SYMBOL_[A-Z0-9_]+', arg[i:]) + if m: i += m.end(); continue + if cur: groups.append(cur); cur = [] + i += 1 + if cur: groups.append(cur) + return [''.join(g) for g in groups] + + def _sources(): return sorted(glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h'))) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 9d48e72..15b5f9d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -5362,7 +5362,7 @@ static void versionCheckUpdateUi() { snprintf(b, sizeof b, TR("Firmware beta_%d\nCouldn't reach the update server"), my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } else { - snprintf(b, sizeof b, "Firmware beta_%d\nChecking for updates over Wi-Fi…", my_n); + snprintf(b, sizeof b, TR("Firmware beta_%d\nChecking for updates over Wi-Fi…"), my_n); lv_obj_set_style_text_color(s_update_about_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); } lv_label_set_text(s_update_about_lbl, b); @@ -5579,7 +5579,7 @@ static void otaPrevPickCb(lv_event_t* e) { s_ota_pending_n = v; otaPrevModalClose(); char m[96]; - snprintf(m, sizeof m, "Install beta_%d?\nThis downgrades the firmware and reboots.", v); + snprintf(m, sizeof m, TR("Install beta_%d?\nThis downgrades the firmware and reboots."), v); showConfirm(m, TR("Install"), otaConfirmInstallApply); } @@ -14945,14 +14945,21 @@ static void wifiRebuildNetworkList() { // Manual rescan (the scan is also kicked on open). Label flips while one runs. s_wifi_list_y += SC(6); - wifiListRow(scanning_now ? LV_SYMBOL_REFRESH " Scanning\xE2\x80\xA6" - : LV_SYMBOL_REFRESH " Scan again", + // The glyph has to be joined at runtime: TR() returns a pointer, so it cannot + // take part in the compiler's adjacent-string-literal concatenation. + char wifi_rescan[48]; + snprintf(wifi_rescan, sizeof wifi_rescan, LV_SYMBOL_REFRESH " %s", + scanning_now ? TR("Scanning\xE2\x80\xA6") : TR("Scan again")); + wifiListRow(wifi_rescan, nullptr, lv_color_hex(scanning_now ? COLOR_SUB : COLOR_TEXT), scanning_now ? nullptr : wifiRefreshRowCb, nullptr); // Hidden / manual network. s_wifi_list_y += SC(3); - wifiListRow(LV_SYMBOL_PLUS " Other (hidden) network\xE2\x80\xA6", nullptr, lv_color_hex(COLOR_ACCENT), wifiHiddenRowCb, nullptr); + char wifi_hidden[64]; + snprintf(wifi_hidden, sizeof wifi_hidden, LV_SYMBOL_PLUS " %s", + TR("Other (hidden) network\xE2\x80\xA6")); + wifiListRow(wifi_hidden, nullptr, lv_color_hex(COLOR_ACCENT), wifiHiddenRowCb, nullptr); lv_obj_set_height(s_wifi_list_cont, s_wifi_list_y + SC(2)); // grow the card to fit navMarkDirty(); } @@ -22883,7 +22890,7 @@ static void discoverBuildFeed() { else if (buf[q - 1] == '\n') buf[q - 1] = '\0'; lv_label_set_text(s_discover_feed, buf); if (s_discover_status) - lv_label_set_text_fmt(s_discover_status, TR("%s \xC2\xB7 %d nearby (%d rpt, %d comp)"), s_discover_scanning ? "Scanning\xE2\x80\xA6" : "Paused", (int)m, rpt, comp); + lv_label_set_text_fmt(s_discover_status, TR("%s \xC2\xB7 %d nearby (%d rpt, %d comp)"), s_discover_scanning ? TR("Scanning\xE2\x80\xA6") : TR("Paused"), (int)m, rpt, comp); } // Sweep interval, backing off. A probe is not one packet: it is our zero-hop diff --git a/src/ui-touch/i18n_builtin.h b/src/ui-touch/i18n_builtin.h index 29c9de1..4406385 100644 --- a/src/ui-touch/i18n_builtin.h +++ b/src/ui-touch/i18n_builtin.h @@ -363,7 +363,7 @@ static const I18nPair kBuiltin_hu[] = { { "History limit off", "Előzménykorlát kikapcsolva" }, { "Home", "Kezdőlap" }, { "Hotkey saved", "Gyorsbillentyű mentése" }, - { "How tiles work:\nThe map is built from 256×256 \"slippy\" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.", "A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg." }, + { "How tiles work:\nThe map is built from 256×256 \"slippy\" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.", "A csempék működése:\nA térkép 256×256 képpontos \"slippy\" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg." }, { "How you'll appear to other nodes. You can change this later in Settings.", "Hogyan fogsz megjelenni más csomópontok számára. Ezt később módosíthatod a Beállításokban." }, { "Humidity", "Páratartalom" }, { "I accept the privacy risk", "Elfogadom az adatvédelmi kockázatot" }, @@ -386,6 +386,7 @@ static const I18nPair kBuiltin_hu[] = { { "Install", "Telepít" }, { "Install a previous version", "Telepítse az előző verziót" }, { "Install an earlier version", "Telepítse egy korábbi verziót" }, + { "Install beta_%d?\nThis downgrades the firmware and reboots.", "A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul." }, { "Install failed (network?)", "A telepítés nem sikerült (hálózat?)" }, { "Install update", "Frissítés telepítése" }, { "Installed", "Telepítve" }, @@ -723,7 +724,7 @@ static const I18nPair kBuiltin_hu[] = { { "SD card removed", "SD-kártya eltávolítva" }, { "SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.", "Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig." }, { "SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.", "Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig." }, - { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget." }, + { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a \"Belső adatok másolása SD-kártyára\" lehetőséget." }, { "SD download failed: %s", "SD-letöltés sikertelen: %s" }, { "SD formatted - %s (MESHCOMOD)", "SD formázás - %s (MESHCOMOD)" }, { "SD is busy - close tools and retry", "Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra." }, @@ -2606,7 +2607,7 @@ static const I18nPair kBuiltin_de[] = { { "SD card removed", "SD-Karte entfernt" }, { "SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.", "SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden." }, { "SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.", "SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden." }, - { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden." }, + { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; \"Interne Daten auf SD kopieren\" zum erneuten Versuch verwenden." }, { "SD download failed: %s", "SD-Download fehlgeschlagen: %s" }, { "SD formatted - %s (MESHCOMOD)", "SD formatiert - %s (MESHCOMOD)" }, { "SD is busy - close tools and retry", "SD ist belegt – Tools schließen und erneut versuchen" }, @@ -8070,7 +8071,7 @@ static const I18nPair kBuiltin_bg[] = { { "TCP off", "TCP изкл" }, { "TCP on", "TCP вкл" }, { "Tab hotkeys — tap a row, then press a key", "Клавиши за раздела — докосни ред, после натисни клавиш" }, - { "Tap Use to switch - the device reboots to apply.", "Натиснете „Избери“, за да превключите — устройството ще се рестартира." }, + { "Tap Use to switch - the device reboots to apply.", "Натиснете \"Избери\", за да превключите — устройството ще се рестартира." }, { "Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own.", "Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои." }, { "Tap a language; the device reboots to apply.", "Изберете език; устройството ще се рестартира." }, { "Taste the rainbow!", "Вкуси дъгата!" }, @@ -8965,7 +8966,7 @@ static const I18nPair kBuiltin_sr[] = { { "TCP off", "TCP искљ" }, { "TCP on", "TCP укљ" }, { "Tab hotkeys — tap a row, then press a key", "Пречице картице — додирни ред, па притисни тастер" }, - { "Tap Use to switch - the device reboots to apply.", "Додирните „Користи“ да промените — уређај ће се поново покренути." }, + { "Tap Use to switch - the device reboots to apply.", "Додирните \"Користи\" да промените — уређај ће се поново покренути." }, { "Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own.", "Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје." }, { "Tap a language; the device reboots to apply.", "Изаберите језик; уређај ће се рестартовати." }, { "Taste the rainbow!", "Окуси дугу!" }, From 2cd6f937896b1ebd6e88de12631d4c338ae7ecb0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 10:36:25 +0200 Subject: [PATCH 15/22] Merge PR #320: resolve the keyboard-modifier conflicts against #326 #326's compat stubs were placeholders for exactly the host-side modifier state this PR implements, so pr/320's side wins in all three files. --- src/helpers/input/PagerKeyboard.cpp | 14 -------------- src/helpers/input/PagerKeyboard.h | 12 ------------ src/helpers/input/TDeckKeyboard.cpp | 6 ------ 3 files changed, 32 deletions(-) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index d903ece..4e39a1d 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -121,21 +121,7 @@ bool pagerKeyboardAltHeld() { return s_state.altHeld(); } void pagerKeyboardMarkAltUsed() { s_state.markAltUsed(); } void pagerKeyboardDiscardAlt() { -<<<<<<< HEAD - 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; - return true; -======= s_state.discardAlt(); ->>>>>>> pr/320 } bool pagerKeyboardBackspaceHeld() { return s_state.backspaceHeld(); } diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index f52fdd7..7d3eca4 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -49,21 +49,9 @@ bool pagerKeyboardAltHeld(); * releasing Alt afterward does not arm the one-shot symbol layer. */ void pagerKeyboardMarkAltUsed(); -<<<<<<< HEAD -/** 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 - * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */ -bool pagerKeyboardConsumeAltTap(); -======= /** Cancel one-shot/locked Alt, suppress a later release from a held Alt, and * discard pending Alt chords. Used when input is intentionally discarded. */ void pagerKeyboardDiscardAlt(); ->>>>>>> pr/320 /** True while Backspace is physically held WITHOUT Alt (raw state, mirrors * pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b' diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 959e253..8e911e8 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -167,11 +167,6 @@ int tdeckKeyboardReadKey() { return key; } -<<<<<<< HEAD -void tdeckKeyboardDiscardModifiers() {} - -void tdeckKeyboardAllowModifiers() {} -======= void tdeckKeyboardDiscardModifiers() { portENTER_CRITICAL(&s_keyboard_mux); if (s_modifier_input_allowed) { @@ -191,6 +186,5 @@ void tdeckKeyboardAllowModifiers() { } portEXIT_CRITICAL(&s_keyboard_mux); } ->>>>>>> pr/320 #endif From 2475cba5e77387b7b7f267a20c96e0139e7c669c Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 10:54:24 +0200 Subject: [PATCH 16/22] store: do not offer an app the board cannot run beta_69 published Wardrive and Nearby. Both need the extended SDK, and the Heltec V4 does not have it, so the most common board in the mesh showed a prominent Get button for two apps that download, install, open, and then refuse to do anything. matthewjk reported exactly that on Discord and had to be told it was a hardware limit. An app can now declare `"requires"` in the catalog. The Store greys the button, labels it N/A and says so on the row, rather than letting someone spend a slow download to find out. Wardrive and Nearby declare sdk_ext. An unknown requirement allows the install: a newer catalog must not disable apps on firmware that has never heard of the capability name, which would be exactly the wrong way round. Co-Authored-By: Claude Opus 5 --- deploy/apps/apps.json | 6 ++++-- deploy/apps/lang/19/bg.lang | 9 +++++++++ deploy/apps/lang/19/de.lang | 9 +++++++++ deploy/apps/lang/19/el.lang | 9 +++++++++ deploy/apps/lang/19/es.lang | 9 +++++++++ deploy/apps/lang/19/fr.lang | 9 +++++++++ deploy/apps/lang/19/hu.lang | 9 +++++++++ deploy/apps/lang/19/it.lang | 9 +++++++++ deploy/apps/lang/19/nl.lang | 9 +++++++++ deploy/apps/lang/19/pt-br.lang | 9 +++++++++ deploy/apps/lang/19/ro.lang | 9 +++++++++ deploy/apps/lang/19/ru.lang | 9 +++++++++ deploy/apps/lang/19/sr.lang | 9 +++++++++ deploy/apps/lang/19/uk.lang | 9 +++++++++ deploy/apps/lang/bg.lang | 9 +++++++++ deploy/apps/lang/de.lang | 9 +++++++++ deploy/apps/lang/el.lang | 9 +++++++++ deploy/apps/lang/es.lang | 9 +++++++++ deploy/apps/lang/fr.lang | 9 +++++++++ deploy/apps/lang/hu.lang | 9 +++++++++ deploy/apps/lang/it.lang | 9 +++++++++ deploy/apps/lang/nl.lang | 9 +++++++++ deploy/apps/lang/pt-br.lang | 9 +++++++++ deploy/apps/lang/ro.lang | 9 +++++++++ deploy/apps/lang/ru.lang | 9 +++++++++ deploy/apps/lang/sr.lang | 9 +++++++++ deploy/apps/lang/uk.lang | 9 +++++++++ src/ui-touch/UITask.cpp | 30 +++++++++++++++++++++++++++--- 28 files changed, 265 insertions(+), 5 deletions(-) diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index acd3334..9e6b97d 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -34,13 +34,15 @@ "id": "wardrive", "name": "Wardrive", "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." + "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.", + "requires": "sdk_ext" }, { "id": "nearby", "name": "Nearby", "ver": "1.0", - "desc": "Your contacts on a real map, sorted by distance, with bearing and a live frame counter. Reference app for wada.map, wada.ui.list, wada.geo and app.on_packet." + "desc": "Your contacts on a real map, sorted by distance, with bearing and a live frame counter. Reference app for wada.map, wada.ui.list, wada.geo and app.on_packet.", + "requires": "sdk_ext" }, { "id": "gpscompass", diff --git a/deploy/apps/lang/19/bg.lang b/deploy/apps/lang/19/bg.lang index d824f0f..21b7c3f 100644 --- a/deploy/apps/lang/19/bg.lang +++ b/deploy/apps/lang/19/bg.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/de.lang b/deploy/apps/lang/19/de.lang index 4483ba4..1722419 100644 --- a/deploy/apps/lang/19/de.lang +++ b/deploy/apps/lang/19/de.lang @@ -1090,3 +1090,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/el.lang b/deploy/apps/lang/19/el.lang index 9eb2146..6e966fc 100644 --- a/deploy/apps/lang/19/el.lang +++ b/deploy/apps/lang/19/el.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/es.lang b/deploy/apps/lang/19/es.lang index 5b9d9dc..0b2cdbc 100644 --- a/deploy/apps/lang/19/es.lang +++ b/deploy/apps/lang/19/es.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/fr.lang b/deploy/apps/lang/19/fr.lang index 694b405..8ccf214 100644 --- a/deploy/apps/lang/19/fr.lang +++ b/deploy/apps/lang/19/fr.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/hu.lang b/deploy/apps/lang/19/hu.lang index a2fd72d..fa5188b 100644 --- a/deploy/apps/lang/19/hu.lang +++ b/deploy/apps/lang/19/hu.lang @@ -1112,3 +1112,12 @@ Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul. Other (hidden) network… Other (hidden) network… Paused Paused +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/it.lang b/deploy/apps/lang/19/it.lang index 203cdf5..49b3183 100644 --- a/deploy/apps/lang/19/it.lang +++ b/deploy/apps/lang/19/it.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/nl.lang b/deploy/apps/lang/19/nl.lang index a04eb65..cc0e72d 100644 --- a/deploy/apps/lang/19/nl.lang +++ b/deploy/apps/lang/19/nl.lang @@ -1091,3 +1091,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/pt-br.lang b/deploy/apps/lang/19/pt-br.lang index 98ad61b..2bcda5e 100644 --- a/deploy/apps/lang/19/pt-br.lang +++ b/deploy/apps/lang/19/pt-br.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/ro.lang b/deploy/apps/lang/19/ro.lang index 05cb60e..4679e46 100644 --- a/deploy/apps/lang/19/ro.lang +++ b/deploy/apps/lang/19/ro.lang @@ -1095,3 +1095,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/ru.lang b/deploy/apps/lang/19/ru.lang index 04fa21c..5c8d5cc 100644 --- a/deploy/apps/lang/19/ru.lang +++ b/deploy/apps/lang/19/ru.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/sr.lang b/deploy/apps/lang/19/sr.lang index 836e0b5..bd479b9 100644 --- a/deploy/apps/lang/19/sr.lang +++ b/deploy/apps/lang/19/sr.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/19/uk.lang b/deploy/apps/lang/19/uk.lang index cde3704..52d7572 100644 --- a/deploy/apps/lang/19/uk.lang +++ b/deploy/apps/lang/19/uk.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/bg.lang b/deploy/apps/lang/bg.lang index d824f0f..21b7c3f 100644 --- a/deploy/apps/lang/bg.lang +++ b/deploy/apps/lang/bg.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/de.lang b/deploy/apps/lang/de.lang index 4483ba4..1722419 100644 --- a/deploy/apps/lang/de.lang +++ b/deploy/apps/lang/de.lang @@ -1090,3 +1090,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/el.lang b/deploy/apps/lang/el.lang index 9eb2146..6e966fc 100644 --- a/deploy/apps/lang/el.lang +++ b/deploy/apps/lang/el.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/es.lang b/deploy/apps/lang/es.lang index 5b9d9dc..0b2cdbc 100644 --- a/deploy/apps/lang/es.lang +++ b/deploy/apps/lang/es.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/fr.lang b/deploy/apps/lang/fr.lang index 694b405..8ccf214 100644 --- a/deploy/apps/lang/fr.lang +++ b/deploy/apps/lang/fr.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/hu.lang b/deploy/apps/lang/hu.lang index a2fd72d..fa5188b 100644 --- a/deploy/apps/lang/hu.lang +++ b/deploy/apps/lang/hu.lang @@ -1112,3 +1112,12 @@ Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul. Other (hidden) network… Other (hidden) network… Paused Paused +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/it.lang b/deploy/apps/lang/it.lang index 203cdf5..49b3183 100644 --- a/deploy/apps/lang/it.lang +++ b/deploy/apps/lang/it.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/nl.lang b/deploy/apps/lang/nl.lang index a04eb65..cc0e72d 100644 --- a/deploy/apps/lang/nl.lang +++ b/deploy/apps/lang/nl.lang @@ -1091,3 +1091,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/pt-br.lang b/deploy/apps/lang/pt-br.lang index 98ad61b..2bcda5e 100644 --- a/deploy/apps/lang/pt-br.lang +++ b/deploy/apps/lang/pt-br.lang @@ -1093,3 +1093,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/ro.lang b/deploy/apps/lang/ro.lang index 05cb60e..4679e46 100644 --- a/deploy/apps/lang/ro.lang +++ b/deploy/apps/lang/ro.lang @@ -1095,3 +1095,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/ru.lang b/deploy/apps/lang/ru.lang index 04fa21c..5c8d5cc 100644 --- a/deploy/apps/lang/ru.lang +++ b/deploy/apps/lang/ru.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/sr.lang b/deploy/apps/lang/sr.lang index 836e0b5..bd479b9 100644 --- a/deploy/apps/lang/sr.lang +++ b/deploy/apps/lang/sr.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/deploy/apps/lang/uk.lang b/deploy/apps/lang/uk.lang index cde3704..52d7572 100644 --- a/deploy/apps/lang/uk.lang +++ b/deploy/apps/lang/uk.lang @@ -1094,3 +1094,12 @@ Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nTh Other (hidden) network… Other (hidden) network… Paused Paused Scan again Scan again +At a glance At a glance +At a glance disabled At a glance disabled +At a glance enabled At a glance enabled +At a glance while locked At a glance while locked +Glance only shows when unlocked Glance only shows when unlocked +Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked +Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction +N/A N/A +This board cannot run this app. This board cannot run this app. diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index f2539e0..3cef362 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26396,7 +26396,12 @@ static int verchkFetchLatest(WiFiClient& client, HTTPClient& http) { fs::FS* luaHostAppFs(); // bridges (defined later in this TU) void luaHostAppPath(char* out, size_t cap, const char* rel); -struct LuaCatApp { char id[20]; char name[28]; char ver[12]; char desc[72]; }; +// `requires` is an optional capability name the board must have for the app to +// work at all ("sdk_ext" today). beta_69 published Wardrive and Nearby, both of +// which need the extended SDK, and the Heltec V4 does not have it -- so the most +// common board in the mesh offered a prominent Get button for two apps that then +// refused to do anything, with no warning anywhere (reported on Discord). +struct LuaCatApp { char id[20]; char name[28]; char ver[12]; char desc[72]; char requires_cap[16]; }; // `icon` is the manifest's optional icon NAME (not a glyph): the device's JSON // scanner only reads quoted strings, and a name is reviewable in a store // submission where a raw private-use codepoint would not be. Mapped to a glyph @@ -40641,6 +40646,7 @@ static void luaStoreParseCatalog() { luaJsonField(obj, "name", c.name, sizeof c.name) && luaJsonField(obj, "ver", c.ver, sizeof c.ver)) { if (!luaJsonField(obj, "desc", c.desc, sizeof c.desc)) c.desc[0] = 0; + if (!luaJsonField(obj, "requires", c.requires_cap, sizeof c.requires_cap)) c.requires_cap[0] = 0; s_lua_cat_n++; } p = cb + 1; @@ -40655,6 +40661,16 @@ static void luaStoreParseCatalog() { // The caption is HIDDEN rather than deleted: this runs inside the button's own // click event, and deleting objects mid-dispatch is exactly how the popup // use-after-frees happened. Adding the spinner as a new child is safe. +// Empty requirement = runs anywhere. Unknown requirement = allow: a newer +// catalog must not disable apps on older firmware that has never heard of the +// name, which would be the wrong way round. +static bool luaStoreBoardMeets(const char* req) { + if (!req || !req[0]) return true; + if (!strcmp(req, "sdk_ext")) return CAP_LUA_SDK_EXT != 0; + if (!strcmp(req, "sd")) return CAP_SD != 0; + return true; +} + static void luaStoreMarkBtnBusy(lv_obj_t* b) { if (!b) return; const uint32_t n = lv_obj_get_child_cnt(b); @@ -41261,10 +41277,18 @@ static void luaStoreRebuildList() { const bool cur = inst && strcmp(inst->ver, s_lua_cat[i].ver) == 0; if (!inst) lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); else if (!cur) lv_obj_set_style_bg_color(b, lv_color_hex(0x4F9DF7), LV_PART_MAIN); - lv_label_set_text(bl, !inst ? TR("Get") : cur ? TR("Remove") : TR("Update")); + const bool runnable = luaStoreBoardMeets(s_lua_cat[i].requires_cap); + lv_label_set_text(bl, !runnable ? TR("N/A") : !inst ? TR("Get") : cur ? TR("Remove") : TR("Update")); lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN); lv_obj_center(bl); - if (inst && cur) { + if (!runnable && !inst) { + // Greyed and inert, with the reason on the row rather than a toast after + // a pointless download. + lv_obj_set_style_bg_color(b, lv_color_hex(0x39404C), LV_PART_MAIN); + lv_obj_clear_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_color(ds, lv_color_hex(0xD7574E), LV_PART_MAIN); + lv_label_set_text(ds, TR("This board cannot run this app.")); + } else if (inst && cur) { // Remove needs the INSTALLED index int ii = (int)(inst - s_lua_inst); lv_obj_set_style_bg_color(b, lv_color_hex(0x8A4444), LV_PART_MAIN); From 867030ab5804686ca77f1af5273e369c210d1279 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 10:55:20 +0200 Subject: [PATCH 17/22] touch: beta_70 release notes Co-Authored-By: Claude Opus 5 --- release-notes/beta_70.txt | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 release-notes/beta_70.txt diff --git a/release-notes/beta_70.txt b/release-notes/beta_70.txt new file mode 100644 index 0000000..c713bbc --- /dev/null +++ b/release-notes/beta_70.txt @@ -0,0 +1,39 @@ +# beta_70 - test build. Stable stays beta_65. +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- The crash on the regions screen ------------------------------------------------------ +Adding or removing known regions and then leaving that screen crashed the device. If you have been unable to get out of Radio & mesh without resetting, this was why. +The cause was general, not specific to regions: when a text field is destroyed, the interface library sends it one last "you lost focus" event, and seven fields in the firmware save their value on losing focus. So a field was reading and rewriting itself from inside its own destructor. All seven now ignore a blur that is really a deletion. +Found by decoding the core dump attached to the report, against a rebuilt beta_69 image. Thank you for sending it. + +# --- Translations: undoing damage we caused ------------------------------------------------ +Language file v18 contained keys that do not exist anywhere in the firmware. Entries like "Paste (move)Paste (copy)" and "Unblock Block" were two separate buttons glued into one, and one entry was a Dutch phrase that appears only inside a source-code comment. +That was a bug in the tool that decides which text belongs in a language file, introduced in the last release. It has been fixed twice over: it no longer joins the two halves of a choice into one entry, and it no longer reads text out of comments. +202 invented entries are gone from all thirteen files. Nothing a translator had actually translated was touched. +The curly quotes in the Hungarian map credits showed as empty boxes because no bundled font contains them. Replaced with straight quotes. +Five more strings are translatable: the Wi-Fi rescan and hidden-network rows, the update check, and the downgrade prompt. +Language files are at v19. v18 is withdrawn. + +# --- The Store stops offering apps your board cannot run ----------------------------------- +Wardrive and Nearby need the extended SDK, which the Heltec V4 does not have. The Store offered both anyway, so the most common board in the mesh could download, install and open two apps that then refused to work, with no warning. Apps can now say what they need, and the Store says so on the row instead. + +# --- Community work, by Michael A. Cojocari (@oumike) --------------------------------------- +Lua apps can play WAV and MP3 audio, from the SD card or from memory. +@-mention autocomplete in the device chat and the web interface, suggesting people whose adverts this device actually heard rather than everyone in your contacts. +Symbol and alt keys latch: tap once for the next key, tap twice to lock the layer until you tap again. No more holding a modifier down. +The web reader opens a home page from the SD card if you put one there, so a device with no internet still has somewhere to start. +Wardrive no longer stops when a node name contains an emoji. Names are cut on character boundaries now, and text coming from an app is checked before it reaches the screen. +Screenshots from the Control Center on the ThinkNode M9 and both T-Lora Pagers, with a timer for immediate, 3-second or 10-second capture. +The M9's Home key works while you are typing in a text field, and the built-in Snake starts from the first direction you press. +Tapping a chat composer that is already focused reopens the on-screen keyboard instead of doing nothing. + +# --- Community work, by Tesso M Costa (@codemonkeybr) -------------------------------------- +A new "At a glance" notification style: when a message arrives while the screen is dimmed, it shows a short preview without fully waking the device or lighting the keyboard. Optional, and it can be limited to when the device is unlocked. + +# --- Also fixed ----------------------------------------------------------------------------- +Granting an app permission could silently take a permission away from a different app. The switches packed the app's position and the permission into too few bits, so "Send discovery probes" wrote the wrong app's record, marking an app you never touched as refused. That is why apps reported "permission denied" with every permission granted. + +# --- For testers ------------------------------------------------------------------------------ +The regions crash is the fix most worth confirming: add and remove a few known regions, leave the screen, and check the device stays up. +If you translate, your language file is clean again but has more untranslated entries than before, because strings that were previously invisible to the checker are now listed. +Console mode is still an early preview and has not changed this release. From 2df6f087ebb2bcbcecf13592c82abe2a248e5f03 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 12:08:42 +0200 Subject: [PATCH 18/22] keyboard: do not decide the T-Deck's capability on one I2C read (#332) jrote1 flashed the June 2025 C3 keyboard firmware, which supports raw column frames, and still got no modifier latching. The probe asked for a raw frame exactly once, on the first poll after boot, and anything other than five bytes with every high bit clear committed the device to legacy mode for the rest of the session. The first I2C read after boot is the least trustworthy one there is: the C3 is still starting, so a short or garbled frame is ordinary rather than exceptional. There was no recovery except a reboot, and a reboot re-rolls the same dice. It now retries for 1.5 seconds before settling for legacy. A single-byte answer is still delivered as a keystroke while probing, so nothing typed in that window is lost, and a genuinely old controller ends up in the same place a little later. The fallback message also says what it actually saw -- how many bytes came back, and whether a column had its high bit set. "Update your keyboard firmware" to someone who just did is not a diagnosis, and this is the same lesson as #274: a failure path that says nothing produces a bug report with nothing in it. Verified on a T-Deck with the newer firmware: still raw mode, latching works. Co-Authored-By: Claude Opus 5 --- src/helpers/input/TDeckKeyboard.cpp | 38 +++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 8e911e8..cdc8b47 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -19,6 +19,11 @@ static uint8_t s_head = 0; static uint8_t s_tail = 0; static bool s_inited = false; enum class KeyboardMode : uint8_t { Probe, Raw, Legacy }; +// How long to keep asking the C3 for raw frames before settling for legacy mode. +// Generous on purpose: getting this wrong costs the user modifier latching for +// the entire session, and the only cost of waiting is that the first second of +// keystrokes goes through the legacy path, which still works. +static const uint32_t kProbeWindowMs = 1500; static KeyboardMode s_mode = KeyboardMode::Probe; static TDeckKeyboardState s_raw_state; static bool s_modifier_input_allowed = true; // guarded by s_keyboard_mux @@ -125,23 +130,46 @@ void tdeckKeyboardPoll() { if (s_mode == KeyboardMode::Probe) { // LilyGO keyboard firmware from June 2025 onward accepts 0x03 and returns // five column-state bytes. Older controllers ignore it and keep returning - // one resolved ASCII byte; restore 0x04 key mode immediately in that case. + // one resolved ASCII byte; restore 0x04 key mode when that is what we have. + // + // KEEP RETRYING before concluding "old firmware". This used to decide on a + // SINGLE read, and the first I2C read after boot is the least trustworthy + // one there is: the C3 is still coming up, so a short or garbled frame is + // ordinary. One such frame committed the device to legacy mode for the whole + // session, with no recovery short of a reboot -- reported as latching simply + // not working on a T-Deck carrying the June 2025 firmware (#332). + static uint32_t s_probe_start_ms = 0; + const uint32_t now = millis(); + if (!s_probe_start_ms) s_probe_start_ms = now; + keyboardCommand(0x03); uint8_t frame[TDeckKeyboardState::COLS] = {}; const int count = keyboardRead(frame, sizeof frame); bool valid_raw = count == (int)sizeof frame; - for (size_t col = 0; col < sizeof frame && valid_raw; ++col) - valid_raw = (frame[col] & 0x80) == 0; + size_t bad_col = 0; + for (size_t col = 0; col < sizeof frame && valid_raw; ++col) { + if (frame[col] & 0x80) { valid_raw = false; bad_col = col; } + } if (valid_raw) { s_mode = KeyboardMode::Raw; Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled"); processRawFrame(frame, millis()); return; } + // A legacy controller answers with one resolved byte. Deliver it either way + // so nothing typed during the probe window is dropped. + if (count == 1) { keyboardCommand(0x04); processLegacyKey(frame[0]); } + if ((uint32_t)(now - s_probe_start_ms) < kProbeWindowMs) return; // try again next poll + keyboardCommand(0x04); s_mode = KeyboardMode::Legacy; - Serial.println("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier latching"); - if (count == 1) processLegacyKey(frame[0]); + // Say WHY, so the next report of "latching does not work" is one line to + // diagnose instead of a guess about which firmware someone has. + Serial.printf("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier " + "latching (probe read %d bytes, wanted %d%s)\n", + count, (int)sizeof frame, + (count == (int)sizeof frame) ? ", high bit set in a column" : ""); + (void)bad_col; return; } if (s_mode == KeyboardMode::Raw) { From 80b9fd16932d9f44f4beecda1a1d4a3cdbecf659 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 12:23:16 +0200 Subject: [PATCH 19/22] lua: let an app reach all its contacts, and write a bigger app Both reported by Jade, and both were arbitrary caps rather than real limits. wada.mesh.contacts() scanned the first 200 contacts, returned at most 100, and said nothing about having truncated. On a device holding 2000 contacts most of them were simply unreachable. It takes an offset and a limit now, defaulting to the old 100 and capped at 250 per call, because every entry is an eight-field Lua table and building thousands in one go would exhaust the per-app heap. The window moves, so everything is reachable by asking more than once, and the new wada.mesh.contact_count() means an app can page without probing for the end. The app source limit was 64 KB, which an ordinary program can reach. It is 192 KB now. The source sits in PSRAM only until luaL_loadbuffer has compiled it, so this is a transient allocation, not a per-app cost, and the compiled chunk still has to fit the 256 KB app heap. The Store's download buffer was the other half of that ceiling and moves with it. Co-Authored-By: Claude Opus 5 --- deploy/site/sdk.html | 5 +++-- src/ui-touch/LuaAppHost.cpp | 35 +++++++++++++++++++++++++++++++++-- src/ui-touch/UITask.cpp | 2 +- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html index c0a72dc..b8ed2c0 100644 --- a/deploy/site/sdk.html +++ b/deploy/site/sdk.html @@ -292,7 +292,8 @@ return app

wada.mesh

- + + @@ -410,7 +411,7 @@ end
wada.mesh.contacts()Array of {name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}. pubkey is the first 4 bytes as 8 hex characters — the same short form the rest of the interface uses, and what lines a contact up with a discovery hit.
wada.mesh.contacts([offset], [limit])Array of {name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}. pubkey is the first 4 bytes as 8 hex characters — the same short form the rest of the interface uses, and what lines a contact up with a discovery hit. Returns at most limit entries (default 100, maximum 250) starting at offset, because every entry is an eight-field table and building thousands in one call would exhaust the app heap. On a device with more contacts than that, page through them: ask for 100, then 100 from offset 100, and so on.
wada.mesh.contact_count()How many contacts the device holds, so you can page through contacts() without calling it repeatedly to find where the list ends.
wada.mesh.rx_log()Recent packets, newest first: {ago_ms, type, rssi, snr, hops, route, len}, plus whatever identity the frame actually carried — see below. The RF Monitor feed.
wada.mesh.stats(){rssi, noise, rx_air_s, tx_air_s, rx_pkts, rx_err, tx_budget_ms, rx_events, rx_dropped, tx_pkts, freq, bw, sf, duty_pct}.
wada.mesh.self()This node: {name, pubkey, lat, lon, lat_e6, lon_e6}.

http:// only, both of them. On-device TLS is not workable at the heap these boards have left once Wi-Fi has associated, so there is no https to offer; put a proxy in front if you need it. One request in flight at a time per app.

The fetch is asynchronous and runs on the firmware's existing network worker, so it does not block the UI. Your callback runs on the UI thread once the body is in memory.

-

Plain HTTP only, and that is not an oversight. After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30 KB and roughly 5 KB is free. If you need an HTTPS source, put a small proxy in front of it, the way the map tiles do. Responses are capped (64 KB by default).

+

Plain HTTP only, and that is not an oversight. After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30 KB and roughly 5 KB is free. If you need an HTTPS source, put a small proxy in front of it, the way the map tiles do. Responses are capped (192 KB by default).

diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index 29acd7e..c7b1bac 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -170,7 +170,12 @@ void budgetHook(lua_State* L, lua_Debug*) { // host state // --------------------------------------------------------------------------- constexpr size_t kHeapCap = 256 * 1024; // per-app PSRAM cap -constexpr size_t kMaxSrc = 64 * 1024; // app source size limit +// App source size limit. Was 64 KB, which a real app can reach: reported from a +// 2000-contact device whose author hit it writing an ordinary Lua program, not +// anything pathological. The source lives in PSRAM only until luaL_loadbuffer +// has compiled it, so this is a transient allocation rather than a per-app cost, +// and the compiled chunk still has to fit the 256 KB per-app heap below. +constexpr size_t kMaxSrc = 192 * 1024; // app source size limit constexpr size_t kStoreMax = 2048; // per-app persisted KV budget (bytes, serialized) constexpr int kMinTickMs = 33; // fastest on_tick cadence (~30 fps) @@ -1835,10 +1840,35 @@ void pressCb(lv_event_t* e) { } // ---- wada.mesh (read-only) ---- +// wada.mesh.contacts([offset], [limit]) +// +// Used to scan the first 200 contacts and return at most 100, with no way to see +// past that and nothing to say it had truncated. On a device with 2000 contacts +// an app simply could not reach most of them (reported by Jade). +// +// It takes an offset and a limit now. The default limit stays 100 because every +// entry is an 8-field Lua table and building thousands of them in one call would +// blow the per-app heap -- but the window can be moved, so all contacts are +// reachable by asking twice. wada.mesh.contact_count() says how many there are. +// How many contacts the device holds, so an app can page through them without +// calling contacts() repeatedly just to discover where the list ends. +static int meshContactCount(lua_State* L) { + char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6; + int n = 0; + while (n < 4096 && luaHostContactAt(n, name, sizeof name, &type, &ago, &lat, &lon, + pk, sizeof pk, &lat6, &lon6)) ++n; + lua_pushinteger(L, n); + return 1; +} + int meshContacts(lua_State* L) { + const int offset = (int)luaL_optinteger(L, 1, 0); + int limit = (int)luaL_optinteger(L, 2, 100); + if (limit < 1) limit = 1; + if (limit > 250) limit = 250; // one call still has to fit the app's heap lua_newtable(L); char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6; - for (int i = 0, out = 0; i < 200 && out < 100; i++) { + for (int i = (offset > 0 ? offset : 0), out = 0; out < limit; i++) { if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon, pk, sizeof pk, &lat6, &lon6)) break; lua_createtable(L, 0, 8); @@ -2328,6 +2358,7 @@ void openWada(lua_State* L) { lua_newtable(L); // wada.mesh (read-only) lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts"); + lua_pushcfunction(L, meshContactCount); lua_setfield(L, -2, "contact_count"); lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log"); lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats"); lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self"); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 3cef362..2f2fedc 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26523,7 +26523,7 @@ static bool luaStoreDownloadWorker(WiFiClient& client, HTTPClient& http, luaHostAppPath(appsdir, sizeof appsdir, "/apps"); fs->mkdir(appsdir); static const char* const kExt[2] = { "lua", "json" }; - const size_t kCap[2] = { 64 * 1024, 1024 }; + const size_t kCap[2] = { 192 * 1024, 1024 }; // must match kMaxSrc in LuaAppHost.cpp for (int e = 0; e < 2; e++) { char* buf = (char*)heap_caps_malloc(kCap[e], MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) return false; From f49a950e30cbdd546d14d2f74297e4a3cae5914c Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 13:02:15 +0200 Subject: [PATCH 20/22] lua: wada.ui.clear(), so an app can have more than one screen The SDK documented building widgets in on_open() and gave no way to take them down, so an app with a second screen drew it on top of the first. pisti87 hit this building a multi-screen app and had been working around it with buttons; he also noted that ui.list() has clear() while the page it sits on does not. The reason this is not just lv_obj_clean() is the handles. WidgetUd holds a raw lv_obj_t*, guarded only by `if (u->obj)`, so a Lua variable still referring to a label from the screen just cleared would sail past that check into freed memory. Every handle now records the generation it was created in, clear() bumps the generation, and the accessors null the pointer of any handle from an older one. The null guard that every method already has then turns a stale call into a no-op. Given the day this codebase has had with use-after-frees, a clear() that invited one would not have been worth shipping. Co-Authored-By: Claude Opus 5 --- deploy/site/sdk.html | 1 + src/ui-touch/LuaAppHost.cpp | 60 ++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html index b8ed2c0..0defa3b 100644 --- a/deploy/site/sdk.html +++ b/deploy/site/sdk.html @@ -238,6 +238,7 @@ return app wada.ui.button(text, x, y, w, h, fn)A tappable button at x, y sized w × h; fn is called with no arguments. wada.ui.text_w(text [, size])Rendered width of text in pixels, for the given size class. wada.ui.text_lines(text, width [, size])How many lines text wraps to inside width. Counts explicit newlines too. Use this when you lay out your own rows. label:width() turns wrapping on, so a line longer than the screen becomes two while a fixed y step still advances by one, and the next row is drawn on top of it. This is the single most common way an app's layout goes wrong; it happened to the SDK Test app's channel list. + wada.ui.clear()Remove every widget from the app's page, leaving the page itself. This is how you build an app with more than one screen: clear, then create the next screen's widgets in the same place you built the first. Any handle you still hold from before the clear is dead — calling a method on it does nothing rather than crashing, but it will not come back, so re-create and re-assign. Older firmware has no ui.clear: write local clear = ui.clear or function() end if you need to run on both. wada.ui.list(x, y, w, h)A scrollable list of selectable rows — the "pick one of N" widget. Methods: add(text [, fn]) returning the row index, set(i, text), color(i, c), select(i) (highlights and scrolls it into view), selected(), count(), clear(), pos(x, y). Rows are real buttons, so keyboard and trackball navigation walks them on touchless boards without you doing anything. wada.ui.input(title, initial, cb)A modal text field. cb(text) on OK, cb(nil) on cancel or on the dialog being closed any other way — exactly one call, always, so an app that disabled itself while waiting always gets to re-enable. Uses the firmware's own dialog and keyboard, so it behaves identically on a touchscreen, on the Tanmatsu's physical keyboard and on a trackball board. One prompt at a time. wada.ui.canvas(w, h)A drawing surface. See the canvas methods below. diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index c7b1bac..efad828 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -179,6 +179,16 @@ constexpr size_t kMaxSrc = 192 * 1024; // app source size limit constexpr size_t kStoreMax = 2048; // per-app persisted KV budget (bytes, serialized) constexpr int kMinTickMs = 33; // fastest on_tick cadence (~30 fps) +// Bumped by wada.ui.clear(). Every widget handle records the generation it was +// created in; a handle from an older one has already been destroyed, so the +// accessors below null its pointer and the `if (u->obj)` guard that every +// method already has does the rest. +// +// Without this, clear() would be a use-after-free generator: WidgetUd holds a +// raw lv_obj_t*, so a Lua variable still referring to a label from the previous +// screen would sail past the null check straight into freed memory. +static uint32_t s_ui_gen = 1; + struct Host { lua_State* L = nullptr; LuaHeap heap; @@ -280,7 +290,7 @@ const char* safeUiText(const char* text, size_t length, size_t* safe_length = nu } // ---- canvas userdata ---- -struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; }; +struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; uint32_t gen; }; CanvasUd* checkCanvas(lua_State* L) { return (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas"); @@ -373,6 +383,7 @@ int uiCanvas(lua_State* L) { lv_canvas_set_buffer(cv, buf, w, h, LV_IMG_CF_TRUE_COLOR); lv_canvas_fill_bg(cv, lv_color_hex(0x000000), LV_OPA_COVER); CanvasUd* ud = (CanvasUd*)lua_newuserdatauv(L, sizeof(CanvasUd), 0); + ud->gen = s_ui_gen; ud->obj = cv; ud->buf = buf; ud->w = w; ud->h = h; luaL_setmetatable(L, "wada.canvas"); // Pin the handle in the registry for the app's lifetime. LVGL draws straight @@ -388,8 +399,12 @@ int uiCanvas(lua_State* L) { } // ---- label userdata ---- -struct WidgetUd { lv_obj_t* obj; }; -WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); } +struct WidgetUd { lv_obj_t* obj; uint32_t gen; }; +WidgetUd* checkLabel(lua_State* L) { + WidgetUd* u = (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); + if (u->gen != s_ui_gen) u->obj = nullptr; // cleared out from under this handle + return u; +} int lbSet(lua_State* L) { WidgetUd* u = checkLabel(L); @@ -440,14 +455,19 @@ int uiLabel(lua_State* L) { 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->gen = s_ui_gen; ud->obj = l; luaL_setmetatable(L, "wada.label"); return 1; } // ---- chart userdata (the primitive the native Monitor/Airtime pages use) ---- -struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; }; -ChartUd* checkChart(lua_State* L) { return (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); } +struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; uint32_t gen; }; +ChartUd* checkChart(lua_State* L) { + ChartUd* u = (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); + if (u->gen != s_ui_gen) { u->obj = nullptr; u->n_ser = 0; } + return u; +} int chPush(lua_State* L) { // chart:push(series_idx, value) ChartUd* c = checkChart(L); @@ -514,6 +534,7 @@ int uiChart(lua_State* L) { // wada.ui.chart(w, h, points, color1 [, color2] [ lv_obj_set_style_line_color(ch, lv_color_hex(0x1A1D1F), LV_PART_MAIN); // grid lv_obj_set_style_line_width(ch, 2, LV_PART_ITEMS); ChartUd* ud = (ChartUd*)lua_newuserdatauv(L, sizeof(ChartUd), 0); + ud->gen = s_ui_gen; ud->obj = ch; ud->n_ser = 0; ud->ser[0] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 4, 0x15B6A6)), LV_CHART_AXIS_PRIMARY_Y); @@ -562,6 +583,7 @@ int uiButton(lua_State* L) { lv_obj_add_event_cb(b, btnEventCb, LV_EVENT_CLICKED, nullptr); } WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0); + ud->gen = s_ui_gen; ud->obj = b; luaL_setmetatable(L, "wada.label"); // shares set/pos/color methods return 1; @@ -578,8 +600,12 @@ int uiButton(lua_State* L) { // keyboard/trackball focus navigation walks them on touchless boards for free, // and a tap works on the ones with a screen. Per-row callbacks ride the same // button-callback table uiButton uses. -struct ListUd { lv_obj_t* obj; int sel; }; -ListUd* checkList(lua_State* L) { return (ListUd*)luaL_checkudata(L, 1, "wada.list"); } +struct ListUd { lv_obj_t* obj; int sel; uint32_t gen; }; +ListUd* checkList(lua_State* L) { + ListUd* u = (ListUd*)luaL_checkudata(L, 1, "wada.list"); + if (u->gen != s_ui_gen) u->obj = nullptr; + return u; +} lv_obj_t* listRowAt(ListUd* u, int i) { // i is 1-based, as everywhere in Lua if (!u->obj || i < 1) return nullptr; @@ -631,6 +657,24 @@ int uiTextLines(lua_State* L) { return 1; } +// wada.ui.clear() -- remove every widget from the app's page. +// +// The SDK documented building widgets in on_open() and gave no way to take them +// down again, so an app with more than one screen drew the second on top of the +// first. Reported by pisti87, who had been working around it with buttons. +// +// Bumping the generation is what makes this safe. A Lua variable still holding a +// label from the screen just cleared keeps a raw pointer to freed memory; the +// accessors compare generations and null it, so the existing `if (u->obj)` guard +// in every method turns a stale call into a no-op instead of a crash. +static int uiClear(lua_State* L) { + (void)L; + if (!s_h || !s_h->body) return 0; + lv_obj_clean(s_h->body); // deletes the children, keeps the page itself + ++s_ui_gen; + return 0; +} + int uiList(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); const int x = (int)luaL_checkinteger(L, 1), y = (int)luaL_checkinteger(L, 2); @@ -648,6 +692,7 @@ int uiList(lua_State* L) { lv_obj_set_scroll_dir(c, LV_DIR_VER); lv_obj_add_flag(c, LV_OBJ_FLAG_SCROLLABLE); ListUd* ud = (ListUd*)lua_newuserdatauv(L, sizeof(ListUd), 0); + ud->gen = s_ui_gen; ud->obj = c; ud->sel = 0; luaL_setmetatable(L, "wada.list"); return 1; @@ -2279,6 +2324,7 @@ void openWada(lua_State* L) { lua_setfield(L, -2, "colors"); lua_pushcfunction(L, uiInput); lua_setfield(L, -2, "input"); // modal text entry lua_pushcfunction(L, uiList); lua_setfield(L, -2, "list"); // scrollable selectable rows + lua_pushcfunction(L, uiClear); lua_setfield(L, -2, "clear"); // wipe the page (#318 / Discord) lua_setfield(L, -2, "ui"); lua_newtable(L); // wada.sys From 11d826bdfe6957aaa90e162516294b38e49ae9aa Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 13:52:38 +0200 Subject: [PATCH 21/22] touch: beta_71 release notes Co-Authored-By: Claude Opus 5 --- release-notes/beta_71.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 release-notes/beta_71.txt diff --git a/release-notes/beta_71.txt b/release-notes/beta_71.txt new file mode 100644 index 0000000..1ddb87d --- /dev/null +++ b/release-notes/beta_71.txt @@ -0,0 +1,14 @@ +# beta_71 - test build. Stable stays beta_65. +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- For people writing Lua apps ------------------------------------------------------------ +Apps can clear their page. There was no way to remove the widgets an app had already created, so anything with a second screen drew it on top of the first. wada.ui.clear() takes the page back to empty, and any widget you were still holding from before the clear becomes inert rather than dangerous. +Apps can reach all your contacts. The list stopped at 100, and it only ever looked at the first 200 in the first place, so on a device with a lot of contacts most of them were unreachable and nothing said so. wada.mesh.contacts() now takes an offset and a limit, and wada.mesh.contact_count() tells an app how many there are to page through. +Apps can be three times larger. The limit was 64 KB, which an ordinary program can reach. It is 192 KB now. + +# --- T-Deck keyboard --------------------------------------------------------------------------- +Modifier latching could fail to turn on even with the right keyboard firmware installed. The firmware asked the keyboard what it was capable of exactly once, on the first read after boot, and the first read after boot is the one most likely to come back garbled because the keyboard controller is still starting. One bad answer meant no latching for the rest of the session, and rebooting simply asked again at the same unlucky moment. It now keeps asking for a second and a half before giving up, and says what it actually saw when it does. + +# --- For testers --------------------------------------------------------------------------------- +This is a small drop on top of beta_70, which carried the fix for the crash when leaving the known-regions screen. If you have not tried that yet, it is still the thing most worth confirming: add and remove a few regions, leave the screen, check the device stays up. +If your screen is not going to sleep, we are still investigating (issue 333) and it is not fixed here. From 799756cd940b8ee3d430902e390f25bad77d3b5d Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 27 Aug 2026 17:41:31 +0200 Subject: [PATCH 22/22] i18n: the signal card's own strings were never wrapped pisti87 pointed at UITask.cpp:22021 twice, and he was right both times. sigCell() does not translate its argument, so "Signal", "Signal (stale)" and "nothing heard yet" were raw at the call site and the card read English in every language. Easy to miss because the home-graph signal popup has its own TR()'d copies of the same three strings, so the text is translated in one place and not the other. It also explains why "nothing heard yet" matched no key when his translation for it did not appear: there was no key, only a literal. Hungarian supplied by him. Co-Authored-By: Claude Opus 5 --- deploy/apps/lang/bg.lang | 3 +++ deploy/apps/lang/de.lang | 3 +++ deploy/apps/lang/el.lang | 3 +++ deploy/apps/lang/es.lang | 3 +++ deploy/apps/lang/fr.lang | 3 +++ deploy/apps/lang/hu.lang | 2 ++ deploy/apps/lang/it.lang | 3 +++ deploy/apps/lang/nl.lang | 3 +++ deploy/apps/lang/pt-br.lang | 3 +++ deploy/apps/lang/ro.lang | 3 +++ deploy/apps/lang/ru.lang | 3 +++ deploy/apps/lang/sr.lang | 3 +++ deploy/apps/lang/uk.lang | 3 +++ src/ui-touch/UITask.cpp | 7 +++++-- src/ui-touch/i18n_builtin.h | 2 ++ 15 files changed, 45 insertions(+), 2 deletions(-) diff --git a/deploy/apps/lang/bg.lang b/deploy/apps/lang/bg.lang index 21b7c3f..46da469 100644 --- a/deploy/apps/lang/bg.lang +++ b/deploy/apps/lang/bg.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/de.lang b/deploy/apps/lang/de.lang index 1722419..e26299e 100644 --- a/deploy/apps/lang/de.lang +++ b/deploy/apps/lang/de.lang @@ -1099,3 +1099,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/el.lang b/deploy/apps/lang/el.lang index 6e966fc..ee4ade2 100644 --- a/deploy/apps/lang/el.lang +++ b/deploy/apps/lang/el.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/es.lang b/deploy/apps/lang/es.lang index 0b2cdbc..ccc5c3e 100644 --- a/deploy/apps/lang/es.lang +++ b/deploy/apps/lang/es.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/fr.lang b/deploy/apps/lang/fr.lang index 8ccf214..038ab8a 100644 --- a/deploy/apps/lang/fr.lang +++ b/deploy/apps/lang/fr.lang @@ -1102,3 +1102,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/hu.lang b/deploy/apps/lang/hu.lang index fa5188b..e1167b2 100644 --- a/deploy/apps/lang/hu.lang +++ b/deploy/apps/lang/hu.lang @@ -1121,3 +1121,5 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal (stale) Jel (elavult) +nothing heard yet Még semmi hír. diff --git a/deploy/apps/lang/it.lang b/deploy/apps/lang/it.lang index 49b3183..af103b8 100644 --- a/deploy/apps/lang/it.lang +++ b/deploy/apps/lang/it.lang @@ -1102,3 +1102,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/nl.lang b/deploy/apps/lang/nl.lang index cc0e72d..b8a59f9 100644 --- a/deploy/apps/lang/nl.lang +++ b/deploy/apps/lang/nl.lang @@ -1100,3 +1100,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/pt-br.lang b/deploy/apps/lang/pt-br.lang index 2bcda5e..96cda17 100644 --- a/deploy/apps/lang/pt-br.lang +++ b/deploy/apps/lang/pt-br.lang @@ -1102,3 +1102,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/ro.lang b/deploy/apps/lang/ro.lang index 4679e46..0331e6c 100644 --- a/deploy/apps/lang/ro.lang +++ b/deploy/apps/lang/ro.lang @@ -1104,3 +1104,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/ru.lang b/deploy/apps/lang/ru.lang index 5c8d5cc..d8a02db 100644 --- a/deploy/apps/lang/ru.lang +++ b/deploy/apps/lang/ru.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/sr.lang b/deploy/apps/lang/sr.lang index bd479b9..b1d9f31 100644 --- a/deploy/apps/lang/sr.lang +++ b/deploy/apps/lang/sr.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/deploy/apps/lang/uk.lang b/deploy/apps/lang/uk.lang index 52d7572..4cba790 100644 --- a/deploy/apps/lang/uk.lang +++ b/deploy/apps/lang/uk.lang @@ -1103,3 +1103,6 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction N/A N/A This board cannot run this app. This board cannot run this app. +Signal Signal +Signal (stale) Signal (stale) +nothing heard yet nothing heard yet diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2f2fedc..9c08415 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -22187,10 +22187,13 @@ static void openSignalInfoPopup() { sig_row2_y = sig_row1_y + sig_line_h + 2; #endif - sigCell(stale && heard ? "Signal (stale)" : "Signal", 0, sig_hdr_y, COLOR_TEXT); + // sigCell does not translate its argument, so these wrap at the call site. The + // home-graph signal popup has its own TR()'d copy of the same three strings; + // this one was missed, so the card read English in every language (pisti87). + sigCell(stale && heard ? TR("Signal (stale)") : TR("Signal"), 0, sig_hdr_y, COLOR_TEXT); int cy; if (!heard) { - sigCell("nothing heard yet", 8, sig_row1_y, COLOR_SUB); + sigCell(TR("nothing heard yet"), 8, sig_row1_y, COLOR_SUB); #if defined(TLORA_PAGER) cy = sig_row1_y + lv_font_get_line_height(&g_font_12) + 10; #else diff --git a/src/ui-touch/i18n_builtin.h b/src/ui-touch/i18n_builtin.h index 4406385..bf5050f 100644 --- a/src/ui-touch/i18n_builtin.h +++ b/src/ui-touch/i18n_builtin.h @@ -836,6 +836,7 @@ static const I18nPair kBuiltin_hu[] = { { "Sightline", "Rálátás" }, { "Signal", "Jelerősség" }, { "Signal & traffic", "Jelzés és forgalom" }, + { "Signal (stale)", "Jel (elavult)" }, { "Signal probe", "Jelmérés" }, { "Size", "Méret" }, { "Skip", "Kihagyás" }, @@ -1011,6 +1012,7 @@ static const I18nPair kBuiltin_hu[] = { { "my region", "saját régió" }, { "n/a in this build", "n/a ebben a buildben" }, { "not asked yet", "Még nem kérték" }, + { "nothing heard yet", "Még semmi hír." }, { "now on channel %s", "most a %s csatornán" }, { "now talking to %s", "most %s-szel beszélgetek" }, { "off", "ki" },