From 9b30dad9e88ccebe9fa9d27811d231cb4b629575 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Thu, 23 Jul 2026 18:40:48 +0200 Subject: [PATCH 01/99] touch: recover a wedged microSD at runtime instead of playing dead until reboot A runtime SD failure is a one-way door in arduino-esp32: one timed-out command latches STA_NOINIT in sd_diskio (and a data-phase error leaves every later op failing FR_DISK_ERR without even setting NOINIT, so FatFs never re-inits the card). Meanwhile SD.cardType() returns the type CACHED at mount -- only an explicit SD.end() ever makes it CARD_NONE again -- so every 'cardType() != CARD_NONE' gate keeps passing, s_sd_mounted stays true, fmSdTryMount() no-ops, and the file-manager removal poll (which compared against CARD_NONE) could literally never fire. Net effect: after one bad moment on the bus (heavy clustered writes -- scheduled telemetry log rewrites, battery log, core-0 history flush / tile writes -- on the shared rail) the card silently plays dead until a reboot power-cycles it. Reported as: SD behaves as not present until the next reboot. Fix, three parts: - sdNoteWriteFailure(): any writer on any task (loop, core-0 worker) stamps a volatile flag when an SD write open fails while the card is supposedly mounted (telemetry log, battery log, wardrive CSV, chat history/threads store, tile cache). - sdHealthTick() in UITask::loop: verifies the stamp with real I/O -- sdProbeAlive() opens the root dir, whose opendir runs f_opendir -> disk_status = a real SEND_STATUS transaction (and one re-init handshake on a NOINIT'd card, so a recoverable card self-heals right there). If the card stopped answering, drop the mount and re-run the full mount ladder: exactly what the next reboot would have done, minus the reboot. Success => 'SD card remounted' toast and every feature resumes transparently (same VFS mountpoint, cached fs pointers stay valid). Failure => honest 'SD card lost' with cardType() now reading CARD_NONE, so features degrade gracefully and the existing remount paths (FM poll, row tap, feature use) keep retrying with the usual backoff. Held off while the core-0 worker owns the card (hist flush / FAT scan / firmware download) and while a format is pending; probes rate-limited to 1/5 s. - The FM removal poll now probes with real I/O instead of the dead 'SD.cardType() == CARD_NONE' check, so physically yanking the card is finally detected too. Builds verified: LilyGo_TDeck_companion_radio_touch, heltec_v4_tft_companion_radio_usb_tcp_touch, heltec_v4_r8_tft_companion_radio_usb_tcp_touch, tlora_pager_sx1262_companion_radio_touch, ThinkNode_M9_companion_radio_touch. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 96 ++++++++++++++++++++++++++++++++++++++--- src/ui-touch/i18n.cpp | 2 + 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..30ee56d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -16335,6 +16335,21 @@ static void calibrateBatteryCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(b, 3000); } +// ----- SD wedge detection (flag side; the probe/remount lives in sdHealthTick) ----- +// A runtime SD failure is a one-way door in arduino-esp32: one timed-out command +// latches STA_NOINIT (or a data-phase error leaves FR_DISK_ERR everywhere), while +// SD.cardType() keeps returning the type CACHED at mount — so every +// `cardType() != CARD_NONE` gate still passes, writes fail silently, and nothing +// ever re-runs the mount ladder until a reboot power-cycles the card. Any writer +// (loop task, core-0 worker, notify task) that sees an SD write fail while the +// card is supposedly mounted stamps this; UITask::loop verifies with real I/O +// and remounts if the card wedged. Volatile store only — safe from any task. +static volatile uint32_t s_sd_fail_note_ms = 0; +static inline void sdNoteWriteFailure() { + const uint32_t m = (uint32_t)millis(); + s_sd_fail_note_ms = m ? m : 1; +} + // ----- Battery history: 5-minute log to SD + a 24h chart popup (T-Deck only) ----- // Logging piggybacks the always-on UITask::loop (no extra wakeup): every // k_batt_log_period_ms a line is appended to /meshcomod/battery.log and the file @@ -16374,7 +16389,7 @@ static void batteryLogAppend(uint32_t epoch, uint16_t mv, int pct, uint16_t cpu_ if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = fs.open(battLogPath(), FILE_READ); File wf = fs.open(battLogTmp(), FILE_WRITE); // truncate/create - if (!wf) { if (rf) rf.close(); return; } + if (!wf) { if (rf) rf.close(); if (battLogOnSd()) sdNoteWriteFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -16734,7 +16749,7 @@ static void telemetryLogAppend(const uint8_t* key, uint32_t epoch, int mv, int t if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = SD.open(path, FILE_READ); File wf = SD.open(tmp, FILE_WRITE); - if (!wf) { if (rf) rf.close(); return; } + if (!wf) { if (rf) rf.close(); sdNoteWriteFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -18084,6 +18099,22 @@ static void fmSdUnmount() { if (s_sd_mounted) { SD.end(); s_sd_mounted = false; s_sd_size = 0; } s_sd_retry_after_ms = 0; // a reinsert should be able to mount right away } +// True when the card still answers real I/O. SD.cardType() is useless for this: +// arduino-esp32 caches the type at mount and only an explicit SD.end() ever +// returns CARD_NONE again — a wedged (or even physically removed) card keeps +// reporting its mount-time type forever. Opening the root dir DOES hit the card: +// the root `stat` is answered from RAM (vfs_fat special-cases "/"), but the +// opendir behind it runs f_opendir -> disk_status = a real SEND_STATUS +// transaction, and a NOINIT'd card then gets one full re-init handshake — so a +// recoverable card self-heals right here, a dead one returns an invalid File +// (opendir fails -> operator bool false). Healthy-card cost is sub-ms; a dead +// card rides out the SPI timeouts once, so callers rate-limit the probe. +static bool sdProbeAlive() { + File d = SD.open("/"); + const bool ok = d && d.isDirectory(); + if (d) d.close(); + return ok; +} static void fmSdClickCb(lv_event_t* e) { // SHORT_CLICKED (not CLICKED) so a long-press — which formats the card — does // not also fall through and open it. @@ -20512,7 +20543,7 @@ static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::Di SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/discover"); File f = SD.open("/meshcomod/discover/wardrive.csv", FILE_APPEND); - if (!f) return; + if (!f) { sdNoteWriteFailure(); return; } if (f.size() == 0) f.print("epoch,lat,lon,type,pubkey,rssi,snr,hops\n"); uint32_t epoch = (uint32_t)rtc_clock.getCurrentTime(); f.printf("%lu,%.6f,%.6f,%s,%02X%02X%02X%02X,%d,%.1f,%u\n", @@ -24255,11 +24286,17 @@ static void tileFetchTaskFn(void* arg) { } else { ++s_tile_fetch_short_wr; s_tile_fetch_last_wr = 'P'; // short/failed disk write (card full or SD error) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdNoteWriteFailure(); // wedge tell (worker task — stamp only) +#endif } } } else { ++s_tile_fetch_open_fail; s_tile_fetch_last_wr = 'O'; // open("w") failed: dir missing / write-protect / SD bus +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdNoteWriteFailure(); // wedge tell (worker task — stamp only) +#endif } } else { s_tile_fetch_last_wr = 'Z'; // bad / oversized content-length @@ -40921,7 +40958,14 @@ static bool uiDataFsIsSdCard() { static File uiDataOpen(const char* name, const char* mode) { if (!uiDataFsReady()) return File(); char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); - return s_ui_data_fs->open(p, mode); + File f = s_ui_data_fs->open(p, mode); +#if defined(HAS_TDECK_GT911) + // A failed WRITE open on the SD-backed history store is the wedge tell (reads + // fail legitimately on first boot). Called from the loop task AND the core-0 + // history worker — sdNoteWriteFailure is a volatile stamp, safe from both. + if (!f && mode && mode[0] == 'w' && s_ui_data_fs == &SD) sdNoteWriteFailure(); +#endif + return f; } // Delete a ui-data file — used to quarantine a blob that fails to load so a // corrupt/truncated file (interrupted SD/FAT write, brownout) can't wedge the @@ -44009,6 +44053,44 @@ static inline void uiCp(const char* next) { s_ui_cp_t0 = nowms; } +#if CAP_SD || defined(TLORA_PAGER) +// Runtime SD wedge detect + recover. Some writer saw an SD write fail while the +// card was supposedly mounted (sdNoteWriteFailure) — verify with real I/O and, +// if the card stopped answering, drop the mount and re-run the full mount +// ladder (SD.end() + staged SD.begin): exactly what the next reboot would do, +// minus the reboot. Without this the firmware is blind to a wedged card: +// SD.cardType() keeps returning its cached mount-time type, s_sd_mounted stays +// true so fmSdTryMount() no-ops, and every SD feature fails silently until the +// user power-cycles (the "SD stops working until reboot" report). +static void sdHealthTick() { + if (!s_sd_fail_note_ms) return; + if (!s_sd_mounted) { s_sd_fail_note_ms = 0; return; } // unmounted: the existing FM poll / user-tap remount paths own recovery + if (s_sd_format_pending) return; // format owns the card right now + // Hold off while the core-0 worker is (or may be about to be) on the card — + // SD.end() under an open worker file handle is the crash we must not add. + // On a truly wedged card the worker's own ops fail fast, so this clears. + if (s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request) return; +#if defined(HAS_TDECK_GT911) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA + if (s_sdfw_request) return; // worker streaming a firmware download to /BINS (T-Deck only) +#endif + static uint32_t s_next_probe_ms = 0; + const uint32_t now = (uint32_t)millis(); + if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; + s_next_probe_ms = now + 5000; // rate-limit: probing a dead card rides out SPI timeouts + s_sd_fail_note_ms = 0; + if (sdProbeAlive()) return; // transient failure (card full, bad path, ...) — not a wedge + fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake + if (fmSdTryMount()) { + if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); + } else { + // Ladder failed — card needs a real power cycle (reinsert). s_sd_mounted is + // now false and SD.cardType() reads CARD_NONE, so features degrade honestly + // instead of silently failing, and the usual remount paths keep retrying. + if (g_lv.task) g_lv.task->showAlert(TR("SD card lost - reinsert to recover"), 2600); + } +} +#endif + void UITask::loop() { unsigned long now = millis(); #if !defined(HAS_TANMATSU) @@ -44441,6 +44523,7 @@ void UITask::loop() { else s_jump_dimmed = true; // nothing open: park the state } #if CAP_SD || defined(TLORA_PAGER) + sdHealthTick(); // wedge detect + remount, driven by writer-flagged failures (any task) // microSD insert/remove detection — only while the file manager is open, so // there's no idle SPI traffic. SD.begin runs on this loop task (never // concurrent with the radio's SPI), and reuses the radio's already-begun bus. @@ -44455,7 +44538,10 @@ void UITask::loop() { showAlert(TR("SD card inserted"), 1500); if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } - } else if (SD.cardType() == CARD_NONE) { + } else if (!sdProbeAlive()) { + // Real-I/O probe, NOT `SD.cardType() == CARD_NONE`: cardType() is cached + // at mount and only SD.end() ever resets it, so the old check could never + // fire — neither a yanked card nor a wedged one was ever detected here. fmSdUnmount(); showAlert(TR("SD card removed"), 1500); if (s_fm_fs == &SD || !s_fm_fs) fmShowRoots(); diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index 30c8292..8094360 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -130,6 +130,8 @@ static const I18nEntry kI18n[] = { { "Invalid lat/lon", { 0, "Ongeldige lat/lon","Ungültige Lat/Lon","Lat/lon invalide","Lat/lon no válida","Lat/lon non valida","Неверные коорд.","Невірні коорд.","Невалидни коорд.","Неисправне коорд.","Μη έγκυρες συντ." ,"Lat/lon inválida"} }, { "SD card inserted", { 0, "SD-kaart geplaatst","SD-Karte eingelegt","Carte SD insérée","Tarjeta SD insertada","Scheda SD inserita","SD-карта вставлена","SD-картку вставлено","SD картата е поставена","SD картица убачена","Η κάρτα SD εισήχθη" ,"Cartão SD inserido"} }, { "SD card removed", { 0, "SD-kaart verwijderd","SD-Karte entfernt","Carte SD retirée","Tarjeta SD retirada","Scheda SD rimossa","SD-карта извлечена","SD-картку вилучено","SD картата е извадена","SD картица уклоњена","Η κάρτα SD αφαιρέθηκε" ,"Cartão SD removido"} }, + { "SD card remounted", { 0, "SD-kaart opnieuw gekoppeld","SD-Karte neu eingebunden","Carte SD remontée","Tarjeta SD remontada","Scheda SD rimontata","SD-карта перемонтирована","SD-картку перемонтовано","SD картата е монтирана отново","SD картица поново монтирана","Η κάρτα SD επανασυνδέθηκε" ,"Cartão SD remontado"} }, + { "SD card lost - reinsert to recover", { 0, "SD-kaart verloren - plaats opnieuw","SD-Karte verloren - neu einstecken","Carte SD perdue - réinsérez-la","Tarjeta SD perdida - reinsértala","Scheda SD persa - reinserirla","SD-карта потеряна - переставьте её","SD-картку втрачено - вставте знову","SD картата е изгубена - поставете я отново","SD картица изгубљена - убаците поново","Η κάρτα SD χάθηκε - επανατοποθετήστε" ,"Cartão SD perdido - reinsira-o"} }, // ---- Common field labels / status words ---- { "Enable Bluetooth", { 0, "Bluetooth inschakelen","Bluetooth aktivieren","Activer le Bluetooth","Activar Bluetooth","Attiva Bluetooth","Включить Bluetooth","Увімкнути Bluetooth","Включи Bluetooth","Укључи Bluetooth","Ενεργοποίηση Bluetooth" ,"Ativar Bluetooth"} }, From c3e0cdb8d83331657ef1ba81f9e5285cb263813c Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Thu, 23 Jul 2026 19:08:32 +0200 Subject: [PATCH 02/99] =?UTF-8?q?touch:=20SD=20wedge=20recovery=20?= =?UTF-8?q?=E2=80=94=20catch=20idle=20removal,=20un-freeze=20the=20map,=20?= =?UTF-8?q?backoff=20the=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device testing of the previous commit showed three gaps: - No 'SD card lost' when the card is pulled while nothing writes: detection was purely failure-driven, and the battery path never stamps (battLogFs's SD.exists fails -> silently falls back to SPIFFS, which is exactly the 'battery stats fail to load' symptom). sdHealthTick now also runs a 30 s background probe (one sub-ms SEND_STATUS on a healthy card), so idle removal is noticed and toasted; battLogFs stamps when cardType() claims present but real I/O fails. - Map froze the whole UI when the card was pulled with the map open: every tile lookup ran up to five SD ops, each riding a full sd_diskio timeout + re-init cycle, serializing the render pass into a multi-minute freeze. Tile paths (loadTileJpeg / tileExistsAt / the SD-backed tile cache) now short-circuit while a dead card is suspected, and sdReadFailedCardDead() distinguishes a missing tile (healthy card: cheap, silent) from a dead card (stamp + bail) on a failed read. - Even after an unmount, hot paths calling fmSdTryMount() directly would re-walk the multi-second mount ladder per call. fmSdTryMount() now fast-fails inside the post-failure backoff window; the explicit user retry (tap the SD row in the file manager) clears the backoff first. Also renames sdNoteWriteFailure -> sdNoteIoFailure (it now covers reads). Builds verified: LilyGo_TDeck_companion_radio_touch, heltec_v4_tft_companion_radio_usb_tcp_touch, heltec_v4_r8_tft_companion_radio_usb_tcp_touch, tlora_pager_sx1262_companion_radio_touch, ThinkNode_M9_companion_radio_touch. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 91 +++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 30ee56d..a3ae989 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -16339,13 +16339,13 @@ static void calibrateBatteryCb(lv_event_t* e) { // A runtime SD failure is a one-way door in arduino-esp32: one timed-out command // latches STA_NOINIT (or a data-phase error leaves FR_DISK_ERR everywhere), while // SD.cardType() keeps returning the type CACHED at mount — so every -// `cardType() != CARD_NONE` gate still passes, writes fail silently, and nothing -// ever re-runs the mount ladder until a reboot power-cycles the card. Any writer -// (loop task, core-0 worker, notify task) that sees an SD write fail while the +// `cardType() != CARD_NONE` gate still passes, I/O fails silently, and nothing +// ever re-runs the mount ladder until a reboot power-cycles the card. Any SD +// user (loop task, core-0 worker, notify task) that sees SD I/O fail while the // card is supposedly mounted stamps this; UITask::loop verifies with real I/O // and remounts if the card wedged. Volatile store only — safe from any task. static volatile uint32_t s_sd_fail_note_ms = 0; -static inline void sdNoteWriteFailure() { +static inline void sdNoteIoFailure() { const uint32_t m = (uint32_t)millis(); s_sd_fail_note_ms = m ? m : 1; } @@ -16362,7 +16362,12 @@ static inline void sdNoteWriteFailure() { // SPIFFS (flat) uses a top-level path. static fs::FS& battLogFs() { #if CAP_SD - if (SD.cardType() != CARD_NONE && SD.exists("/meshcomod")) return SD; + if (SD.cardType() != CARD_NONE) { + if (SD.exists("/meshcomod")) return SD; + // cardType() says present (cached) but real I/O failed — wedge/removal tell. + // (Or just a card without /meshcomod; sdHealthTick's probe arbitrates cheaply.) + sdNoteIoFailure(); + } #endif return SPIFFS; } @@ -16389,7 +16394,7 @@ static void batteryLogAppend(uint32_t epoch, uint16_t mv, int pct, uint16_t cpu_ if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = fs.open(battLogPath(), FILE_READ); File wf = fs.open(battLogTmp(), FILE_WRITE); // truncate/create - if (!wf) { if (rf) rf.close(); if (battLogOnSd()) sdNoteWriteFailure(); return; } + if (!wf) { if (rf) rf.close(); if (battLogOnSd()) sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -16749,7 +16754,7 @@ static void telemetryLogAppend(const uint8_t* key, uint32_t epoch, int mv, int t if (epoch > 1700000000 && localtime_r(&t, &tmv)) strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv); File rf = SD.open(path, FILE_READ); File wf = SD.open(tmp, FILE_WRITE); - if (!wf) { if (rf) rf.close(); sdNoteWriteFailure(); return; } + if (!wf) { if (rf) rf.close(); sdNoteIoFailure(); return; } if (rf) { while (rf.available()) { String ln = rf.readStringUntil('\n'); @@ -18037,6 +18042,12 @@ static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); } // already initialised by the radio/display, so those pins are untouched. static bool fmSdTryMount() { if (s_sd_mounted) return true; + // Fast-fail inside the post-failure backoff window. Hot paths call this + // directly (map tile lookups on the loop task, history writes via + // uiDataFsReady, WAV chimes) — without this gate every such call re-walks + // the multi-second mount ladder while the card is out, freezing the UI. + // Explicit user retries clear the backoff first (fmSdMountOrFormatCb). + if (s_sd_retry_after_ms && millis() < s_sd_retry_after_ms) return false; #if defined(TLORA_PAGER) if (!pagerSdCardPresent()) return false; #endif @@ -18115,6 +18126,19 @@ static bool sdProbeAlive() { if (d) d.close(); return ok; } +// Discriminator for hot READ paths (map tile lookups run up to five SD opens +// per tile on the loop task): a missing FILE on a healthy card must stay cheap +// and silent, while a dead card must stamp the failure AND let the caller +// short-circuit — otherwise one render pass serializes behind sd_diskio's +// per-op timeout/re-init and the whole UI freezes while a card is out. Call +// after a failed SD read/open; returns true when the card itself is dead +// (bail out of the whole SD pass), false when it was just a missing file. +static bool sdReadFailedCardDead() { + if (s_sd_fail_note_ms) return true; // already suspected — sdHealthTick arbitrates within ~5 s + if (sdProbeAlive()) return false; // card fine: genuinely missing file + sdNoteIoFailure(); + return true; +} static void fmSdClickCb(lv_event_t* e) { // SHORT_CLICKED (not CLICKED) so a long-press — which formats the card — does // not also fall through and open it. @@ -18163,6 +18187,7 @@ static void fmSdDoFormat() { // (e.g. exFAT, which this build can't read — only FAT16/FAT32) offer to format. static void fmSdMountOrFormatCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + s_sd_retry_after_ms = 0; // explicit user retry — bypass the mount backoff if (fmSdTryMount()) { fmShowRoots(); return; } showConfirm(TR("Format SD as MESHCOMOD (FAT32)?\nAll data on the card will be erased."), "Format", fmSdDoFormat); @@ -20543,7 +20568,7 @@ static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::Di SD.mkdir("/meshcomod"); SD.mkdir("/meshcomod/discover"); File f = SD.open("/meshcomod/discover/wardrive.csv", FILE_APPEND); - if (!f) { sdNoteWriteFailure(); return; } + if (!f) { sdNoteIoFailure(); return; } if (f.size() == 0) f.print("epoch,lat,lon,type,pubkey,rssi,snr,hops\n"); uint32_t epoch = (uint32_t)rtc_clock.getCurrentTime(); f.printf("%lu,%.6f,%.6f,%s,%02X%02X%02X%02X,%d,%.1f,%u\n", @@ -24287,7 +24312,7 @@ static void tileFetchTaskFn(void* arg) { ++s_tile_fetch_short_wr; s_tile_fetch_last_wr = 'P'; // short/failed disk write (card full or SD error) #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) - if (s_tile_fs == &SD) sdNoteWriteFailure(); // wedge tell (worker task — stamp only) + if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) #endif } } @@ -24295,7 +24320,7 @@ static void tileFetchTaskFn(void* arg) { ++s_tile_fetch_open_fail; s_tile_fetch_last_wr = 'O'; // open("w") failed: dir missing / write-protect / SD bus #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) - if (s_tile_fs == &SD) sdNoteWriteFailure(); // wedge tell (worker task — stamp only) + if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only) #endif } } else { @@ -24543,6 +24568,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, #if CAP_SD if (s_tiles_from_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). + if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) if (!fmSdTryMount()) return false; markSdIo(); // SD read activity -> status-bar LED // Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png @@ -24566,14 +24592,14 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, fsd = SD.open(ppath, FILE_READ); } if (!fsd) fsd = SD.open(path, FILE_READ); // legacy /tiles///.jpg - if (!fsd) return false; + if (!fsd) { sdReadFailedCardDead(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit) const size_t szsd = fsd.size(); if (szsd == 0 || szsd > 256 * 1024) { fsd.close(); return false; } // PNG tiles run larger than JPEG uint8_t* bufsd = (uint8_t*)lvglPsramAlloc(szsd); if (!bufsd) { fsd.close(); return false; } const size_t nsd = fsd.read(bufsd, szsd); fsd.close(); - if (nsd != szsd) { lvglPsramFree(bufsd); return false; } + if (nsd != szsd) { lvglPsramFree(bufsd); sdReadFailedCardDead(); return false; } // short read mid-tile = card died under us *out_data = bufsd; *out_len = szsd; return true; } @@ -24585,8 +24611,18 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, // re-downloaded forever and rendered nothing (#tiles). open() is the real existence test; read up to // the 100 KB writer cap and use the ACTUAL bytes read. (S3 boards: f.size() works there, but this is // equally correct — a transient 100 KB PSRAM buffer per tile, freed right after decode.) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + // Launcher installs cache tiles on the raw SD (s_tile_fs == &SD) — same + // dead-card short-circuit as the SD-pack path above. + if (s_tile_fs == &SD && s_sd_fail_note_ms) return false; +#endif File f = tileCacheOpen(path, "r"); - if (!f) return false; + if (!f) { +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + if (s_tile_fs == &SD) sdReadFailedCardDead(); +#endif + return false; + } const size_t CAP = 100 * 1024; uint8_t* buf = (uint8_t*)lvglPsramAlloc(CAP); if (!buf) { f.close(); return false; } @@ -24635,6 +24671,7 @@ static bool mapTileSourceReady() { static bool tileExistsAt(uint8_t z, long x, long y) { #if CAP_SD if (s_tiles_from_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below + if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) if (!fmSdTryMount()) return false; char p[56]; snprintf(p, sizeof p, "/maps/osm/%u/%ld/%ld.png", (unsigned)z, x, y); @@ -24646,7 +24683,9 @@ static bool tileExistsAt(uint8_t z, long x, long y) { snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.PNG", (unsigned)z, x, y); if (SD.exists(p)) return true; snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.jpg", (unsigned)z, x, y); - return SD.exists(p); + if (SD.exists(p)) return true; + sdReadFailedCardDead(); // all five missing: either past the pack edge (cheap, silent) or the card died + return false; } #endif if (!s_tiles_fs_ready) return false; @@ -40962,8 +41001,8 @@ static File uiDataOpen(const char* name, const char* mode) { #if defined(HAS_TDECK_GT911) // A failed WRITE open on the SD-backed history store is the wedge tell (reads // fail legitimately on first boot). Called from the loop task AND the core-0 - // history worker — sdNoteWriteFailure is a volatile stamp, safe from both. - if (!f && mode && mode[0] == 'w' && s_ui_data_fs == &SD) sdNoteWriteFailure(); + // history worker — sdNoteIoFailure is a volatile stamp, safe from both. + if (!f && mode && mode[0] == 'w' && s_ui_data_fs == &SD) sdNoteIoFailure(); #endif return f; } @@ -44055,7 +44094,7 @@ static inline void uiCp(const char* next) { #if CAP_SD || defined(TLORA_PAGER) // Runtime SD wedge detect + recover. Some writer saw an SD write fail while the -// card was supposedly mounted (sdNoteWriteFailure) — verify with real I/O and, +// card was supposedly mounted (sdNoteIoFailure) — verify with real I/O and, // if the card stopped answering, drop the mount and re-run the full mount // ladder (SD.end() + staged SD.begin): exactly what the next reboot would do, // minus the reboot. Without this the firmware is blind to a wedged card: @@ -44063,7 +44102,6 @@ static inline void uiCp(const char* next) { // true so fmSdTryMount() no-ops, and every SD feature fails silently until the // user power-cycles (the "SD stops working until reboot" report). static void sdHealthTick() { - if (!s_sd_fail_note_ms) return; if (!s_sd_mounted) { s_sd_fail_note_ms = 0; return; } // unmounted: the existing FM poll / user-tap remount paths own recovery if (s_sd_format_pending) return; // format owns the card right now // Hold off while the core-0 worker is (or may be about to be) on the card — @@ -44073,10 +44111,21 @@ static void sdHealthTick() { #if defined(HAS_TDECK_GT911) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA if (s_sdfw_request) return; // worker streaming a firmware download to /BINS (T-Deck only) #endif - static uint32_t s_next_probe_ms = 0; + // Probe when some SD user flagged a failure (rate-limited to 1/5 s), and + // also on a slow 30 s background cadence — a card yanked while nothing is + // writing (idle home screen) would otherwise never be noticed at all, since + // detection is failure-driven and reads fall back silently. A healthy-card + // probe is one SEND_STATUS transaction (sub-ms), so the idle cost is nil. + static uint32_t s_next_probe_ms = 0; + static uint32_t s_next_bg_probe_ms = 0; const uint32_t now = (uint32_t)millis(); - if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; - s_next_probe_ms = now + 5000; // rate-limit: probing a dead card rides out SPI timeouts + if (s_sd_fail_note_ms) { + if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; + } else { + if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; + } + s_next_probe_ms = now + 5000; + s_next_bg_probe_ms = now + 30000; s_sd_fail_note_ms = 0; if (sdProbeAlive()) return; // transient failure (card full, bad path, ...) — not a wedge fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake From 3b773986f0a4451ad4d056163109254f5d2dbcfd Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Thu, 23 Jul 2026 19:29:01 +0200 Subject: [PATCH 03/99] touch: pause the SD background probe in the sleep regime The 30 s background liveness probe now skips while touchSleep has the device parked (screen off, loop throttled): nobody is watching for the toast, and the periodic SEND_STATUS would keep the card from settling into its own power-down. A card yanked while asleep is caught by the first background probe after wake. The failure-driven probe stays active during sleep on purpose -- scheduled telemetry keeps logging to SD while parked, and a wedge there must still recover. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index a3ae989..ad2b666 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -44116,12 +44116,19 @@ static void sdHealthTick() { // writing (idle home screen) would otherwise never be noticed at all, since // detection is failure-driven and reads fall back silently. A healthy-card // probe is one SEND_STATUS transaction (sub-ms), so the idle cost is nil. + // The background probe pauses in the sleep regime (screen off, loop + // throttled): nobody sees the toast, an SD poke keeps the card out of its + // own power-down, and a yank-while-asleep is caught by the first probe + // after wake. The FAILURE-driven probe stays active asleep on purpose — + // scheduled telemetry keeps logging to SD during sleep, and a wedge there + // must still recover. static uint32_t s_next_probe_ms = 0; static uint32_t s_next_bg_probe_ms = 0; const uint32_t now = (uint32_t)millis(); if (s_sd_fail_note_ms) { if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; } else { + if (touchSleep::isSleeping()) return; if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; } s_next_probe_ms = now + 5000; From 0b1349aa006ea844484a7046728fabae6a84f7f7 Mon Sep 17 00:00:00 2001 From: Jacob Pretorius <17687841+jacobpretorius@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:37:50 +0100 Subject: [PATCH 04/99] feat(ui-touch): add Channel Utilization tool to Apps drawer Adds a self-contained "Airtime" app that measures how busy the LoRa channel actually is, so lost-message reports can be checked against real data instead of assumed congestion. It separates the two things that get conflated: - Is the spectrum busy? channel utilization % = (RX airtime + TX airtime) / wall-clock, sampled at 1 Hz, plotted over time, with a session average and a congestion-threshold reference line. - Are packets actually being lost at the radio? CRC errors (collisions), packets heard but read too late (getRxEvents - packetsRecv - recvErrors), and RX-queue drops. A plain verdict combines both axes so a field tester can report a concrete conclusion. Reads existing counters via the_mesh (Dispatcher airtime/packet accumulators) and radio_driver (RadioLib wrapper stats); no MeshCore core changes. --- src/ui-touch/ChannelUtil.cpp | 289 +++++++++++++++++++++++++++++++++++ src/ui-touch/ChannelUtil.h | 75 +++++++++ src/ui-touch/UITask.cpp | 10 +- 3 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 src/ui-touch/ChannelUtil.cpp create mode 100644 src/ui-touch/ChannelUtil.h diff --git a/src/ui-touch/ChannelUtil.cpp b/src/ui-touch/ChannelUtil.cpp new file mode 100644 index 0000000..2c1f77b --- /dev/null +++ b/src/ui-touch/ChannelUtil.cpp @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ChannelUtil.h" +#include "i18n.h" + +#include // millis() +#include // snprintf +#include "MyMesh.h" // extern MyMesh& the_mesh; (+ pulls target.h -> extern radio_driver) + +// The globals the rest of the UI already talks to. `the_mesh` derives from +// mesh::Dispatcher (airtime + packet-count accumulators) and `radio_driver` is +// the RadioLib wrapper (live RSSI, noise floor, RX event/error/drop counts). +extern MyMesh& the_mesh; +// radio_driver is declared `extern WRAPPER_CLASS radio_driver;` in the board's +// target.h, reached transitively through MyMesh.h above. + +// Mirrors the app's status-bar height; the overlay starts below it so the bar +// stays visible (and can dismiss us on the big-screen boards). Local so this +// module needs nothing from UITask. +static constexpr lv_coord_t kTopBar = 22; +static constexpr uint32_t kTickMs = 1000; // 1 Hz sample +static constexpr int kBusyPct = 40; // congestion threshold line / verdict pivot + +// Palette (hex literals so the module is self-contained). +static constexpr uint32_t kColBg = 0x0E1216; +static constexpr uint32_t kColText = 0xE6EAEE; +static constexpr uint32_t kColDim = 0x8A929A; +static constexpr uint32_t kColGood = 0x6FCF6F; // green (idle / not congested) +static constexpr uint32_t kColWarn = 0xE0B84C; // amber (moderate) +static constexpr uint32_t kColBusy = 0xE0584C; // red (busy / congested) +static constexpr uint32_t kColUtil = 0x35C9C9; // chart util series (cyan) +static constexpr uint32_t kColThresh = 0x5A6166; // chart threshold line (grey) + +ChannelUtil* ChannelUtil::s_active = nullptr; + +bool ChannelUtil::isOpen() { return s_active != nullptr && s_active->root_ != nullptr; } + +void ChannelUtil::launch() { + if (s_active) return; + ChannelUtil* c = new ChannelUtil(); + s_active = c; + if (!c->open()) { s_active = nullptr; delete c; } +} + +void ChannelUtil::dismiss() { + // Guaranteed exit from outside (a status-bar tap): the in-page X can sit under + // a taller status bar on big-screen boards. Mirrors closeCb's teardown; safe + // to delete synchronously here (the triggering target is the status bar). + if (!s_active) return; + ChannelUtil* c = s_active; + s_active = nullptr; + c->close(); + delete c; +} + +static uint32_t utilColor(int pct) { + if (pct >= kBusyPct) return kColBusy; + if (pct >= kBusyPct / 2) return kColWarn; + return kColGood; +} + +void ChannelUtil::snapshotBaseline() { + base_ms_ = millis(); + base_rx_air_ = the_mesh.getReceiveAirTime(); + base_tx_air_ = the_mesh.getTotalAirTime(); + base_rx_pkts_ = radio_driver.getPacketsRecv(); + base_rx_err_ = radio_driver.getPacketsRecvErrors(); + base_rx_evt_ = radio_driver.getRxEvents(); + base_rx_drop_ = radio_driver.getRxQueueDrops(); + have_prev_ = false; // windowed numbers restart too +} + +void ChannelUtil::tick() { + if (!root_ || !chart_) return; + + const uint32_t now = millis(); + const unsigned long rx_air = the_mesh.getReceiveAirTime(); + const unsigned long tx_air = the_mesh.getTotalAirTime(); + const uint32_t rx_pk = radio_driver.getPacketsRecv(); + const uint32_t rx_err = radio_driver.getPacketsRecvErrors(); + const uint32_t rx_evt = radio_driver.getRxEvents(); + const uint32_t rx_drp = radio_driver.getRxQueueDrops(); + + // --- Windowed (instantaneous, last tick) --------------------------------- + int inst_util = 0, inst_rx = 0, inst_tx = 0, rate = 0; + if (have_prev_ && now > prev_ms_) { + const uint32_t dt = now - prev_ms_; // ms of wall clock + const uint32_t d_rx = (uint32_t)(rx_air - prev_rx_air_); + const uint32_t d_tx = (uint32_t)(tx_air - prev_tx_air_); + inst_rx = (int)((uint64_t)d_rx * 100 / dt); + inst_tx = (int)((uint64_t)d_tx * 100 / dt); + inst_util = inst_rx + inst_tx; + if (inst_util > 100) inst_util = 100; + if (inst_rx > 100) inst_rx = 100; + if (inst_tx > 100) inst_tx = 100; + rate = (int)((uint64_t)(rx_pk - prev_rx_pkts_) * 60000 / dt); + } + prev_ms_ = now; prev_rx_air_ = rx_air; prev_tx_air_ = tx_air; + prev_rx_pkts_ = rx_pk; have_prev_ = true; + + // --- Session (since open / last Reset) ----------------------------------- + const uint32_t sess_ms = now - base_ms_; + int avg_util = 0; + if (sess_ms > 0) { + const uint64_t busy = (uint64_t)(rx_air - base_rx_air_) + (uint64_t)(tx_air - base_tx_air_); + avg_util = (int)(busy * 100 / sess_ms); + if (avg_util > 100) avg_util = 100; + } + const uint32_t sess_rx = rx_pk - base_rx_pkts_; + const uint32_t sess_err = rx_err - base_rx_err_; + const uint32_t sess_drop = rx_drp - base_rx_drop_; + // Radio heard it (RX-done ISR) but we never delivered it: events - recv - err. + // Can dip transiently negative by the 1-2 packets currently queued/unread. + long late = (long)(rx_evt - base_rx_evt_) - (long)sess_rx - (long)sess_err; + if (late < 0) late = 0; + const uint32_t sess_lost = (uint32_t)late + sess_drop; + + // --- Chart --------------------------------------------------------------- + lv_chart_set_next_value(chart_, ser_util_, (lv_coord_t)inst_util); + + // --- Headline ------------------------------------------------------------ + lv_obj_set_style_text_color(headline_, lv_color_hex(utilColor(inst_util)), LV_PART_MAIN); + lv_label_set_text_fmt(headline_, TR("CHANNEL %d%% BUSY"), inst_util); + + // --- Signal + preset ----------------------------------------------------- + const int now_rssi = (int)radio_driver.getCurrentRSSI(); + const int nf = radio_driver.getNoiseFloor(); + NodePrefs* np = the_mesh.getNodePrefs(); + const int sf = np ? (int)np->sf : 0; + const int bw_khz = np ? (int)np->bw : 0; + const int cr = np ? (int)np->cr : 0; + + const uint32_t h = sess_ms / 3600000UL; + const uint32_t m = (sess_ms / 60000UL) % 60; + const uint32_t s = (sess_ms / 1000UL) % 60; + + // Metrics block (recolour markup: #RRGGBB text#). + const uint32_t lossCol = sess_lost ? kColBusy : kColGood; + char b[420]; + snprintf(b, sizeof b, + "Now #%06X %d%%# (RX %d / TX %d) avg #%06X %d%%#\n" + "RX #%06X %d/min# %lu pkt sig %d / nf %d\n" + "Lost #%06X %lu# (crc %lu late %ld qf %lu)\n" + "SF%d BW%d CR%d run %02lu:%02lu:%02lu", + (unsigned)utilColor(inst_util), inst_util, inst_rx, inst_tx, + (unsigned)utilColor(avg_util), avg_util, + (unsigned)kColUtil, rate, (unsigned long)sess_rx, now_rssi, nf, + (unsigned)lossCol, (unsigned long)sess_lost, + (unsigned long)sess_err, late, (unsigned long)sess_drop, + sf, bw_khz, cr, + (unsigned long)h, (unsigned long)m, (unsigned long)s); + lv_label_set_text(metrics_, b); + + // --- Verdict ------------------------------------------------------------- + // Two independent axes: is the air busy, and are we losing packets. Report the + // honest combination so a tester can copy a real conclusion, not a guess. + const bool busy = avg_util >= kBusyPct; + const bool losing = sess_lost > 0; + char v[200]; uint32_t vc; + if (!busy && !losing) { + vc = kColGood; + snprintf(v, sizeof v, TR("Air only %d%% busy, 0 lost - congestion unlikely."), avg_util); + } else if (busy && losing) { + vc = kColBusy; + snprintf(v, sizeof v, TR("Air %d%% busy and %lu lost - congestion is plausible."), avg_util, (unsigned long)sess_lost); + } else if (!busy && losing) { + vc = kColWarn; + snprintf(v, sizeof v, TR("Air only %d%% but %lu lost - not congestion (weak signal or stall?)."), avg_util, (unsigned long)sess_lost); + } else { + vc = kColWarn; + snprintf(v, sizeof v, TR("Air %d%% busy but 0 lost - handling the load fine."), avg_util); + } + lv_obj_set_style_text_color(verdict_, lv_color_hex(vc), LV_PART_MAIN); + lv_label_set_text(verdict_, v); + + // Re-anchor the verdict under the now-4-line metrics block (the placeholder it + // was aligned to at open() was a single line). Layout must be current first. + lv_obj_update_layout(metrics_); + lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); +} + +bool ChannelUtil::open() { + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + const lv_coord_t sh = lv_disp_get_ver_res(nullptr); + const bool narrow = sw < 280; + + root_ = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(root_); + lv_obj_set_size(root_, sw, sh - kTopBar); + lv_obj_set_pos(root_, 0, kTopBar); + lv_obj_set_style_bg_color(root_, lv_color_hex(kColBg), LV_PART_MAIN); + lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_pad_all(root_, 6, LV_PART_MAIN); + + // Title. + lv_obj_t* title = lv_label_create(root_); + lv_label_set_text(title, TR("Channel Utilization")); + lv_obj_set_style_text_color(title, lv_color_hex(kColDim), LV_PART_MAIN); + lv_obj_align(title, LV_ALIGN_TOP_LEFT, 2, 2); + + // Close (X) + Reset buttons, top-right. + lv_obj_t* x = lv_btn_create(root_); + lv_obj_set_size(x, 30, 24); + lv_obj_align(x, LV_ALIGN_TOP_RIGHT, 0, -2); + lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this); + lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); + + lv_obj_t* rst = lv_btn_create(root_); + lv_obj_set_size(rst, 30, 24); + lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, -36, -2); + lv_obj_add_event_cb(rst, resetCb, LV_EVENT_CLICKED, this); + lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_center(rl); + + // Headline utilization %. + headline_ = lv_label_create(root_); + lv_obj_set_style_text_color(headline_, lv_color_hex(kColText), LV_PART_MAIN); + lv_label_set_text(headline_, TR("CHANNEL --% BUSY")); + lv_obj_align(headline_, LV_ALIGN_TOP_MID, 0, narrow ? 30 : 26); + + // Rolling utilization chart. + chart_ = lv_chart_create(root_); + const int chart_w = sw - 12; + const int chart_h = narrow ? 46 : 50; + const int chart_y = narrow ? 50 : 46; + lv_obj_set_size(chart_, chart_w, chart_h); + lv_obj_align(chart_, LV_ALIGN_TOP_MID, 0, chart_y); + lv_chart_set_type(chart_, LV_CHART_TYPE_LINE); + lv_chart_set_point_count(chart_, kPoints); + lv_chart_set_range(chart_, LV_CHART_AXIS_PRIMARY_Y, 0, 100); + lv_chart_set_update_mode(chart_, LV_CHART_UPDATE_MODE_SHIFT); + lv_obj_set_style_size(chart_, 0, LV_PART_INDICATOR); // no point dots + lv_obj_set_style_bg_color(chart_, lv_color_hex(0x11161B), LV_PART_MAIN); + lv_obj_set_style_border_width(chart_, 0, LV_PART_MAIN); + ser_thresh_ = lv_chart_add_series(chart_, lv_color_hex(kColThresh), LV_CHART_AXIS_PRIMARY_Y); + ser_util_ = lv_chart_add_series(chart_, lv_color_hex(kColUtil), LV_CHART_AXIS_PRIMARY_Y); + lv_chart_set_all_value(chart_, ser_thresh_, kBusyPct); // flat congestion reference + lv_chart_set_all_value(chart_, ser_util_, 0); + + // Metrics block (recolour markup). + metrics_ = lv_label_create(root_); + lv_label_set_recolor(metrics_, true); + lv_obj_set_style_text_color(metrics_, lv_color_hex(kColText), LV_PART_MAIN); + lv_obj_set_width(metrics_, sw - 12); + lv_label_set_long_mode(metrics_, LV_LABEL_LONG_WRAP); + lv_label_set_text(metrics_, TR("Sampling\xE2\x80\xA6")); + lv_obj_align(metrics_, LV_ALIGN_TOP_LEFT, 2, chart_y + chart_h + 6); + + // Verdict line, anchored directly under the (fixed 4-line) metrics block so it + // can never overlap it, whatever the board height. + verdict_ = lv_label_create(root_); + lv_label_set_recolor(verdict_, false); + lv_obj_set_style_text_color(verdict_, lv_color_hex(kColDim), LV_PART_MAIN); + lv_obj_set_width(verdict_, sw - 12); + lv_label_set_long_mode(verdict_, LV_LABEL_LONG_WRAP); + lv_label_set_text(verdict_, TR("Measuring\xE2\x80\xA6 give it a few seconds of traffic.")); + lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 8); + + snapshotBaseline(); + timer_ = lv_timer_create(timerCb, kTickMs, this); + lv_timer_ready(timer_); // first sample right away (seeds prev_, shows preset) + return true; +} + +void ChannelUtil::close() { + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } + if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes all children + headline_ = nullptr; chart_ = nullptr; ser_util_ = nullptr; ser_thresh_ = nullptr; + metrics_ = nullptr; verdict_ = nullptr; +} + +// ---- LVGL C-callback trampolines (user_data = the instance) ---- +void ChannelUtil::timerCb(lv_timer_t* t) { + auto* self = static_cast(t->user_data); + if (self) self->tick(); +} +void ChannelUtil::resetCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->snapshotBaseline(); + if (self->chart_ && self->ser_util_) lv_chart_set_all_value(self->chart_, self->ser_util_, 0); +} +void ChannelUtil::closeCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self) return; + self->close(); + if (s_active == self) s_active = nullptr; + delete self; +} diff --git a/src/ui-touch/ChannelUtil.h b/src/ui-touch/ChannelUtil.h new file mode 100644 index 0000000..b403a3e --- /dev/null +++ b/src/ui-touch/ChannelUtil.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +// Self-contained "Channel Utilization" tool launched from the Apps drawer. +// +// Purpose: give field testers a single screen that proves or DISPROVES the +// "the mesh is congested" theory for lost messages. It separates two distinct +// questions that get conflated: +// +// 1. Is the SPECTRUM actually busy? -> channel utilization % = +// (RX airtime + TX airtime) / wall-clock time. This is the real +// "is the air full" number, sampled live and averaged over the session. +// +// 2. Are packets actually being LOST at the radio? -> CRC errors +// (collisions), packets the radio heard but the loop read too late +// (getRxEvents - packetsRecv - recvErrors), and RX-queue drops. +// +// A mesh can be busy without losing anything, and can lose packets while the +// air is nearly idle (weak signal, SW stalls). Showing both side by side lets a +// tester report a concrete verdict instead of a hunch. +// +// Owns a full-screen overlay on lv_layer_top (mirrors SnakeGame): a title row +// with a Reset + close button, a big headline utilization %, a rolling chart of +// utilization over time, a metrics block, and a plain-language verdict line. A +// 1 Hz lv_timer samples the counters. One instance at a time. Decoupled from +// UITask internals — reaches the radio via the file-scope `the_mesh` / +// `radio_driver` globals the rest of the UI already uses. +class ChannelUtil { +public: + static void launch(); // open (no-op if already open) + static bool isOpen(); // UITask: gate the tab bar + raise the status bar + static void dismiss(); // close from outside (status-bar tap) — guaranteed exit + +private: + static constexpr int kPoints = 60; // chart history (~60 s at 1 Hz) + + lv_obj_t* root_ = nullptr; // full-screen overlay (owns all below) + lv_obj_t* headline_ = nullptr; // big "12%" + BUSY/idle word + lv_obj_t* chart_ = nullptr; // utilization % over time + lv_chart_series_t* ser_util_ = nullptr; // total channel util (RX+TX) + lv_chart_series_t* ser_thresh_ = nullptr; // flat congestion-threshold line + lv_obj_t* metrics_ = nullptr; // recolour label: rate / loss / signal / preset + lv_obj_t* verdict_ = nullptr; // plain-language conclusion + lv_timer_t* timer_ = nullptr; + + // Baselines snapshotted at open() (and re-snapshotted by Reset) so the + // session average and loss totals are measured from a known start, without + // touching the core's lifetime counters (other UI reads them too). + uint32_t base_ms_ = 0; + unsigned long base_rx_air_ = 0; // ms + unsigned long base_tx_air_ = 0; // ms + uint32_t base_rx_pkts_ = 0; + uint32_t base_rx_err_ = 0; + uint32_t base_rx_evt_ = 0; + uint32_t base_rx_drop_ = 0; + + // Previous-tick snapshot for the windowed (instantaneous) numbers. + uint32_t prev_ms_ = 0; + unsigned long prev_rx_air_ = 0; + unsigned long prev_tx_air_ = 0; + uint32_t prev_rx_pkts_ = 0; + bool have_prev_ = false; + + bool open(); // build UI; false on failure + void close(); + void snapshotBaseline(); + void tick(); // 1 Hz sample -> chart + labels + + static ChannelUtil* s_active; // the one live instance (or nullptr) + static void timerCb(lv_timer_t* t); + static void closeCb(lv_event_t* e); + static void resetCb(lv_event_t* e); +}; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..15fad7c 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -105,6 +105,7 @@ #include #include // PSRAM-preferred alloc helpers for the map tile cache #include "SnakeGame.h" // Apps → Snake (self-contained game module) +#include "ChannelUtil.h" // Apps → Airtime (self-contained channel-utilization tool) #if defined(HAS_TOUCH_UI) #include @@ -35711,7 +35712,7 @@ static void openControlCenter() { enum AppDrawerAction { APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS, APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL, - APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, + APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_AIRTIME, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, APPACT_DISCOVER, }; @@ -35957,6 +35958,7 @@ static void appTileCb(lv_event_t* e) { case APPACT_SIGNAL: openSignalInfoPopup(); return; // signal/traffic + auto-discover settings case APPACT_MONITOR: openMonitorPage(); return; // RF activity graph + repeater-style radio stats case APPACT_SPECTRUM: openSpectrumPage(); return; // swept RF spectrum analyzer (borrows the radio) + case APPACT_AIRTIME: ChannelUtil::launch(); return; // channel-utilization / congestion proof tool case APPACT_DISCOVER: openDiscoverPage(); return; // active node-discovery sweep + nearby list #if !defined(HAS_TANMATSU) case APPACT_VNC: openVncPage(); return; // screen mirror + remote control from a browser @@ -36317,6 +36319,7 @@ static void openAppDrawer() { { nullptr, "Signal", APPACT_SIGNAL, 0, COLOR_ACCENT }, // theme (drawn signal bars) { TOUCH_SYM_ANTENNA, "Monitor", APPACT_MONITOR, 0, 0x35C9C9 }, // RF monitor cyan { TOUCH_SYM_ANTENNA, "Spectrum", APPACT_SPECTRUM, 0, 0xE8A33D }, // RF spectrum analyzer amber + { "%", "Airtime", APPACT_AIRTIME, 0, 0xF2793C }, // channel-utilization / congestion proof (orange) { "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber { LV_SYMBOL_SETTINGS, "Settings", APPACT_SETTINGS, 0, 0x9AA3AD }, // neutral gear grey #if defined(HAS_TOUCH_UI) @@ -36413,6 +36416,7 @@ static void statusBarTapCb(lv_event_t* e) { // game's own X can sit UNDER a taller status bar on big-screen boards (the P4), // leaving no reachable exit — so a bar tap closes Snake here (the guaranteed way out). if (SnakeGame::isOpen()) { SnakeGame::dismiss(); return; } + if (ChannelUtil::isOpen()) { ChannelUtil::dismiss(); return; } // guaranteed exit if the X sits under a tall bar if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold // A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back // chevron + title, so a tap goes back (closes the page) just like settings. @@ -37068,7 +37072,7 @@ static void updateGlobalStatusBar() { // lv_layer_top and moved foreground, so without this the TALL bar's lower half (back // chevron + title) is covered by the page. Settings sheets sit on lv_scr_act so they // never need this. - const bool want_fg = SnakeGame::isOpen() || (s_apppage_title != nullptr); + const bool want_fg = SnakeGame::isOpen() || ChannelUtil::isOpen() || (s_apppage_title != nullptr); if (want_fg) { // Keep the bar the TOPMOST lv_layer_top child so its back tap is always hittable — // re-assert every tick (a page opened over a chat can otherwise let the chat's @@ -44829,7 +44833,7 @@ void UITask::loop() { } // Also hard-lock the tab bar while Snake is open so a swipe/tap near the // bottom can't slip through to the app switcher (e.g. into the map). - const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen(); + const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen() || ChannelUtil::isOpen(); if (want_lock != s_tabbar_locked && g_lv.tabview) { lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview); if (tab_btns) { From fd8e835cf3ce4aa511af534258fb84666722ba19 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Fri, 24 Jul 2026 21:19:15 +0200 Subject: [PATCH 05/99] touch: remount a reinserted card so history writes resume (messages were lost) On-device test: after a failed remount ('SD card lost', SD.end() run) a REINSERTED card looked recovered -- battery stats quietly fall back to SPIFFS -- but chat history kept writing into the unregistered SD VFS and silently failing: new messages were never persisted again until reboot. Nothing outside the file manager ever re-attempted a mount, and sdHealthTick early-returned while unmounted. - sdHealthTick now runs a reinsert watch while unmounted: one light single-attempt SD.begin (no ladder; an empty slot fails fast) every 30 s while awake. A healthy warm card mounts on the first 4 MHz attempt -> 'SD card remounted', and the armed history flush retries then land (messages received while the card was out are still in the RAM ring and flush too). The FM poll / SD-row tap keep the full ladder for stubborn cards; the watch skips while the FM is open or the pager card-detect says the slot is empty. - Hot paths no longer walk the mount ladder at all: map tile lookups (loadTileJpeg / tileExistsAt) check s_sd_mounted only -- with the card out, the post-backoff re-ladder read as recurring multi-second UI freezes on the map. The notify-task WAV open also drops its fmSdTryMount(): laddering SD.end()/SD.begin() from a second task races the loop task's SD use. Mounting is owned by boot adoption, the reinsert watch, the FM paths, and the SD-tiles toggle. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 53 +++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index ad2b666..cc1307d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -833,7 +833,12 @@ static bool wavOpen(const char* prefpath, File& f) { if (!prefpath || !prefpath[0]) return false; fs::FS* fsp = &SPIFFS; const char* fp = prefpath; #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) // only the T-Deck/pager sound picker ever writes an "sd:"-prefixed pref - if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; fmSdTryMount(); } + // No fmSdTryMount() here: this runs on the throwaway notify task, and walking + // the SD.end()+SD.begin() mount ladder from a second task races the loop + // task's own SD use (the one documented single-task assumption). If the card + // isn't mounted the open below fails fast and the chime falls back; mounting + // is owned by boot adoption / sdHealthTick's reinsert watch / the FM paths. + if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; } #endif f = fsp->open(fp, FILE_READ); if (!f || f.isDirectory()) { if (f) f.close(); return false; } @@ -24569,7 +24574,11 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, if (s_tiles_from_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) - if (!fmSdTryMount()) return false; + // Mounted check only — a render loop must never walk the multi-second + // mount ladder per tile (with the card out that read as recurring UI + // freezes). Mounting is owned by boot adoption, sdHealthTick's reinsert + // watch, the FM poll, and the SD-tiles toggle (mapOptTilesSdCb mounts). + if (!s_sd_mounted) return false; markSdIo(); // SD read activity -> status-bar LED // Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png // (decoded via lodepng); fall back to the legacy /tiles/{z}/{x}/{y}.jpg. @@ -24672,7 +24681,7 @@ static bool tileExistsAt(uint8_t z, long x, long y) { #if CAP_SD if (s_tiles_from_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) - if (!fmSdTryMount()) return false; + if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg) char p[56]; snprintf(p, sizeof p, "/maps/osm/%u/%ld/%ld.png", (unsigned)z, x, y); if (SD.exists(p)) return true; @@ -44102,7 +44111,40 @@ static inline void uiCp(const char* next) { // true so fmSdTryMount() no-ops, and every SD feature fails silently until the // user power-cycles (the "SD stops working until reboot" report). static void sdHealthTick() { - if (!s_sd_mounted) { s_sd_fail_note_ms = 0; return; } // unmounted: the existing FM poll / user-tap remount paths own recovery + static uint32_t s_next_probe_ms = 0; + static uint32_t s_next_bg_probe_ms = 0; + const uint32_t now = (uint32_t)millis(); + if (!s_sd_mounted) { + s_sd_fail_note_ms = 0; + // Reinsert watch. After a failed remount ("SD card lost") the SD VFS is + // unregistered and NOTHING outside the file manager ever re-runs a mount + // — a reinserted card then LOOKED recovered (battery quietly falls back + // to SPIFFS) while chat-history writes kept failing silently and new + // messages were never persisted. One light single-attempt mount (no + // ladder; an empty slot fails in well under a second) every 30 s while + // awake — a healthy warm card mounts on the first 4 MHz attempt. The FM + // poll / SD-row tap still provide the full ladder for stubborn cards. + if (touchSleep::isSleeping()) return; + if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; + s_next_bg_probe_ms = now + 30000; +#if defined(TLORA_PAGER) + if (!pagerSdCardPresent()) return; // card-detect says the slot is empty +#endif + if (s_fm_list) return; // FM open: its 2 s poll owns remounting (full ladder) + SPIClass* spi = sdSharedSPI(); + if (!spi) return; + SD.end(); + if (SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE) { + s_sd_mounted = true; + s_sd_size = SD.cardSize(); + s_sd_retry_after_ms = 0; + markSdIo(); + if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); + } else { + SD.end(); // leave cardType() honestly CARD_NONE + } + return; + } if (s_sd_format_pending) return; // format owns the card right now // Hold off while the core-0 worker is (or may be about to be) on the card — // SD.end() under an open worker file handle is the crash we must not add. @@ -44122,9 +44164,6 @@ static void sdHealthTick() { // after wake. The FAILURE-driven probe stays active asleep on purpose — // scheduled telemetry keeps logging to SD during sleep, and a wedge there // must still recover. - static uint32_t s_next_probe_ms = 0; - static uint32_t s_next_bg_probe_ms = 0; - const uint32_t now = (uint32_t)millis(); if (s_sd_fail_note_ms) { if (s_next_probe_ms && (int32_t)(now - s_next_probe_ms) < 0) return; } else { From f29728bdf8d4b8e70361a1bff36560b608297ec1 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Fri, 24 Jul 2026 23:14:41 +0200 Subject: [PATCH 06/99] touch: persist the chat ring immediately after an SD remount Message-loss analysis from testing: messages live in a RAM ring and the SD file is written lazily (>=30 s coalesce for the SD-sized ring), with a synchronous persist only on SOFT reboot/shutdown. A hard power cut (power switch, reset button, reflash) therefore loses everything since the last SUCCESSFUL flush -- and while the card is out, flushes fail for the whole outage, so the loss window grows to the entire card-out span plus up to 30 s after remount. This also explains the confusing UX where the thread list still shows the correct last-message time while the conversation lacks the recent messages: thread metadata (ui_threads_v1.bin, ~1.5 s coalesce) and the message ring (ui_msgs_v1.bin, >=30 s) are separate files with different freshness. All three remount paths (wedge recovery, reinsert watch, FM insert poll) now call persistHistoryNow() right after a successful mount, so the entire RAM ring -- including every message received while the card was out -- lands on the card within the same second it returns. They also SD.mkdir(/meshcomod) first: a fresh replacement card has no data root, and without it every history flush failed silently forever. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index cc1307d..37f2143 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -44140,6 +44140,15 @@ static void sdHealthTick() { s_sd_retry_after_ms = 0; markSdIo(); if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); +#if defined(HAS_TDECK_GT911) + // Land the RAM ring on the card NOW, not up to 30+ s later: every message + // received while the card was out is only in RAM, and a hard power cut + // (power switch, reset, reflash) before the next lazy flush would drop + // the whole outage backlog. mkdir first — a fresh replacement card has no + // /meshcomod yet, and without it every history flush fails silently. + SD.mkdir("/meshcomod"); + if (g_lv.task) g_lv.task->persistHistoryNow(); +#endif } else { SD.end(); // leave cardType() honestly CARD_NONE } @@ -44177,6 +44186,10 @@ static void sdHealthTick() { fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake if (fmSdTryMount()) { if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); +#if defined(HAS_TDECK_GT911) + SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + if (g_lv.task) g_lv.task->persistHistoryNow(); // land the RAM ring before any hard power cut +#endif } else { // Ladder failed — card needs a real power cycle (reinsert). s_sd_mounted is // now false and SD.cardType() reads CARD_NONE, so features degrade honestly @@ -44631,6 +44644,10 @@ void UITask::loop() { // unmountable card spikes current / churns the bus and can reset the board. if (now >= s_sd_retry_after_ms && fmSdTryMount()) { showAlert(TR("SD card inserted"), 1500); +#if defined(HAS_TDECK_GT911) + SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + persistHistoryNow(); // land the RAM ring (outage backlog) before any hard power cut +#endif if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } } else if (!sdProbeAlive()) { From f1e2bc375bdcf2e4c6fc77991a5f75e1b7308943 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Fri, 24 Jul 2026 23:29:29 +0200 Subject: [PATCH 07/99] touch: surface chat-history save failures; harden the reboot-time persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Digging into 'messages still lost after a soft reboot': the loader is sound (well-formed files load fully; a quarantine discards the whole load, never a suffix) and the chat detail applies no time filter — so a conversation missing only its recent messages means the ui_msgs file itself is stale. Two ways that happens silently today: - Persistent ring-write failures. uiWriteMsgsFile failing (full card, FAT damage from hard cuts/card yanks, wedge) was 100% invisible: the small threads file keeps succeeding, so the thread list shows fresh last-message times while messages quietly stop persisting — exactly the reported UX. Now every ring-write outcome is recorded (uiMsgsWriteResult), repeated failures raise an on-screen warning, a failure on the SD store stamps the SD health probe, and the About page gets a 'Chat store' section showing the resolved backend (also exposes the boot-with-unmountable-card trap where history silently goes to internal flash), the msgs file size, and the last save result. - The reboot-time persist had a hole and a corruption hazard. A worker still busy past uiHistWaitWorkerIdle's 9 s cap left _msgs_dirty cleared, so rebootDevice skipped the final sync save entirely; and when the sync save DID overlap a stalled worker write, both truncated the SAME temp file — interleaved garbage, and whichever rename landed last installed a corrupt file the next boot quarantines (total history loss). A still-busy worker now counts as unwritten data, the synchronous writer uses its own temp (last-rename-wins, each candidate internally consistent), a failed last-chance save shows a warning before the reset, and boot reclaims orphaned temps. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 112 +++++++++++++++++++++++++++++++++++----- src/ui-touch/i18n.cpp | 2 + 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 37f2143..372946c 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -224,6 +224,13 @@ constexpr uint16_t k_ui_history_min_version = 6; // v4/v5 used 96-char records constexpr const char* k_ui_threads_path = "/ui_threads_v1.bin"; constexpr const char* k_ui_msgs_path = "/ui_msgs_v1.bin"; constexpr const char* k_ui_msgs_tmp_path = "/ui_msgs_v1.bin.tmp"; +// Separate temp for the SYNCHRONOUS (shutdown/reboot/no-PSRAM) writer. The +// core-0 worker and the loop task can overlap when a stalled worker outlives +// uiHistWaitWorkerIdle's 9 s cap — two truncating opens of ONE tmp interleave +// into garbage, and whichever rename lands last installs a corrupt file that +// the next boot quarantines (total history loss). Distinct tmp names make the +// overlap last-rename-wins with each candidate internally consistent. +constexpr const char* k_ui_msgs_tmp2_path = "/ui_msgs_v1.bin.tm2"; constexpr uint32_t k_ui_threads_magic = 0x55495448; // "UITH" constexpr uint32_t k_ui_msgs_magic = 0x55494D53; // "UIMS" @@ -4543,6 +4550,17 @@ static volatile bool s_hist_flush_req = false; // snapshot armed, waiting for static volatile bool s_hist_flush_busy = false; // worker owns the snapshot + the history file static volatile bool s_hist_flush_ok = true; // last worker write result (retry on false) static bool uiHistWorkerFlush(); // defined with the storage code below +// Message-ring write health. A failing ui_msgs write used to be 100% silent — +// the user only found out on the next reboot as "messages from the last N +// minutes vanished" (the thread list still showed fresh times because the +// small threads file kept writing while the big ring write failed). Updated +// inside uiWriteMsgsFile from EITHER task (volatile stores only); read by the +// About page + the repeated-failure toast in flushHistoryIfDue. +static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) +static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write +static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success +static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend +static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists static volatile bool s_sdinfo_ok = false; // card present + sizes valid static uint64_t s_sdinfo_tot = 0; @@ -10025,6 +10043,33 @@ static void sysInfoTextLive(char* buf, size_t cap) { (unsigned long)rx_evt, (unsigned long)rx_ok, (unsigned long)rx_err, (unsigned long)rx_lost, (unsigned long)radio_driver.getRxQueueDrops(), radio_driver.rxQueueEnabled() ? "on" : "off"); + + // Chat-history store health. Two failure modes this exposes that are + // otherwise invisible: (a) the "wrong backend" trap — a boot whose SD mount + // failed resolves the store to internal SPIFFS and every message written + // that session is invisible on the next carded boot; (b) persistent ring + // write failures (full card / damaged FAT) — the small threads file keeps + // succeeding, so the thread list looks fresh while messages silently stop + // persisting. + { + size_t fsz = 0; + { File f = uiDataOpen(k_ui_msgs_path, "r"); if (f) { fsz = f.size(); f.close(); } } + char stat[48]; + const uint32_t nowms = (uint32_t)millis(); + if (s_msgs_write_fails) + snprintf(stat, sizeof stat, "FAILING x%u (%lus ago)", + (unsigned)s_msgs_write_fails, + (unsigned long)((nowms - s_msgs_write_fail_ms) / 1000u)); + else if (s_msgs_write_ok_ms) + snprintf(stat, sizeof stat, "OK %lus ago", + (unsigned long)((nowms - s_msgs_write_ok_ms) / 1000u)); + else + snprintf(stat, sizeof stat, "none this session"); + p += snprintf(buf + p, cap - p, + "Chat store\n %s file: %u KB\n last save: %s\n\n", + uiDataFsIsSdCard() ? "SD /meshcomod" : "Internal flash", + (unsigned)(fsz / 1024), stat); + } #endif (void)p; (void)cap; } @@ -40881,8 +40926,17 @@ static uint16_t s_hist_snap_head = 0; static uint32_t s_hist_snap_msgcount = 0; void UITask::flushHistoryIfDue(unsigned long now) { - // A worker write failed (storage hiccup): re-arm and try again. - if (!s_hist_flush_ok) { s_hist_flush_ok = true; markMsgsDirty(5000); } + // A worker write failed (storage hiccup): re-arm and try again. Surface + // repeated failures — a persistently failing ring write was previously + // 100% silent and read as "messages from the last N minutes vanished" on + // the next reboot (the small threads file kept writing, so the thread + // list still showed fresh last-message times). + if (!s_hist_flush_ok) { + s_hist_flush_ok = true; + markMsgsDirty(5000); + if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) + showAlert(TR("Chat history is NOT saving (storage error)"), 2600); + } // Thread metadata (~4 KB) flushes on a short delay; the message ring // (scales with MAX_UI_MESSAGES) flushes lazily to reduce flash write pressure. if (_threads_dirty && now >= _next_threads_flush_ms) { @@ -41245,6 +41299,10 @@ bool UITask::loadLegacyHistoryFromStorage() { bool UITask::loadHistoryFromStorage() { #if defined(ESP32) + // Hygiene: a crash/power-cut mid-flush can orphan a temp file; they're never + // read (the loader only opens the final path), just reclaim the space. + uiDataRemove(k_ui_msgs_tmp_path); + uiDataRemove(k_ui_msgs_tmp2_path); const bool have_threads = loadThreadsFromStorage(); const bool have_msgs = loadMsgsFromStorage(); if (!have_threads && !have_msgs) { @@ -41321,12 +41379,31 @@ bool UITask::saveThreadsToStorage() { // write from PSRAM" that regressed before: the chunk lives in internal RAM (the // flash driver never bounces a PSRAM source). Alloc failure falls back to the // original per-record writes. +// Record a ring-write outcome (runs on the loop task OR the core-0 worker — +// volatile stores only, LVGL-free) and stamp the SD health probe on failure so +// a wedged card gets arbitrated instead of the write failing silently forever. +static bool uiMsgsWriteResult(bool ok) { + const uint32_t m = (uint32_t)millis(); + if (ok) { + s_msgs_write_ok_ms = m ? m : 1; + s_msgs_write_fails = 0; + } else { + s_msgs_write_fail_ms = m ? m : 1; + if (s_msgs_write_fails < 0xFFFFu) s_msgs_write_fails = s_msgs_write_fails + 1; +#if defined(HAS_TDECK_GT911) + if (s_ui_data_fs == &SD) sdNoteIoFailure(); +#endif + } + return ok; +} + static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, - uint16_t count, uint16_t head, uint32_t msgcount) { + uint16_t count, uint16_t head, uint32_t msgcount, + const char* tmp_path) { #if defined(ESP32) WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC - File f = uiDataOpen(k_ui_msgs_tmp_path, "w"); - if (!f) return false; + File f = uiDataOpen(tmp_path, "w"); + if (!f) return uiMsgsWriteResult(false); UiMsgFileHeader hdr{}; hdr.magic = k_ui_msgs_magic; @@ -41402,10 +41479,10 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, } } f.close(); - if (!ok) { uiDataRemove(k_ui_msgs_tmp_path); return false; } - return uiDataReplaceFile(k_ui_msgs_path, k_ui_msgs_tmp_path); + if (!ok) { uiDataRemove(tmp_path); return uiMsgsWriteResult(false); } + return uiMsgsWriteResult(uiDataReplaceFile(k_ui_msgs_path, tmp_path)); #else - (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; + (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; (void)tmp_path; return false; #endif } @@ -41415,7 +41492,8 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, static bool uiHistWorkerFlush() { if (!s_hist_snap || s_hist_snap_cap <= 0) return false; // armed without a snapshot — retry return uiWriteMsgsFile(s_hist_snap, s_hist_snap_cap, - s_hist_snap_count, s_hist_snap_head, s_hist_snap_msgcount); + s_hist_snap_count, s_hist_snap_head, s_hist_snap_msgcount, + k_ui_msgs_tmp_path); } bool UITask::saveMsgsToStorage() { @@ -41425,7 +41503,8 @@ bool UITask::saveMsgsToStorage() { return uiWriteMsgsFile(_ui_msgs, _ui_msg_cap, static_cast(_ui_msg_count), static_cast(_ui_msg_head), - static_cast(_msgcount)); + static_cast(_msgcount), + k_ui_msgs_tmp2_path); // own tmp — never collide with a stalled worker write #else return false; #endif @@ -43799,9 +43878,18 @@ void UITask::rebootDevice() { // Persist chat history synchronously before we reboot — the periodic // flush is rate-capped, so without this a reboot could drop the most // recent chat history. - if (uiHistWaitWorkerIdle()) _msgs_dirty = true; // cancelled snapshot = unwritten data + // A worker still busy after the 9 s wait cap counts as unwritten data too: + // its write may never land (stalled on a dying card), and _msgs_dirty was + // already cleared when its snapshot was armed — skipping the sync save here + // silently dropped everything since the last successful flush. + if (uiHistWaitWorkerIdle() || s_hist_flush_busy) _msgs_dirty = true; if (_threads_dirty) saveThreadsToStorage(); - if (_msgs_dirty) saveMsgsToStorage(); + if (_msgs_dirty && !saveMsgsToStorage()) { + // Last-chance save failed — say so instead of rebooting into silent loss. + showAlert(TR("Chat history save FAILED"), 1500); + lv_refr_now(NULL); + delay(1200); // let the warning actually paint before the reset + } discoveredFlushNow(); // persist the Discovered ring before we go down the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices) if (_board) _board->reboot(); diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index 8094360..413f9a9 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -132,6 +132,8 @@ static const I18nEntry kI18n[] = { { "SD card removed", { 0, "SD-kaart verwijderd","SD-Karte entfernt","Carte SD retirée","Tarjeta SD retirada","Scheda SD rimossa","SD-карта извлечена","SD-картку вилучено","SD картата е извадена","SD картица уклоњена","Η κάρτα SD αφαιρέθηκε" ,"Cartão SD removido"} }, { "SD card remounted", { 0, "SD-kaart opnieuw gekoppeld","SD-Karte neu eingebunden","Carte SD remontée","Tarjeta SD remontada","Scheda SD rimontata","SD-карта перемонтирована","SD-картку перемонтовано","SD картата е монтирана отново","SD картица поново монтирана","Η κάρτα SD επανασυνδέθηκε" ,"Cartão SD remontado"} }, { "SD card lost - reinsert to recover", { 0, "SD-kaart verloren - plaats opnieuw","SD-Karte verloren - neu einstecken","Carte SD perdue - réinsérez-la","Tarjeta SD perdida - reinsértala","Scheda SD persa - reinserirla","SD-карта потеряна - переставьте её","SD-картку втрачено - вставте знову","SD картата е изгубена - поставете я отново","SD картица изгубљена - убаците поново","Η κάρτα SD χάθηκε - επανατοποθετήστε" ,"Cartão SD perdido - reinsira-o"} }, + { "Chat history is NOT saving (storage error)", { 0, "Chatgeschiedenis wordt NIET opgeslagen (opslagfout)","Chatverlauf wird NICHT gespeichert (Speicherfehler)","L'historique du chat n'est PAS enregistré (erreur de stockage)","El historial de chat NO se guarda (error de almacenamiento)","La cronologia chat NON viene salvata (errore di archiviazione)","История чата НЕ сохраняется (ошибка накопителя)","Історія чату НЕ зберігається (помилка сховища)","Историята на чата НЕ се записва (грешка в паметта)","Историја ћаскања се НЕ чува (грешка складишта)","Το ιστορικό συνομιλίας ΔΕΝ αποθηκεύεται (σφάλμα αποθήκευσης)" ,"Histórico do chat NÃO está sendo salvo (erro de armazenamento)"} }, + { "Chat history save FAILED", { 0, "Opslaan chatgeschiedenis MISLUKT","Speichern des Chatverlaufs FEHLGESCHLAGEN","Échec de l'enregistrement de l'historique","FALLÓ el guardado del historial de chat","Salvataggio cronologia chat FALLITO","НЕ УДАЛОСЬ сохранить историю чата","НЕ ВДАЛОСЯ зберегти історію чату","Записът на историята на чата СЕ ПРОВАЛИ","Чување историје ћаскања НИЈЕ УСПЕЛО","Η αποθήκευση του ιστορικού ΑΠΕΤΥΧΕ" ,"FALHA ao salvar o histórico do chat"} }, // ---- Common field labels / status words ---- { "Enable Bluetooth", { 0, "Bluetooth inschakelen","Bluetooth aktivieren","Activer le Bluetooth","Activar Bluetooth","Attiva Bluetooth","Включить Bluetooth","Увімкнути Bluetooth","Включи Bluetooth","Укључи Bluetooth","Ενεργοποίηση Bluetooth" ,"Ativar Bluetooth"} }, From 999dde93a3d9592bf977216a96943b2289dddcff Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Fri, 24 Jul 2026 23:47:50 +0200 Subject: [PATCH 08/99] touch: stop a failing chat store from freezing the UI Field trace with the new Chat-store diagnostics (FAILING x1, then an escalating UI freeze on every ~30 s retry) exposed three stall sources around a card that mounts fine but times out on large writes: - The About page's Chat-store readout opened the msgs file at 1 Hz on the UI task; that open blocks on the FatFs volume lock behind the worker's slow failing write, so just WATCHING the diagnostics froze the UI. The file size is now cached, refreshed at most every 30 s and never while a flush is in flight. - The persist-after-remount ran the full ring write synchronously on the UI task -- >30 s freeze on a write-sick card. Replaced with flushHistorySoon(): arms an immediate flush without the min-delay clamp, actual write stays on the core-0 worker. - Retries against a hopelessly failing store ran every 30 s forever, each costing seconds of bus stalls. After 5 consecutive failures the retry backs off to 5 min; the RAM ring keeps everything meanwhile, the on-screen warning already fired, and one success resets the counter. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 55 +++++++++++++++++++++++++++++++---------- src/ui-touch/UITask.h | 1 + 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 372946c..e4160eb 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -10052,10 +10052,21 @@ static void sysInfoTextLive(char* buf, size_t cap) { // succeeding, so the thread list looks fresh while messages silently stop // persisting. { - size_t fsz = 0; - { File f = uiDataOpen(k_ui_msgs_path, "r"); if (f) { fsz = f.size(); f.close(); } } - char stat[48]; + // File size is CACHED and refreshed at most every 30 s, never while the + // worker is mid-flush: this label rebuilds at 1 Hz on the UI task, and an + // open() here blocks on the FatFs volume lock behind a slow/failing ring + // write — that was a UI freeze you could trigger just by WATCHING this + // page while the store was sick. + static size_t s_chat_fsz_cached = 0; + static uint32_t s_chat_fsz_ms = 0; const uint32_t nowms = (uint32_t)millis(); + if (!s_hist_flush_busy && + (s_chat_fsz_ms == 0 || (uint32_t)(nowms - s_chat_fsz_ms) > 30000u)) { + s_chat_fsz_ms = nowms ? nowms : 1; + File f = uiDataOpen(k_ui_msgs_path, "r"); + if (f) { s_chat_fsz_cached = f.size(); f.close(); } + } + char stat[48]; if (s_msgs_write_fails) snprintf(stat, sizeof stat, "FAILING x%u (%lus ago)", (unsigned)s_msgs_write_fails, @@ -10068,7 +10079,7 @@ static void sysInfoTextLive(char* buf, size_t cap) { p += snprintf(buf + p, cap - p, "Chat store\n %s file: %u KB\n last save: %s\n\n", uiDataFsIsSdCard() ? "SD /meshcomod" : "Internal flash", - (unsigned)(fsz / 1024), stat); + (unsigned)(s_chat_fsz_cached / 1024), stat); } #endif (void)p; (void)cap; @@ -40925,6 +40936,18 @@ static uint16_t s_hist_snap_count = 0; static uint16_t s_hist_snap_head = 0; static uint32_t s_hist_snap_msgcount = 0; +// Arm an immediate flush of both history files WITHOUT the min-delay clamp and +// WITHOUT blocking this task: the actual write still happens on the core-0 +// worker (or the sync fallback inside flushHistoryIfDue). Used right after an +// SD remount — a synchronous persistHistoryNow() there froze the UI for >30 s +// on a card that mounts fine but times out on large writes. +void UITask::flushHistorySoon() { + _msgs_dirty = true; + _next_msgs_flush_ms = millis(); + _threads_dirty = true; + _next_threads_flush_ms = millis(); +} + void UITask::flushHistoryIfDue(unsigned long now) { // A worker write failed (storage hiccup): re-arm and try again. Surface // repeated failures — a persistently failing ring write was previously @@ -40933,7 +40956,12 @@ void UITask::flushHistoryIfDue(unsigned long now) { // list still showed fresh last-message times). if (!s_hist_flush_ok) { s_hist_flush_ok = true; - markMsgsDirty(5000); + // Back off a hopelessly failing store: each retry against a card that + // times out on large writes costs seconds of bus stalls (felt as UI + // freezes through the FatFs volume lock). After 5 straight failures + // retry every 5 min instead of every 30 s — the RAM ring keeps + // everything meanwhile, and a successful write resets the counter. + markMsgsDirty(s_msgs_write_fails >= 5 ? 300000 : 5000); if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) showAlert(TR("Chat history is NOT saving (storage error)"), 2600); } @@ -44229,13 +44257,14 @@ static void sdHealthTick() { markSdIo(); if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); #if defined(HAS_TDECK_GT911) - // Land the RAM ring on the card NOW, not up to 30+ s later: every message - // received while the card was out is only in RAM, and a hard power cut - // (power switch, reset, reflash) before the next lazy flush would drop - // the whole outage backlog. mkdir first — a fresh replacement card has no - // /meshcomod yet, and without it every history flush fails silently. + // Land the RAM ring on the card promptly, not up to 30+ s later: every + // message received while the card was out is only in RAM. Armed as an + // OFF-THREAD flush — a synchronous write here froze the UI for >30 s on + // a card that mounts fine but times out on large writes. mkdir first — + // a fresh replacement card has no /meshcomod yet, and without it every + // history flush fails silently. SD.mkdir("/meshcomod"); - if (g_lv.task) g_lv.task->persistHistoryNow(); + if (g_lv.task) g_lv.task->flushHistorySoon(); #endif } else { SD.end(); // leave cardType() honestly CARD_NONE @@ -44276,7 +44305,7 @@ static void sdHealthTick() { if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); #if defined(HAS_TDECK_GT911) SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root - if (g_lv.task) g_lv.task->persistHistoryNow(); // land the RAM ring before any hard power cut + if (g_lv.task) g_lv.task->flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif } else { // Ladder failed — card needs a real power cycle (reinsert). s_sd_mounted is @@ -44734,7 +44763,7 @@ void UITask::loop() { showAlert(TR("SD card inserted"), 1500); #if defined(HAS_TDECK_GT911) SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root - persistHistoryNow(); // land the RAM ring (outage backlog) before any hard power cut + flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index a838f77..83ea5c1 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -486,6 +486,7 @@ public: // lost — the periodic flush is off-thread and rate-capped. Overrides the // AbstractUITask hook so the companion CMD_REBOOT path flushes too. void persistHistoryNow() override; + void flushHistorySoon(); // arm an immediate OFF-THREAD flush (worker) — no min-delay clamp, no UI stall // from AbstractUITask void msgRead(int msgcount) override; From 99a76a4569129bfbdf6105611cfdb0e453b6c66f Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sat, 25 Jul 2026 00:15:58 +0200 Subject: [PATCH 09/99] touch: compact deleted messages out of the ring file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting messages tombstones them in place (thread[0]='\0'; the ring cannot memmove — slot indexes are live UI handles), but the writer emitted tombstones as full blank records: delete 1000+ messages and the file never shrank, and every flush kept rewriting the high-water size. The writer now skips tombstones (the file compacts to live records on the next flush), and the loader accepts a legitimately empty header-only file instead of quarantining it as corrupt. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index e4160eb..474e658 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41190,6 +41190,17 @@ bool UITask::loadMsgsFromStorage() { // lets a 500-slot file load into a 5000 ring (SD upgrade) and a 5000-slot file // shrink into a 500 ring (card pulled), instead of quarantining real history. const int file_slots = (int)(((size_t)f.size() - sizeof(hdr)) / disk_sz); + if (file_slots == 0 && hdr.ui_msg_count == 0) { + // Legitimately empty (every message was deleted, so the compacting writer + // emitted a header-only file) — NOT corruption. Load the empty ring; the + // old quarantine here deleted the file and made the boot fall through to + // stale fallbacks. + f.close(); + _ui_msg_count = 0; + _ui_msg_head = 0; + _msgcount = static_cast(hdr.msgcount); + return true; + } if (file_slots <= 0 || hdr.ui_msg_count > file_slots || hdr.ui_msg_head >= file_slots) { f.close(); uiDataRemove(k_ui_msgs_path); return false; // header disagrees with the file body @@ -41446,7 +41457,17 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, const int wcap = cap > 0 ? cap : 1; const int used = (int)count > cap ? cap : (int)count; const int oldst = (((int)head - used) % wcap + wcap) % wcap; - hdr.ui_msg_count = static_cast(used); + // Deleted messages are tombstones IN RAM (thread[0] == '\0'; the ring can't + // compact in place — slot indexes are live UI handles), but writing them out + // kept the file at its high-water size forever: delete 1000 messages and + // every flush still rewrote them all as full blank records. Skip them here — + // the file compacts to the LIVE records on the next flush, shrinking both + // the write time and the flash wear. Geometry is unaffected: records stay + // oldest-first with head=0 and count = live records actually emitted. + int live = 0; + for (int k = 0; k < used; ++k) + if (msgs[(oldst + k) % wcap].thread[0]) ++live; + hdr.ui_msg_count = static_cast(live); hdr.ui_msg_head = 0; // written oldest-first => the file's ring head sits at 0 hdr.msgcount = msgcount; if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { @@ -41462,6 +41483,7 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, size_t fill = 0; for (int k = 0; ok && k < used; ++k) { const int i = (oldst + k) % wcap; + if (!msgs[i].thread[0]) continue; // tombstone (deleted) — never hits the file UiHistoryMsg* m = reinterpret_cast(buf + fill); memset(m, 0, REC); m->ts = msgs[i].ts; @@ -41489,6 +41511,7 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, UiHistoryMsg m{}; for (int k = 0; ok && k < used; ++k) { const int i = (oldst + k) % wcap; + if (!msgs[i].thread[0]) continue; // tombstone (deleted) — never hits the file memset(&m, 0, sizeof(m)); m.ts = msgs[i].ts; m.channel = msgs[i].channel ? 1u : 0u; From f22c9decc5b4afe68f019d62216ba8affd6cd476 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sat, 25 Jul 2026 00:19:51 +0200 Subject: [PATCH 10/99] touch: raise SD max_files 3 -> 6; report chat-save failure stage + errno Field evidence reframed the chat-save failures: a soft reboot wrote the FULL 515 KB ring to the same card without trouble, while the periodic worker autosave could not even create the file. That rules out a size/bad-region card problem and points at contention: the SD VFS was mounted with max_files=3 (set when the card only served the file manager), but the card now serves telemetry log rewrites (2 handles), battery log rewrites (2), the history flush, tile reads/writes, WAV chimes and the About readout concurrently -- the flush's open("w") loses that race with ENFILE while the card itself is fine, and at reboot (everything quiesced) the same write sails through. All four mount sites now use max_files=6 (~+1.7 KB while mounted). To make the next such failure self-explaining on a device with no readable serial, the About Chat-store line now includes the failure stage and errno ('FAIL x3 @o e23'): o/h/b/r = open/header/body/rename; errno 23/24 = VFS file table full, 28 = card full, 5 = card I/O error. The header-write failure path also joins the failure accounting (it returned early without counting before) and cleans up its temp file. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/main.cpp | 2 +- src/ui-touch/UITask.cpp | 43 +++++++++++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6b481de..3cca636 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -520,7 +520,7 @@ void setup() { for (int a = 0; a < tries && !sd_mounted; ++a) { SD.end(); delay(kBootMount[a].settle_ms); - if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 3) && SD.cardType() != CARD_NONE) + if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE) sd_mounted = true; } } diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 474e658..2034497 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -18,6 +18,7 @@ #include #include #include +#include // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO) #if defined(ESP32) #include #include @@ -4559,6 +4560,8 @@ static bool uiHistWorkerFlush(); // defined with the storage cod static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success +static volatile uint8_t s_msgs_write_stage = 0; // where the last failure hit: 'o' open, 'h' header, 'b' body, 'r' rename +static volatile int s_msgs_write_errno = 0; // errno at that failure (ENFILE/EMFILE = VFS file table full, ENOSPC = card full, EIO = card error) static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists @@ -10068,8 +10071,12 @@ static void sysInfoTextLive(char* buf, size_t cap) { } char stat[48]; if (s_msgs_write_fails) - snprintf(stat, sizeof stat, "FAILING x%u (%lus ago)", + // @stage: o=open h=header b=body r=rename; errno: 23/24=file table full, + // 28=card full, 5=I/O error (card) — the triage key for save failures. + snprintf(stat, sizeof stat, "FAIL x%u @%c e%d (%lus ago)", (unsigned)s_msgs_write_fails, + s_msgs_write_stage ? (char)s_msgs_write_stage : '?', + (int)s_msgs_write_errno, (unsigned long)((nowms - s_msgs_write_fail_ms) / 1000u)); else if (s_msgs_write_ok_ms) snprintf(stat, sizeof stat, "OK %lus ago", @@ -18124,8 +18131,11 @@ static bool fmSdTryMount() { // mounts on the first attempt and returns immediately; only a flaky/cold card // walks the whole ladder (~1.1 s worst case). The cumulative delay can // approach the loop watchdog window, so drop the WDT around the loop. 4 MHz - // is reliable on the shared LoRa bus; max_files=3 keeps the VFS footprint - // small (dir + at most 2 files). + // is reliable on the shared LoRa bus. max_files=6 (was 3): the card now + // serves telemetry log rewrites (2 handles), battery log rewrites (2), + // the history flush, tile reads/writes, WAV chimes and the About readout + // CONCURRENTLY — with only 3 VFS slots the history flush's open("w") lost + // that race and chat saves failed with ENFILE while the card was fine. // Ladder of (settle, clock) attempts. The first three use the fast 4 MHz the // shared LoRa bus handles well; a cold / cheap card that won't wake at 4 MHz // then gets progressively longer settles AND a lower clock (1 MHz, then @@ -18150,7 +18160,7 @@ static bool fmSdTryMount() { for (int attempt = 0; attempt < kAttempts; ++attempt) { SD.end(); delay(kMountLadder[attempt].settle_ms); - if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 3) && SD.cardType() != CARD_NONE) { + if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { mounted = true; break; } @@ -41435,14 +41445,24 @@ static bool uiMsgsWriteResult(bool ok) { } return ok; } +// Failure wrapper: record WHERE the write died and the errno — 'open failed +// with ENFILE' (VFS file-handle table full) needs a completely different fix +// than 'body write failed with EIO' (card), and without this they were +// indistinguishable on a device with no readable serial. +static bool uiMsgsWriteFail(char stage) { + s_msgs_write_stage = (uint8_t)stage; + s_msgs_write_errno = errno; + return uiMsgsWriteResult(false); +} static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, uint16_t count, uint16_t head, uint32_t msgcount, const char* tmp_path) { #if defined(ESP32) WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC + errno = 0; File f = uiDataOpen(tmp_path, "w"); - if (!f) return uiMsgsWriteResult(false); + if (!f) return uiMsgsWriteFail('o'); UiMsgFileHeader hdr{}; hdr.magic = k_ui_msgs_magic; @@ -41471,7 +41491,8 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, hdr.ui_msg_head = 0; // written oldest-first => the file's ring head sits at 0 hdr.msgcount = msgcount; if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { - f.close(); return false; + f.close(); uiDataRemove(tmp_path); + return uiMsgsWriteFail('h'); // was silently un-counted before } const size_t REC = sizeof(UiHistoryMsg); @@ -41530,8 +41551,10 @@ static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, } } f.close(); - if (!ok) { uiDataRemove(tmp_path); return uiMsgsWriteResult(false); } - return uiMsgsWriteResult(uiDataReplaceFile(k_ui_msgs_path, tmp_path)); + if (!ok) { uiDataRemove(tmp_path); return uiMsgsWriteFail('b'); } + errno = 0; + if (!uiDataReplaceFile(k_ui_msgs_path, tmp_path)) return uiMsgsWriteFail('r'); + return uiMsgsWriteResult(true); #else (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; (void)tmp_path; return false; @@ -44273,7 +44296,7 @@ static void sdHealthTick() { SPIClass* spi = sdSharedSPI(); if (!spi) return; SD.end(); - if (SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE) { + if (SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE) { s_sd_mounted = true; s_sd_size = SD.cardSize(); s_sd_retry_after_ms = 0; @@ -45281,7 +45304,7 @@ void UITask::loop() { } sdcard_uninit(pdrv); } - if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 3); + if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); } enableLoopWDT(); fmHideFormatOverlay(); From 75883a62a2c5f0d8989ab7ee72c344d3fc9c5652 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sat, 25 Jul 2026 00:30:58 +0200 Subject: [PATCH 11/99] touch: spell out the chat-save failure in alerts and the About readout Replaces the '@o e23' shorthand with a human-readable diagnosis (chatSaveFailText): stage (open/header/write/rename) plus a plain-text errno ('too many open files', 'card full', 'card I/O error', ..., strerror fallback), e.g. 'open failed: too many open files (e23)'. Shown as a second line on the About Chat-store section, on the repeated autosave-failure alert, and on the reboot-time last-chance-save alert. Diagnostic vocabulary stays English on purpose -- it is what ends up in bug reports verbatim. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 62 ++++++++++++++++++++++++++++++++--------- src/ui-touch/i18n.cpp | 2 +- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2034497..7ec9731 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4562,6 +4562,34 @@ static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed r static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success static volatile uint8_t s_msgs_write_stage = 0; // where the last failure hit: 'o' open, 'h' header, 'b' body, 'r' rename static volatile int s_msgs_write_errno = 0; // errno at that failure (ENFILE/EMFILE = VFS file table full, ENOSPC = card full, EIO = card error) +// Human-readable diagnosis of the last chat-store write failure — shown on the +// About panel and in the failure alerts, so a tester doesn't need an errno +// table. Diagnostic vocabulary stays English on purpose (it's what ends up in +// bug reports verbatim). +static void chatSaveFailText(char* out, size_t cap) { + const char* stage; + switch ((char)s_msgs_write_stage) { + case 'o': stage = "open"; break; + case 'h': stage = "header"; break; + case 'b': stage = "write"; break; + case 'r': stage = "rename"; break; + default: stage = "save"; break; + } + const int e = s_msgs_write_errno; + const char* why; + switch (e) { + case ENFILE: + case EMFILE: why = "too many open files"; break; + case ENOSPC: why = "card full"; break; + case EIO: why = "card I/O error"; break; + case ENOENT: why = "folder missing"; break; + case EROFS: why = "write-protected"; break; + case EACCES: why = "access denied"; break; + case 0: why = "no errno"; break; + default: why = strerror(e); break; // newlib carries the full table + } + snprintf(out, cap, "%s failed: %s (e%d)", stage, why, e); +} static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists @@ -10069,15 +10097,15 @@ static void sysInfoTextLive(char* buf, size_t cap) { File f = uiDataOpen(k_ui_msgs_path, "r"); if (f) { s_chat_fsz_cached = f.size(); f.close(); } } - char stat[48]; - if (s_msgs_write_fails) - // @stage: o=open h=header b=body r=rename; errno: 23/24=file table full, - // 28=card full, 5=I/O error (card) — the triage key for save failures. - snprintf(stat, sizeof stat, "FAIL x%u @%c e%d (%lus ago)", + char stat[112]; + if (s_msgs_write_fails) { + char why[64]; + chatSaveFailText(why, sizeof why); + snprintf(stat, sizeof stat, "FAIL x%u (%lus ago)\n %s", (unsigned)s_msgs_write_fails, - s_msgs_write_stage ? (char)s_msgs_write_stage : '?', - (int)s_msgs_write_errno, - (unsigned long)((nowms - s_msgs_write_fail_ms) / 1000u)); + (unsigned long)((nowms - s_msgs_write_fail_ms) / 1000u), + why); + } else if (s_msgs_write_ok_ms) snprintf(stat, sizeof stat, "OK %lus ago", (unsigned long)((nowms - s_msgs_write_ok_ms) / 1000u)); @@ -40972,8 +41000,12 @@ void UITask::flushHistoryIfDue(unsigned long now) { // retry every 5 min instead of every 30 s — the RAM ring keeps // everything meanwhile, and a successful write resets the counter. markMsgsDirty(s_msgs_write_fails >= 5 ? 300000 : 5000); - if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) - showAlert(TR("Chat history is NOT saving (storage error)"), 2600); + if (s_msgs_write_fails >= 3 && (s_msgs_write_fails % 3) == 0) { + char why[64]; chatSaveFailText(why, sizeof why); + char msg[128]; + snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history is NOT saving"), why); + showAlert(msg, 3200); + } } // Thread metadata (~4 KB) flushes on a short delay; the message ring // (scales with MAX_UI_MESSAGES) flushes lazily to reduce flash write pressure. @@ -43959,10 +43991,14 @@ void UITask::rebootDevice() { if (uiHistWaitWorkerIdle() || s_hist_flush_busy) _msgs_dirty = true; if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty && !saveMsgsToStorage()) { - // Last-chance save failed — say so instead of rebooting into silent loss. - showAlert(TR("Chat history save FAILED"), 1500); + // Last-chance save failed — say so (with the diagnosis) instead of + // rebooting into silent loss. + char why[64]; chatSaveFailText(why, sizeof why); + char msg[128]; + snprintf(msg, sizeof msg, "%s\n%s", TR("Chat history save FAILED"), why); + showAlert(msg, 1800); lv_refr_now(NULL); - delay(1200); // let the warning actually paint before the reset + delay(1500); // let the warning actually paint before the reset } discoveredFlushNow(); // persist the Discovered ring before we go down the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices) diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index 413f9a9..57f194c 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -132,7 +132,7 @@ static const I18nEntry kI18n[] = { { "SD card removed", { 0, "SD-kaart verwijderd","SD-Karte entfernt","Carte SD retirée","Tarjeta SD retirada","Scheda SD rimossa","SD-карта извлечена","SD-картку вилучено","SD картата е извадена","SD картица уклоњена","Η κάρτα SD αφαιρέθηκε" ,"Cartão SD removido"} }, { "SD card remounted", { 0, "SD-kaart opnieuw gekoppeld","SD-Karte neu eingebunden","Carte SD remontée","Tarjeta SD remontada","Scheda SD rimontata","SD-карта перемонтирована","SD-картку перемонтовано","SD картата е монтирана отново","SD картица поново монтирана","Η κάρτα SD επανασυνδέθηκε" ,"Cartão SD remontado"} }, { "SD card lost - reinsert to recover", { 0, "SD-kaart verloren - plaats opnieuw","SD-Karte verloren - neu einstecken","Carte SD perdue - réinsérez-la","Tarjeta SD perdida - reinsértala","Scheda SD persa - reinserirla","SD-карта потеряна - переставьте её","SD-картку втрачено - вставте знову","SD картата е изгубена - поставете я отново","SD картица изгубљена - убаците поново","Η κάρτα SD χάθηκε - επανατοποθετήστε" ,"Cartão SD perdido - reinsira-o"} }, - { "Chat history is NOT saving (storage error)", { 0, "Chatgeschiedenis wordt NIET opgeslagen (opslagfout)","Chatverlauf wird NICHT gespeichert (Speicherfehler)","L'historique du chat n'est PAS enregistré (erreur de stockage)","El historial de chat NO se guarda (error de almacenamiento)","La cronologia chat NON viene salvata (errore di archiviazione)","История чата НЕ сохраняется (ошибка накопителя)","Історія чату НЕ зберігається (помилка сховища)","Историята на чата НЕ се записва (грешка в паметта)","Историја ћаскања се НЕ чува (грешка складишта)","Το ιστορικό συνομιλίας ΔΕΝ αποθηκεύεται (σφάλμα αποθήκευσης)" ,"Histórico do chat NÃO está sendo salvo (erro de armazenamento)"} }, + { "Chat history is NOT saving", { 0, "Chatgeschiedenis wordt NIET opgeslagen","Chatverlauf wird NICHT gespeichert","L'historique du chat n'est PAS enregistré","El historial de chat NO se guarda","La cronologia chat NON viene salvata","История чата НЕ сохраняется","Історія чату НЕ зберігається","Историята на чата НЕ се записва","Историја ћаскања се НЕ чува","Το ιστορικό συνομιλίας ΔΕΝ αποθηκεύεται" ,"Histórico do chat NÃO está sendo salvo"} }, { "Chat history save FAILED", { 0, "Opslaan chatgeschiedenis MISLUKT","Speichern des Chatverlaufs FEHLGESCHLAGEN","Échec de l'enregistrement de l'historique","FALLÓ el guardado del historial de chat","Salvataggio cronologia chat FALLITO","НЕ УДАЛОСЬ сохранить историю чата","НЕ ВДАЛОСЯ зберегти історію чату","Записът на историята на чата СЕ ПРОВАЛИ","Чување историје ћаскања НИЈЕ УСПЕЛО","Η αποθήκευση του ιστορικού ΑΠΕΤΥΧΕ" ,"FALHA ao salvar o histórico do chat"} }, // ---- Common field labels / status words ---- From 16f18a6a11c1abf6421df85b29ae2c0e270e7caf Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sat, 25 Jul 2026 00:44:05 +0200 Subject: [PATCH 12/99] touch: run the chat-history flush on core 1; never unmount under a live flush Two failure signatures from on-device testing, both now explained and closed: - EIO ('card I/O error') on the periodic autosave while the identical full-ring write succeeded from the reboot path: the flush rode the core-0 tile_fetch worker -- the Wi-Fi core -- where the driver's prio-23 tasks starve a prio-1 task for hundreds of ms. sd_diskio's busy-waits measure WALL time, so timeouts expired while the task simply wasn't scheduled, and healthy writes surfaced as EIO (intermittently: the write sometimes won the timing, matching the 'succeeded after a few auto tries' observation). The flush now runs on its own small task pinned to core 1 at the loop task's priority -- equal-priority time-slicing keeps the UI serviced during a write, and chat persistence has no Wi-Fi affinity to lose. - EBADF ('bad file number') after a shrink: FatFs FR_INVALID_OBJECT -- a handle whose volume was remounted underneath it. The file-manager removal poll could probe + SD.end() while the flush held its open temp file. All unmount paths now hold off under an in-flight flush (the FM poll joins the sdHealthTick guard). Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 83 ++++++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 7ec9731..c90be3c 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -24148,16 +24148,11 @@ static void tileFetchTaskFn(void* arg) { s_los_result_ready = true; continue; } - // Chat-history flush: write the loop thread's snapshot. busy is raised BEFORE - // req is cleared so the loop-side gate (busy || req) never sees a gap in which - // it could overwrite the snapshot mid-write. - if (s_hist_flush_req) { - s_hist_flush_busy = true; - s_hist_flush_req = false; - s_hist_flush_ok = uiHistWorkerFlush(); - s_hist_flush_busy = false; - continue; - } + // (Chat-history flush moved to its own core-1 task — histFlushTaskFn. On + // THIS worker it shared core 0 with the Wi-Fi driver, whose prio-23 tasks + // starve a prio-1 task for hundreds of ms; sd_diskio's busy-waits measure + // wall time, so healthy SD writes surfaced as timeouts/EIO here while the + // identical write from core 1 succeeded.) // Firmware update check (one-shot, infrequent). Reuses this worker's stack. if (s_verchk_request) { s_verchk_request = false; @@ -24524,6 +24519,54 @@ static bool ensureTileFetchTaskRunning() { return s_tile_fetch_task != nullptr; } +// ---- Chat-history flush worker (core 1) -------------------------------------- +// The flush used to ride the core-0 tile_fetch worker — the Wi-Fi core. Under +// Wi-Fi load its prio-23 driver tasks starve a prio-1 task for hundreds of ms +// at a time, and sd_diskio's busy-waits measure WALL time: the timeout expires +// while the task simply wasn't scheduled, so perfectly good SD writes surface +// as EIO ("card I/O error") — while the identical write from the core-1 loop +// task (the reboot-time persist) succeeds every time. Chat persistence has no +// Wi-Fi affinity, so it gets its own tiny task pinned to core 1 at the loop +// task's priority (equal-priority time-slicing keeps the UI serviced during a +// write). The task is persistent; the 100 ms poll of the volatile req flag +// costs nothing measurable and mirrors the old worker's pickup latency. +static TaskHandle_t s_hist_flush_task = nullptr; +static StaticTask_t s_hist_flush_tcb; +static StackType_t* s_hist_flush_stack = nullptr; +static size_t s_hist_flush_stack_bytes = 0; +static void histFlushTaskFn(void*) { + for (;;) { + // busy is raised BEFORE req is cleared so the loop-side gate (busy || req) + // never sees a gap in which it could overwrite the snapshot mid-write. + if (s_hist_flush_req) { + s_hist_flush_busy = true; + s_hist_flush_req = false; + s_hist_flush_ok = uiHistWorkerFlush(); + s_hist_flush_busy = false; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } +} +static bool ensureHistFlushTaskRunning() { + if (s_hist_flush_task != nullptr) return true; + if (!s_hist_flush_stack) { + // uiWriteMsgsFile keeps its bulk buffer on the heap; stack use is File + // objects + FatFs path work. 6 KB leaves ample headroom (the tile worker's + // overflow-into-globals history earned the caution). + static const size_t k_sz = 6 * 1024; + s_hist_flush_stack = (StackType_t*)heap_caps_malloc(k_sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!s_hist_flush_stack) return false; + s_hist_flush_stack_bytes = k_sz; + } + s_hist_flush_task = xTaskCreateStaticPinnedToCore( + histFlushTaskFn, "hist_flush", + s_hist_flush_stack_bytes / sizeof(StackType_t), + nullptr, 1, + s_hist_flush_stack, &s_hist_flush_tcb, + 1 /*core 1 — NOT the Wi-Fi core; see block comment*/); + return s_hist_flush_task != nullptr; +} + // Queue a missing tile for download. No-op when Wi-Fi is down or the // tile was queued recently. Called from renderMapTiles after a SPIFFS // miss; the actual fetch happens off-thread. @@ -41019,16 +41062,16 @@ void UITask::flushHistoryIfDue(unsigned long now) { } if (_msgs_dirty && now >= _next_msgs_flush_ms) { if (s_hist_flush_busy || s_hist_flush_req) { - // The worker only exists while the tile-fetch task is spawned (map / Wi-Fi - // scan / LOS / …). Without this kick a pending flush can sit armed forever - // while ui_threads_v1.bin keeps updating — messages live only in RAM. - ensureTileFetchTaskRunning(); + // A request is armed but the flush task may not be up yet — kick it so a + // pending flush can't sit armed forever while messages live only in RAM. + ensureHistFlushTaskRunning(); _next_msgs_flush_ms = now + 1000; // one flush in flight; new messages ride the next one return; } #if defined(ESP32) // Snapshot the ring (a few ms of PSRAM memcpy) and hand the write to the - // core-0 worker. Messages arriving while the worker writes stay in the live + // core-1 hist_flush task (NOT the core-0/Wi-Fi tile worker — see + // histFlushTaskFn). Messages arriving while it writes stay in the live // ring and re-arm the dirty flag, so they land in the next flush. if (!s_hist_snap || s_hist_snap_cap != _ui_msg_cap) { if (s_hist_snap) { heap_caps_free(s_hist_snap); s_hist_snap = nullptr; } @@ -41041,7 +41084,7 @@ void UITask::flushHistoryIfDue(unsigned long now) { s_hist_snap_count = (uint16_t)_ui_msg_count; s_hist_snap_head = (uint16_t)_ui_msg_head; s_hist_snap_msgcount = (uint32_t)_msgcount; - if (!ensureTileFetchTaskRunning()) { + if (!ensureHistFlushTaskRunning()) { // No worker — fall back to a synchronous write instead of clearing dirty // and losing the snapshot (the whole point of the worker is to stay off // the loop thread; a rare spawn failure must not drop hours of chat). @@ -41049,7 +41092,7 @@ void UITask::flushHistoryIfDue(unsigned long now) { else _next_msgs_flush_ms = now + 2000; return; } - s_hist_flush_req = true; // worker picks it up + s_hist_flush_req = true; // the flush task picks it up _msgs_dirty = false; return; } @@ -44849,10 +44892,14 @@ void UITask::loop() { #endif if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } - } else if (!sdProbeAlive()) { + } else if (!s_hist_flush_busy && !s_hist_flush_req && !sdProbeAlive()) { // Real-I/O probe, NOT `SD.cardType() == CARD_NONE`: cardType() is cached // at mount and only SD.end() ever resets it, so the old check could never // fire — neither a yanked card nor a wedged one was ever detected here. + // Never probe/unmount under an in-flight history flush: SD.end() while + // the flush holds its open temp file leaves a stale FIL, and every + // later write on it fails FR_INVALID_OBJECT -> EBADF ("bad file + // number") — recovery must not sabotage the very write it protects. fmSdUnmount(); showAlert(TR("SD card removed"), 1500); if (s_fm_fs == &SD || !s_fm_fs) fmShowRoots(); From 47ba9e010cbcc0fe972ac403d69c09c9029e2cb1 Mon Sep 17 00:00:00 2001 From: Yaz Date: Sat, 25 Jul 2026 11:29:56 +0000 Subject: [PATCH 13/99] ui-touch: invalidate LVGL img cache before freeing lock wallpaper buf s_lock_wall_dsc is a static lv_img_dsc_t (stable address). LVGL's image cache is keyed by that src pointer, not by dsc->data, so re-decoding a custom lockscreen wallpaper into a fresh PSRAM buffer without first invalidating the cache entry can leave LVGL drawing from a stale, already-freed pointer on the next lockscreenShow(). The freed PSRAM is commonly reclaimed soon after by the map's 128 KB tile buffers, so the stale draw shows up as colorful RGB565 noise instead of the wallpaper -- exactly the symptom in #127. Only reboot 'fixed' it because the stale LVGL cache entry otherwise persists indefinitely. Same bug class already fixed for map tiles in freeMapTileSlot() (see its comment); this applies the same lv_img_cache_invalidate_src() call to the two places that free s_lock_wall: lockscreenShow() (re-decode) and lockscreenHide() (teardown). Fixes #127 --- src/ui-touch/UITask.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..5e21995 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -32931,7 +32931,18 @@ static void lockscreenShow() { // Wallpaper, scaled to cover the screen (crop overflow, never letterbox). int ww = 0, wh = 0; const uint8_t* wall_data = nullptr; - if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } + if (s_lock_wall) { + // Invalidate LVGL's image cache entry for s_lock_wall_dsc BEFORE freeing the + // buffer it points at. s_lock_wall_dsc is a static var (stable address), and + // LVGL's img cache is keyed by that src pointer -- without this, the next + // lockscreenShow() can hit a stale cache entry pointing at freed PSRAM that + // has since been reused elsewhere (e.g. the map's 128 KB tile buffers), + // rendering as RGB565 noise on wake. Same bug class already fixed for map + // tiles in freeMapTileSlot() -- see the comment there (issue #127). + lv_img_cache_invalidate_src(&s_lock_wall_dsc); + lvglPsramFree(s_lock_wall); + s_lock_wall = nullptr; + } char wpath[TOUCH_LOCK_WALLPAPER_MAXLEN]; touchPrefsGetLockWallpaper(wpath, sizeof wpath); if (!strcmp(wpath, "/lock/placeholder.jpg")) { @@ -33075,7 +33086,7 @@ static void lockscreenHide() { s_lock_status = nullptr; s_lock_hint = nullptr; #endif } - if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } + if (s_lock_wall) { lv_img_cache_invalidate_src(&s_lock_wall_dsc); lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; } s_lock_clock_min = -1; s_lock_unread_n = -1; // Restore the status bar's normal opaque background + accent border. From 8dc02a2b2352d728cd941ef1127a360dc8e4184f Mon Sep 17 00:00:00 2001 From: Steve Glenner Date: Mon, 6 Jul 2026 20:34:42 -0700 Subject: [PATCH 14/99] Move message timestamp to top of bubble to fit more messages on screen --- src/ui-touch/UITask.cpp | 206 ++++++++++++++++++++++++++++------------ 1 file changed, 147 insertions(+), 59 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..4e94790 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -29560,33 +29560,26 @@ static void chatVirtCheckStoreEdges(LvChatPanel* p) { } #endif -static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, - bool thread_is_room, lv_coord_t bubble_max_w) { - ChatBubbleDisplay d{}; - chatParseMessageDisplay(m, channel_mode, thread_is_room, d); - const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH; - lv_coord_t inner_y = 0; - if ((channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]) - inner_y += lv_font_get_line_height(&g_font_12); - - lv_point_t txt_size; - lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w; - lv_point_t wrapped_size; - lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, - txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); +static void chatBuildBubbleMeta(const UITask::UIMessage& m, bool channel_mode, + char* out, size_t out_len, uint32_t* out_fg) { + if (out && out_len > 0) out[0] = '\0'; + if (out_fg) *out_fg = COLOR_SUB; + if (!out || out_len == 0) return; char ts_buf[20]; formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf)); + const char* deliv_glyph = ""; + uint32_t deliv_fg = COLOR_SUB; if (m.outgoing && !channel_mode && m.deliv_state != UITask::DELIV_NONE) { switch (m.deliv_state) { - case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; break; - case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; break; - case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE; break; + case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break; + case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break; + case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break; default: break; } } + char rep_buf[12] = ""; if (m.outgoing && m.sent_fp) { const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp); @@ -29597,13 +29590,71 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); } + snprintf(out, out_len, "%s%s%s", ts_buf, deliv_glyph, rep_buf); + if (out_fg) *out_fg = deliv_glyph[0] ? deliv_fg : COLOR_SUB; +} + +static lv_coord_t chatTextWidth(const char* s) { + if (!s || !s[0]) return 0; + lv_point_t size; + lv_txt_get_size(&size, s, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return size.x; +} + +static bool chatUtf8Continuation(char c) { + return (static_cast(c) & 0xC0) == 0x80; +} + +static void chatFitLeadingEllipsis(const char* src, lv_coord_t max_w, char* out, size_t out_len) { + if (!out || out_len == 0) return; + out[0] = '\0'; + if (!src || !src[0] || max_w <= 0) return; + if (chatTextWidth(src) <= max_w) { + snprintf(out, out_len, "%s", src); + return; + } + + const char* ell = "..."; + if (chatTextWidth(ell) > max_w) return; + + const size_t len = strlen(src); + for (size_t i = 0; i < len; ++i) { + if (chatUtf8Continuation(src[i])) continue; + char cand[48]; + snprintf(cand, sizeof(cand), "%s%s", ell, src + i); + if (chatTextWidth(cand) <= max_w) { + snprintf(out, out_len, "%s", cand); + return; + } + } + snprintf(out, out_len, "%s", ell); +} + +static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode, + bool thread_is_room, lv_coord_t bubble_max_w) { + ChatBubbleDisplay d{}; + chatParseMessageDisplay(m, channel_mode, thread_is_room, d); + const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH; + lv_coord_t inner_y = 0; + char meta_buf[48]; + chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); + if (channel_mode) { + if ((!m.outgoing && d.san_sender[0]) || meta_buf[0]) + inner_y += lv_font_get_line_height(&g_font_12); + } else if (thread_is_room && !m.outgoing && d.san_sender[0]) { + inner_y += lv_font_get_line_height(&g_font_12); + } + + lv_point_t txt_size; + lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w; + lv_point_t wrapped_size; + lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, + txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_coord_t body_h = inner_y + wrapped_size.y; - if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) { - char footer[40]; - snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf); - lv_point_t foot_size; - lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - body_h += 1 + foot_size.y; + if (!channel_mode && meta_buf[0]) { + body_h += 1 + lv_font_get_line_height(&g_font_12); } return kChatBubblePadV * 2 + body_h; } @@ -30314,17 +30365,80 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_set_style_pad_ver(bubble, kChatBubblePadV, LV_PART_MAIN); lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH; + lv_point_t txt_size; + lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW; + + char meta_buf[48]; + uint32_t meta_fg = COLOR_SUB; + chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); + const bool meta_on_top = p->channel_mode; + const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && + !m.outgoing && d.san_sender[0]; + + const lv_coord_t sender_w = show_sender_line ? chatTextWidth(d.san_sender) : 0; + const lv_coord_t meta_w = meta_buf[0] ? chatTextWidth(meta_buf) : 0; + lv_coord_t inner_w = txt_w_used > 0 ? txt_w_used : 1; + if (meta_on_top) { + lv_coord_t header_w = (show_sender_line ? sender_w : 0) + meta_w; + if (show_sender_line && meta_w) header_w += 6; + if (header_w > kInnerMaxW) header_w = kInnerMaxW; + if (header_w > inner_w) inner_w = header_w; + } else if (show_sender_line && sender_w > inner_w) { + inner_w = sender_w > kInnerMaxW ? kInnerMaxW : sender_w; + } + int inner_y = 0; - lv_coord_t inner_w = 0; // widest child (sender/text/footer) -> analytic bubble width for x-alignment - if ((p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]) { + // Analytic bubble width for x-alignment (widest of sender/text/meta) — do not use + // lv_obj_get_width() right after create; unsettled layout can mis-place outgoing bubbles. + if (meta_on_top && (show_sender_line || meta_buf[0])) { + const lv_coord_t line_h = lv_font_get_line_height(&g_font_12); + char meta_fit[48] = ""; + lv_coord_t meta_fit_w = 0; + lv_coord_t sender_label_w = sender_w; + + if (meta_buf[0]) { + lv_coord_t available_meta_w = inner_w - (show_sender_line ? (sender_w + 6) : 0); + const lv_coord_t ell_w = chatTextWidth("..."); + if (show_sender_line && available_meta_w < ell_w && inner_w > ell_w + 6) { + sender_label_w = inner_w - ell_w - 6; + available_meta_w = ell_w; + } + chatFitLeadingEllipsis(meta_buf, available_meta_w, meta_fit, sizeof(meta_fit)); + meta_fit_w = chatTextWidth(meta_fit); + } + + if (show_sender_line && sender_label_w > 0) { + lv_obj_t* slbl = lv_label_create(bubble); + lv_label_set_text(slbl, d.san_sender); + lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); + if (sender_label_w < sender_w) { + lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(slbl, sender_label_w); + } + lv_obj_set_pos(slbl, 0, inner_y); + } + if (meta_fit[0]) { + lv_obj_t* mlbl = lv_label_create(bubble); + lv_label_set_text(mlbl, meta_fit); + lv_obj_set_style_text_font(mlbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(mlbl, lv_color_hex(meta_fg), LV_PART_MAIN); + lv_obj_set_pos(mlbl, inner_w - meta_fit_w, inner_y); + } + inner_y += line_h; + } else if (show_sender_line) { lv_obj_t* slbl = lv_label_create(bubble); lv_label_set_text(slbl, d.san_sender); lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); + if (inner_w < sender_w) { + lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(slbl, inner_w); + } lv_obj_set_pos(slbl, 0, inner_y); inner_y += lv_font_get_line_height(&g_font_12); - lv_point_t ss; lv_txt_get_size(&ss, d.san_sender, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - if (ss.x > inner_w) inner_w = ss.x; } lv_obj_t* tlbl = lv_label_create(bubble); @@ -30338,10 +30452,6 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i char rc[UITask::MAX_MSG_TEXT + 40]; if (chatRecolorUrls(d.san_text, rc, sizeof rc)) { lv_label_set_recolor(tlbl, true); lv_label_set_text(tlbl, rc); } } - const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH; - lv_point_t txt_size; - lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW; if (txt_size.x > kInnerMaxW) lv_label_set_long_mode(tlbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(tlbl, txt_w_used); if (txt_w_used > inner_w) inner_w = txt_w_used; @@ -30361,39 +30471,17 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_add_event_cb(bubble, bubbleRetryTapCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(ring_idx))); - char ts_buf[20]; - formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf)); - const char* deliv_glyph = ""; - uint32_t deliv_fg = COLOR_SUB; - if (m.outgoing && !p->channel_mode && m.deliv_state != UITask::DELIV_NONE) { - switch (m.deliv_state) { - case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break; - case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break; - case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break; - default: break; - } - } - char rep_buf[12] = ""; - if (m.outgoing && m.sent_fp) { - const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp); - if (reps > 0) snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_REFRESH "%u", (unsigned)reps); - } else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX) - && (m.meta_flags & UITask::MSG_META_IS_FLOOD)) { - const uint8_t hops = static_cast(m.path_len & 0x3F); - snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops); - } - if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) { - char footer[40]; - snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf); + // Channel bubbles put timestamp/meta on the top row; DMs/rooms keep it as a footer. + if (!meta_on_top && meta_buf[0]) { lv_obj_t* foot = lv_label_create(bubble); - lv_label_set_text(foot, footer); + lv_label_set_text(foot, meta_buf); lv_obj_set_style_text_font(foot, &g_font_12, LV_PART_MAIN); - lv_obj_set_style_text_color(foot, lv_color_hex(deliv_glyph[0] ? deliv_fg : COLOR_SUB), LV_PART_MAIN); + lv_obj_set_style_text_color(foot, lv_color_hex(meta_fg), LV_PART_MAIN); lv_point_t wrapped_size; lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); lv_point_t foot_size; - lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + lv_txt_get_size(&foot_size, meta_buf, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); if (foot_size.x > inner_w) inner_w = foot_size.x; const int foot_x = (txt_w_used > foot_size.x) ? (txt_w_used - foot_size.x) : 0; const int foot_y = inner_y + wrapped_size.y + 1; From 31cb4c38a88e865b695d6328b2c72d9efd169b59 Mon Sep 17 00:00:00 2001 From: Steve Glenner Date: Sat, 25 Jul 2026 16:35:40 -0700 Subject: [PATCH 15/99] touch: add Display Settings toggle for channel timestamp on top Make the shorter channel-bubble layout optional via a persisted "Timestamp on top" switch (default ON), so classic footer layout remains available. Co-authored-by: Cursor --- src/helpers/esp32/TouchPrefsStore.cpp | 17 +++++++++++- src/helpers/esp32/TouchPrefsStore.h | 5 ++++ src/ui-touch/UITask.cpp | 39 ++++++++++++++++++++++++--- src/ui-touch/i18n.cpp | 2 ++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index c2952b9..25238ed 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF) +static const uint8_t TOUCH_CFG_VER = 40; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 ts_on_top (channel bubble timestamp on the sender row; default ON) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -111,6 +111,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing) uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) + uint8_t ts_on_top; // channel bubbles: put timestamp on the sender row (bool, 1=on) — v40 (trailing) }; static TouchCfg s_cfg; @@ -207,6 +208,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC) c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer) + c.ts_on_top = 1; // ON: channel bubble timestamp shares the sender row (shorter bubbles) } // Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) @@ -278,6 +280,7 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle) if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in) if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default + if (s_cfg.ver < 40) s_cfg.ts_on_top = 1; // new trailing field: channel timestamp-on-top ON by default s_cfg.ver = TOUCH_CFG_VER; s_cfg.magic = TOUCH_CFG_MAGIC; cfgFlush(); // rewrite with new fields defaulted-in @@ -1091,6 +1094,18 @@ bool touchPrefsSetCompactChat(bool on) { return cfgFlush(); } +// Channel bubble timestamp placement: ON = share the sender row (shorter bubbles); +// OFF = footer under the body (classic layout). Default ON. +bool touchPrefsGetTimestampOnTop() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.ts_on_top != 0; +} +bool touchPrefsSetTimestampOnTop(bool on) { + if (!s_begun) touchPrefsBegin(); + s_cfg.ts_on_top = on ? 1 : 0; + return cfgFlush(); +} + // Monotonic send-timestamp floor (ClockFloorRTC, issue #89). Written rate-capped // from UITask::loop + on shutdown; only ever grows between resets. uint32_t touchPrefsGetClockFloor() { diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 8cd41eb..fb506b3 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -178,6 +178,11 @@ bool touchPrefsSetBootAdvert(bool on); bool touchPrefsGetCompactChat(); bool touchPrefsSetCompactChat(bool on); +/* Channel bubble timestamp placement: true = timestamp on the sender row (shorter + * bubbles); false = classic footer under the body. Default true. */ +bool touchPrefsGetTimestampOnTop(); +bool touchPrefsSetTimestampOnTop(bool on); + /* Buffered LoRa receive (experimental): a high-priority drain task lifts each packet * out of the radio within ~1 ms so UI-thread stalls can't overwrite unread packets * (the missed-messages class). Opt-in, default off = stock receive path. */ diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 4e94790..5fddd79 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -10513,6 +10513,18 @@ static void compactChatToggleCb(lv_event_t* e) { if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); } +// Channel bubble timestamp placement: top (sender row) vs classic footer. +static void timestampOnTopToggleCb(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) + touchPrefsSetTimestampOnTop(on); +#endif + if (g_lv.task) g_lv.task->showAlert(on ? TR("Timestamp on top") : TR("Timestamp at bottom"), 1000); + if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); + if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); +} + #if defined(HAS_EXPANSION_KIT) // V4 Expansion Kit: show/hide the Sensors tab + Home env widget. The tab layout // is decided in buildUiTree, so this only persists the pref and prompts for a @@ -11400,6 +11412,18 @@ static void buildDeviceSettings(int sec) { y += LV_MAX(40, h + 12); } + /* Channel bubbles: put the timestamp on the sender row (shorter) vs footer. */ + { + int h = settingsRowLabel(body, y, 6, TR("Timestamp on top"), 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 (touchPrefsGetTimestampOnTop()) lv_obj_add_state(sw, LV_STATE_CHECKED); +#endif + lv_obj_add_event_cb(sw, timestampOnTopToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(40, h + 12); + } + /* Compact messages (IRC-style): one dense "HH:MM name: text" row per message instead of bubbles — far more history on screen. Opt-in (wyvern.red). */ { @@ -29638,10 +29662,15 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann lv_coord_t inner_y = 0; char meta_buf[48]; chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); - if (channel_mode) { +#if defined(ESP32) + const bool meta_on_top = channel_mode && touchPrefsGetTimestampOnTop(); +#else + const bool meta_on_top = channel_mode; +#endif + if (meta_on_top) { if ((!m.outgoing && d.san_sender[0]) || meta_buf[0]) inner_y += lv_font_get_line_height(&g_font_12); - } else if (thread_is_room && !m.outgoing && d.san_sender[0]) { + } else if ((channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]) { inner_y += lv_font_get_line_height(&g_font_12); } @@ -29653,7 +29682,7 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); lv_coord_t body_h = inner_y + wrapped_size.y; - if (!channel_mode && meta_buf[0]) { + if (!meta_on_top && meta_buf[0]) { body_h += 1 + lv_font_get_line_height(&g_font_12); } return kChatBubblePadV * 2 + body_h; @@ -30373,7 +30402,11 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i char meta_buf[48]; uint32_t meta_fg = COLOR_SUB; chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); +#if defined(ESP32) + const bool meta_on_top = p->channel_mode && touchPrefsGetTimestampOnTop(); +#else const bool meta_on_top = p->channel_mode; +#endif const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]; diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index 30c8292..ca1a09d 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -58,6 +58,8 @@ static const I18nEntry kI18n[] = { { "Run setup again", { 0, "Setup opnieuw uitvoeren","Setup erneut ausführen","Relancer la configuration","Ejecutar configuración de nuevo","Esegui di nuovo la configurazione","Запустить настройку снова","Запустити налаштування знову","Стартирай настройката отново","Поново покрени подешавање","Εκτέλεση ρύθμισης ξανά" ,"Executar configuração de novo"} }, { "Distance in miles", { 0, "Afstand in mijlen","Entfernung in Meilen","Distance en miles","Distancia en millas","Distanza in miglia","Расстояние в милях","Відстань у милях","Разстояние в мили","Растојање у миљама","Απόσταση σε μίλια" ,"Distância em milhas"} }, { "Colourful chat bubbles", { 0, "Kleurrijke chatbubbels","Bunte Chat-Blasen","Bulles de chat colorées","Burbujas de chat coloridas","Fumetti chat colorati","Цветные пузыри чата","Кольорові бульбашки чату","Цветни чат балончета","Шарени облачићи ћаскања","Χρωματιστές φούσκες" ,"Balões de chat coloridos"} }, + { "Timestamp on top", { 0, "Tijdstempel bovenaan","Zeitstempel oben","Horodatage en haut","Marca de tiempo arriba","Timestamp in alto","Метка времени сверху","Мітка часу зверху","Времева марка отгоре","Временска ознака горе","Χρονική σήμανση επάνω","Carimbo no topo"} }, + { "Timestamp at bottom", { 0, "Tijdstempel onderaan","Zeitstempel unten","Horodatage en bas","Marca de tiempo abajo","Timestamp in basso","Метка времени снизу","Мітка часу знизу","Времева марка отдолу","Временска ознака доле","Χρονική σήμανση κάτω","Carimbo na base"} }, { "Theme colour", { 0, "Themakleur","Themenfarbe","Couleur du thème","Color del tema","Colore tema","Цвет темы","Колір теми","Цвят на темата","Боја теме","Χρώμα θέματος" ,"Cor do tema"} }, { "Pick colour", { 0, "Kies kleur","Farbe wählen","Choisir la couleur","Elegir color","Scegli colore","Выбрать цвет","Вибрати колір","Избери цвят","Изабери боју","Επιλογή χρώματος" ,"Escolher cor"} }, { "Secondary keyboards", { 0, "Secundaire toetsenborden","Sekundäre Tastaturen","Claviers secondaires","Teclados secundarios","Tastiere secondarie","Дополнительные клавиатуры","Додаткові клавіатури","Допълнителни клавиатури","Секундарне тастатуре","Δευτερεύοντα πληκτρολόγια" ,"Teclados secundários"} }, From 3b1a07dd93134d2b53fe1a88b42bacd685ba75c4 Mon Sep 17 00:00:00 2001 From: Steve Glenner Date: Sun, 26 Jul 2026 08:38:11 -0700 Subject: [PATCH 16/99] Revert "touch: add Display Settings toggle for channel timestamp on top" This reverts commit 31cb4c38a88e865b695d6328b2c72d9efd169b59. --- src/helpers/esp32/TouchPrefsStore.cpp | 17 +----------- src/helpers/esp32/TouchPrefsStore.h | 5 ---- src/ui-touch/UITask.cpp | 39 +++------------------------ src/ui-touch/i18n.cpp | 2 -- 4 files changed, 4 insertions(+), 59 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 25238ed..c2952b9 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 40; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 ts_on_top (channel bubble timestamp on the sender row; default ON) +static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -111,7 +111,6 @@ struct __attribute__((packed)) TouchCfg { uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing) uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) - uint8_t ts_on_top; // channel bubbles: put timestamp on the sender row (bool, 1=on) — v40 (trailing) }; static TouchCfg s_cfg; @@ -208,7 +207,6 @@ static void cfgSetDefaults(TouchCfg& c) { c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC) c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer) - c.ts_on_top = 1; // ON: channel bubble timestamp shares the sender row (shorter bubbles) } // Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) @@ -280,7 +278,6 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle) if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in) if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default - if (s_cfg.ver < 40) s_cfg.ts_on_top = 1; // new trailing field: channel timestamp-on-top ON by default s_cfg.ver = TOUCH_CFG_VER; s_cfg.magic = TOUCH_CFG_MAGIC; cfgFlush(); // rewrite with new fields defaulted-in @@ -1094,18 +1091,6 @@ bool touchPrefsSetCompactChat(bool on) { return cfgFlush(); } -// Channel bubble timestamp placement: ON = share the sender row (shorter bubbles); -// OFF = footer under the body (classic layout). Default ON. -bool touchPrefsGetTimestampOnTop() { - if (!s_begun) touchPrefsBegin(); - return s_cfg.ts_on_top != 0; -} -bool touchPrefsSetTimestampOnTop(bool on) { - if (!s_begun) touchPrefsBegin(); - s_cfg.ts_on_top = on ? 1 : 0; - return cfgFlush(); -} - // Monotonic send-timestamp floor (ClockFloorRTC, issue #89). Written rate-capped // from UITask::loop + on shutdown; only ever grows between resets. uint32_t touchPrefsGetClockFloor() { diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index fb506b3..8cd41eb 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -178,11 +178,6 @@ bool touchPrefsSetBootAdvert(bool on); bool touchPrefsGetCompactChat(); bool touchPrefsSetCompactChat(bool on); -/* Channel bubble timestamp placement: true = timestamp on the sender row (shorter - * bubbles); false = classic footer under the body. Default true. */ -bool touchPrefsGetTimestampOnTop(); -bool touchPrefsSetTimestampOnTop(bool on); - /* Buffered LoRa receive (experimental): a high-priority drain task lifts each packet * out of the radio within ~1 ms so UI-thread stalls can't overwrite unread packets * (the missed-messages class). Opt-in, default off = stock receive path. */ diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5fddd79..4e94790 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -10513,18 +10513,6 @@ static void compactChatToggleCb(lv_event_t* e) { if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); } -// Channel bubble timestamp placement: top (sender row) vs classic footer. -static void timestampOnTopToggleCb(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) - touchPrefsSetTimestampOnTop(on); -#endif - if (g_lv.task) g_lv.task->showAlert(on ? TR("Timestamp on top") : TR("Timestamp at bottom"), 1000); - if (g_lv.dm.detail_open) refreshChatDetailAsync(g_lv.dm); - if (g_lv.ch.detail_open) refreshChatDetailAsync(g_lv.ch); -} - #if defined(HAS_EXPANSION_KIT) // V4 Expansion Kit: show/hide the Sensors tab + Home env widget. The tab layout // is decided in buildUiTree, so this only persists the pref and prompts for a @@ -11412,18 +11400,6 @@ static void buildDeviceSettings(int sec) { y += LV_MAX(40, h + 12); } - /* Channel bubbles: put the timestamp on the sender row (shorter) vs footer. */ - { - int h = settingsRowLabel(body, y, 6, TR("Timestamp on top"), 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 (touchPrefsGetTimestampOnTop()) lv_obj_add_state(sw, LV_STATE_CHECKED); -#endif - lv_obj_add_event_cb(sw, timestampOnTopToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); - y += LV_MAX(40, h + 12); - } - /* Compact messages (IRC-style): one dense "HH:MM name: text" row per message instead of bubbles — far more history on screen. Opt-in (wyvern.red). */ { @@ -29662,15 +29638,10 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann lv_coord_t inner_y = 0; char meta_buf[48]; chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); -#if defined(ESP32) - const bool meta_on_top = channel_mode && touchPrefsGetTimestampOnTop(); -#else - const bool meta_on_top = channel_mode; -#endif - if (meta_on_top) { + if (channel_mode) { if ((!m.outgoing && d.san_sender[0]) || meta_buf[0]) inner_y += lv_font_get_line_height(&g_font_12); - } else if ((channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]) { + } else if (thread_is_room && !m.outgoing && d.san_sender[0]) { inner_y += lv_font_get_line_height(&g_font_12); } @@ -29682,7 +29653,7 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); lv_coord_t body_h = inner_y + wrapped_size.y; - if (!meta_on_top && meta_buf[0]) { + if (!channel_mode && meta_buf[0]) { body_h += 1 + lv_font_get_line_height(&g_font_12); } return kChatBubblePadV * 2 + body_h; @@ -30402,11 +30373,7 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i char meta_buf[48]; uint32_t meta_fg = COLOR_SUB; chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); -#if defined(ESP32) - const bool meta_on_top = p->channel_mode && touchPrefsGetTimestampOnTop(); -#else const bool meta_on_top = p->channel_mode; -#endif const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]; diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index ca1a09d..30c8292 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -58,8 +58,6 @@ static const I18nEntry kI18n[] = { { "Run setup again", { 0, "Setup opnieuw uitvoeren","Setup erneut ausführen","Relancer la configuration","Ejecutar configuración de nuevo","Esegui di nuovo la configurazione","Запустить настройку снова","Запустити налаштування знову","Стартирай настройката отново","Поново покрени подешавање","Εκτέλεση ρύθμισης ξανά" ,"Executar configuração de novo"} }, { "Distance in miles", { 0, "Afstand in mijlen","Entfernung in Meilen","Distance en miles","Distancia en millas","Distanza in miglia","Расстояние в милях","Відстань у милях","Разстояние в мили","Растојање у миљама","Απόσταση σε μίλια" ,"Distância em milhas"} }, { "Colourful chat bubbles", { 0, "Kleurrijke chatbubbels","Bunte Chat-Blasen","Bulles de chat colorées","Burbujas de chat coloridas","Fumetti chat colorati","Цветные пузыри чата","Кольорові бульбашки чату","Цветни чат балончета","Шарени облачићи ћаскања","Χρωματιστές φούσκες" ,"Balões de chat coloridos"} }, - { "Timestamp on top", { 0, "Tijdstempel bovenaan","Zeitstempel oben","Horodatage en haut","Marca de tiempo arriba","Timestamp in alto","Метка времени сверху","Мітка часу зверху","Времева марка отгоре","Временска ознака горе","Χρονική σήμανση επάνω","Carimbo no topo"} }, - { "Timestamp at bottom", { 0, "Tijdstempel onderaan","Zeitstempel unten","Horodatage en bas","Marca de tiempo abajo","Timestamp in basso","Метка времени снизу","Мітка часу знизу","Времева марка отдолу","Временска ознака доле","Χρονική σήμανση κάτω","Carimbo na base"} }, { "Theme colour", { 0, "Themakleur","Themenfarbe","Couleur du thème","Color del tema","Colore tema","Цвет темы","Колір теми","Цвят на темата","Боја теме","Χρώμα θέματος" ,"Cor do tema"} }, { "Pick colour", { 0, "Kies kleur","Farbe wählen","Choisir la couleur","Elegir color","Scegli colore","Выбрать цвет","Вибрати колір","Избери цвят","Изабери боју","Επιλογή χρώματος" ,"Escolher cor"} }, { "Secondary keyboards", { 0, "Secundaire toetsenborden","Sekundäre Tastaturen","Claviers secondaires","Teclados secundarios","Tastiere secondarie","Дополнительные клавиатуры","Додаткові клавіатури","Допълнителни клавиатури","Секундарне тастатуре","Δευτερεύοντα πληκτρολόγια" ,"Teclados secundários"} }, From fdaf48402c9a6ad81adb9636b5df0115c4463574 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 18:37:46 +0200 Subject: [PATCH 17/99] touch: sync-fallback for failing chat saves, with a configurable threshold EIO persisted on the core-1 flush task, which narrows the failure to shared-SPI interleave rather than core-0 starvation alone: the one always-working configuration is the LOOP TASK writing (reboot persist) -- when it writes, nothing else interleaves radio traffic between the SD transactions on the shared bus. (The pager port notes flagged exactly this: 'keep an eye on SD-write + RX overlap during history flush'.) The flush now self-heals: after N consecutive failed background writes it retries synchronously on the loop task (one UI hitch per flush while degraded), and a single success flips it back to the async task. N is a new setting (Settings -> General -> 'Chat save fallback', dropdown Off/1/2/3/5/8, default 2; Off = background retries only). Stored as TouchCfg v40 trailing field hist_sync_after with getter/setter. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/helpers/esp32/TouchPrefsStore.cpp | 13 +++++++- src/helpers/esp32/TouchPrefsStore.h | 6 ++++ src/ui-touch/UITask.cpp | 43 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index c2952b9..e764933 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF) +static const uint8_t TOUCH_CFG_VER = 40; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -111,6 +111,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing) uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) + uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing) }; static TouchCfg s_cfg; @@ -207,6 +208,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC) c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer) + c.hist_sync_after = 2; // chat flush: 2 failed background writes -> synchronous loop-task fallback } // Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) @@ -928,6 +930,15 @@ bool touchPrefsSetMapNight(bool on) { s_cfg.map_night = on ? 1 : 0; return cfgFlush(); } +uint8_t touchPrefsGetHistSyncAfter() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.hist_sync_after > 9 ? 9 : s_cfg.hist_sync_after; +} +bool touchPrefsSetHistSyncAfter(uint8_t n) { + if (!s_begun) touchPrefsBegin(); + s_cfg.hist_sync_after = n > 9 ? 9 : n; + return cfgFlush(); +} uint8_t touchPrefsGetMapZoom() { if (!s_begun) touchPrefsBegin(); return s_cfg.map_zoom; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 8cd41eb..4e24e5b 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -103,6 +103,12 @@ bool touchPrefsSetTilesFromSd(bool from_sd); bool touchPrefsGetMapNight(); bool touchPrefsSetMapNight(bool on); +// Chat-history flush: consecutive failed off-thread (background) writes before +// the flush falls back to the blocking loop-task write (reliable, but the UI +// hitches for the write's duration). 0 = never fall back. Default 2. +uint8_t touchPrefsGetHistSyncAfter(); +bool touchPrefsSetHistSyncAfter(uint8_t n); + /** Last map zoom level, persisted so the map reopens where the user left it. * 0 = unset (let the auto-snap pick a level for the available tile pack). */ uint8_t touchPrefsGetMapZoom(); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c90be3c..06c9356 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -8442,6 +8442,15 @@ static void syncClockCb(lv_event_t* e) { g_lv.task->showAlert(TR("Clock synced"), 900); } +// Chat-save fallback dropdown (Settings -> General): selection index -> number +// of consecutive failed background history writes before the flush retries +// synchronously on the UI task (0 = background retries only, never hitch). +static const uint8_t k_hist_sync_opts[] = { 0, 1, 2, 3, 5, 8 }; +static void histSyncSelectCb(lv_event_t* e) { + const uint16_t sel = lv_dropdown_get_selected(lv_event_get_target(e)); + if (sel < sizeof(k_hist_sync_opts)) touchPrefsSetHistSyncAfter(k_hist_sync_opts[sel]); +} + static void advertNowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; g_lv.task->showAlert(g_lv.task->sendAdvertNow() ? TR("Advert sent") : TR("Advert failed"), 900); @@ -11404,6 +11413,27 @@ static void buildDeviceSettings(int sec) { lv_label_set_text(l_adv, TR("Send advert now")); lv_obj_center(l_adv); y += SC(40); + + /* Chat-save fallback: after how many consecutive failed BACKGROUND history + writes the flush retries synchronously on the UI task. The sync write is + the empirically reliable path on the shared SPI bus, but the screen + hitches for its duration — users who prefer a never-hitching UI pick Off + (background retries only; the RAM ring keeps everything meanwhile). */ + { + y += settingsRowLabel(body, y, 0, TR("Chat save fallback (failed tries)"), COLOR_SUB, &g_font_12, 0) + 2; + lv_obj_t* dd = lv_dropdown_create(body); + lv_dropdown_set_options(dd, TR("Off\n1\n2\n3\n5\n8")); + const uint8_t cur = touchPrefsGetHistSyncAfter(); + uint16_t sel = 2; // index of "2" — the default when the stored value isn't a listed option + for (uint16_t i = 0; i < sizeof(k_hist_sync_opts); ++i) + if (k_hist_sync_opts[i] == cur) { sel = i; break; } + lv_dropdown_set_selected(dd, sel); + lv_obj_set_width(dd, lv_pct(100)); + lv_obj_set_pos(dd, 2, y); + lv_obj_add_event_cb(dd, histSyncSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); // open list on top of the bar + y += SC(44); + } } if (sec == DSEC_DISPLAY) { // --- Display --- @@ -41069,6 +41099,19 @@ void UITask::flushHistoryIfDue(unsigned long now) { return; } #if defined(ESP32) + // Off-thread writes keep failing? Fall back to the LOOP-TASK write — the + // empirically reliable path (the reboot-time persist always succeeds): + // when this task writes, nothing else interleaves radio/display traffic + // on the shared SPI bus between the SD transactions. Costs one UI hitch + // per flush while degraded; a single success resets the counter and the + // next flush goes back to the async task. The threshold is a setting + // (Settings -> General; default 2, 0 = never fall back). + const uint8_t sync_after = touchPrefsGetHistSyncAfter(); + if (sync_after && s_msgs_write_fails >= sync_after) { + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + (s_msgs_write_fails >= 5 ? 300000 : 5000); + return; + } // Snapshot the ring (a few ms of PSRAM memcpy) and hand the write to the // core-1 hist_flush task (NOT the core-0/Wi-Fi tile worker — see // histFlushTaskFn). Messages arriving while it writes stay in the live From f5d8cfa390d07ffa4497c5cafc56f571450bd443 Mon Sep 17 00:00:00 2001 From: Steve Glenner Date: Sun, 26 Jul 2026 11:40:05 -0700 Subject: [PATCH 18/99] touch: put bubble timestamp on top for DMs and rooms too Extend the channel top-row timestamp layout to every bubble-style thread so DMs and room messages match channels. Co-authored-by: Cursor --- src/ui-touch/UITask.cpp | 54 +++++++---------------------------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 4e94790..727402f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -29638,12 +29638,10 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann lv_coord_t inner_y = 0; char meta_buf[48]; chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr); - if (channel_mode) { - if ((!m.outgoing && d.san_sender[0]) || meta_buf[0]) - inner_y += lv_font_get_line_height(&g_font_12); - } else if (thread_is_room && !m.outgoing && d.san_sender[0]) { + // Bubble layout: timestamp/meta always share the top row (channels, DMs, rooms). + const bool show_sender = (channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0]; + if (show_sender || meta_buf[0]) inner_y += lv_font_get_line_height(&g_font_12); - } lv_point_t txt_size; lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); @@ -29652,11 +29650,7 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); - lv_coord_t body_h = inner_y + wrapped_size.y; - if (!channel_mode && meta_buf[0]) { - body_h += 1 + lv_font_get_line_height(&g_font_12); - } - return kChatBubblePadV * 2 + body_h; + return kChatBubblePadV * 2 + inner_y + wrapped_size.y; } static void chatBuildCompactLine(const UITask::UIMessage& m, LvChatPanel* p, int logical_i, @@ -30373,26 +30367,24 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i char meta_buf[48]; uint32_t meta_fg = COLOR_SUB; chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg); - const bool meta_on_top = p->channel_mode; + // All bubble-style threads (channel / DM / room): timestamp + delivery meta on the top row. const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]; const lv_coord_t sender_w = show_sender_line ? chatTextWidth(d.san_sender) : 0; const lv_coord_t meta_w = meta_buf[0] ? chatTextWidth(meta_buf) : 0; lv_coord_t inner_w = txt_w_used > 0 ? txt_w_used : 1; - if (meta_on_top) { + { lv_coord_t header_w = (show_sender_line ? sender_w : 0) + meta_w; if (show_sender_line && meta_w) header_w += 6; if (header_w > kInnerMaxW) header_w = kInnerMaxW; if (header_w > inner_w) inner_w = header_w; - } else if (show_sender_line && sender_w > inner_w) { - inner_w = sender_w > kInnerMaxW ? kInnerMaxW : sender_w; } int inner_y = 0; // Analytic bubble width for x-alignment (widest of sender/text/meta) — do not use // lv_obj_get_width() right after create; unsettled layout can mis-place outgoing bubbles. - if (meta_on_top && (show_sender_line || meta_buf[0])) { + if (show_sender_line || meta_buf[0]) { const lv_coord_t line_h = lv_font_get_line_height(&g_font_12); char meta_fit[48] = ""; lv_coord_t meta_fit_w = 0; @@ -30428,17 +30420,6 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_set_pos(mlbl, inner_w - meta_fit_w, inner_y); } inner_y += line_h; - } else if (show_sender_line) { - lv_obj_t* slbl = lv_label_create(bubble); - lv_label_set_text(slbl, d.san_sender); - lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN); - lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN); - if (inner_w < sender_w) { - lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT); - lv_obj_set_width(slbl, inner_w); - } - lv_obj_set_pos(slbl, 0, inner_y); - inner_y += lv_font_get_line_height(&g_font_12); } lv_obj_t* tlbl = lv_label_create(bubble); @@ -30466,31 +30447,14 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i lv_obj_add_event_cb(bubble, bubbleUrlTapCb, LV_EVENT_SHORT_CLICKED, reinterpret_cast(static_cast(ring_idx))); // Failed sends keep the pre-virtualization one-tap resend (the compact path - // already has it); the footer below spells the affordance out. + // already has it); delivery status on the top meta row spells the affordance out. if (m.outgoing && m.deliv_state == UITask::DELIV_FAILED) lv_obj_add_event_cb(bubble, bubbleRetryTapCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(ring_idx))); - // Channel bubbles put timestamp/meta on the top row; DMs/rooms keep it as a footer. - if (!meta_on_top && meta_buf[0]) { - lv_obj_t* foot = lv_label_create(bubble); - lv_label_set_text(foot, meta_buf); - lv_obj_set_style_text_font(foot, &g_font_12, LV_PART_MAIN); - lv_obj_set_style_text_color(foot, lv_color_hex(meta_fg), LV_PART_MAIN); - lv_point_t wrapped_size; - lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0, - txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE); - lv_point_t foot_size; - lv_txt_get_size(&foot_size, meta_buf, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - if (foot_size.x > inner_w) inner_w = foot_size.x; - const int foot_x = (txt_w_used > foot_size.x) ? (txt_w_used - foot_size.x) : 0; - const int foot_y = inner_y + wrapped_size.y + 1; - lv_obj_set_pos(foot, foot_x, foot_y); - } - lv_obj_update_layout(bubble); lv_coord_t bh = lv_obj_get_height(bubble); - // Width for x-alignment is computed analytically from the widest child (text/sender/footer), + // Width for x-alignment is computed analytically from the widest child (text/sender/meta), // NOT lv_obj_get_width(): right after a send the layout isn't settled and get_width reads wide, // which put the (actually narrow) outgoing bubble on the LEFT until the next rebuild. lv_coord_t bw = inner_w + 2 * kChatBubblePadH; From be6e1f20ff2a4237e84a297947454ee2b48dc4af Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 22:34:41 +0200 Subject: [PATCH 19/99] touch: per-record sequence numbers for the segmented chat store (phase 1) Every ring record gets a monotonic uint32 seq at append time, and the loader backfills seqs in chronological order for records loaded from the pre-segment formats (which carry none on disk), seeding the generator past them. seq is the segmented store's record key: segment membership is a [first_seq, last_seq] range, stable across per-segment compaction. Deliberately a dedicated counter (_ui_seq_next), NOT _msgcount: the companion protocol can overwrite _msgcount (msgRead), so it cannot be a unique key. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 12 ++++++++++++ src/ui-touch/UITask.h | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 06c9356..675a614 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41492,6 +41492,17 @@ bool UITask::loadHistoryFromStorage() { _ui_threads[i].has_mention = false; } } + // Backfill per-record sequence numbers for records loaded from the pre-segment + // formats (the single-file/legacy records carry no seq on disk): stamp them in + // chronological order and seed the generator past them. The segmented loader + // will restore REAL seqs from disk and skip this backfill. + { + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + _ui_msgs[slot].seq = (uint32_t)(i + 1); + } + _ui_seq_next = (uint32_t)_ui_msg_count + 1; + } return true; #else return false; @@ -41981,6 +41992,7 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te time_t sys = time(nullptr); m.ts = (sys > 1700000000) ? (uint32_t)sys : (uint32_t)(millis() / 1000); } + m.seq = _ui_seq_next++; // monotonic record id — the segment store keys on it m.channel = channel; m.outgoing = outgoing; m.ack_hash = ack_hash; diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 83ea5c1..3093dd8 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -60,6 +60,12 @@ public: struct UIMessage { uint32_t ts; + // Monotonic per-record sequence number, assigned at append and persisted by + // the segmented store (its record-to-segment mapping keys on seq ranges). + // NEVER reset mid-session; the boot loader seeds the generator from + // max(loaded seq)+1. Distinct from _msgcount, which the companion protocol + // may overwrite (msgRead) and therefore cannot be a unique key. + uint32_t seq; bool channel; bool outgoing; uint32_t ack_hash; // expected-ack for outgoing DMs (0 if none / channel / incoming) @@ -132,6 +138,9 @@ private: char _alert[80]; unsigned long _alert_expiry; int _msgcount; + /** Next UIMessage::seq to hand out. Seeded by the loader (max loaded seq + 1), + * strictly monotonic for the session. See UIMessage::seq. */ + uint32_t _ui_seq_next = 1; int _ui_msg_count; int _ui_msg_head; /** Runtime ring capacity: MAX_UI_MESSAGES_SD when history lives on an SD card, From 3d487aefcf8442d02f300ae64632d5392e641bd8 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 22:40:23 +0200 Subject: [PATCH 20/99] touch: segmented store core primitives (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-layer building blocks for the segmented chat store, no callers yet: UiSegHeader/UiSegMsg on-disk format (v6 record + appended seq, self-describing rec_size), segment naming (/msgs/seg_.bin, real subdir on FAT backends, legal flat name on SPIFFS), uiDataEnsureDirs, chunked internal-RAM record writer, active-segment append (header-first on create, retry-once after ensuring dirs, ragged-tail crash semantics), one-segment compact (tmp+rename with the .tmp/.tm2 dual-namespace discipline, n==0 removes the file), retired-segment delete, validated open + size-agnostic record reader (File::size()-free — the Tanmatsu FFat metadata layer lies), and segment discovery (dir listing on FAT, flat prefix scan on SPIFFS, orphaned-tmp sweep, sorted ascending). All ops report through uiMsgsWriteResult/uiMsgsWriteFail so the About diagnostics, failure toasts, sync-fallback and SD-wedge arbitration work unchanged; new stage codes a/c/d/s join chatSaveFailText. Builds all five envs (expected unused-function warnings until the scheduler wires these in). Per .notes/segmented-store-plan.md phase 2. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 311 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 675a614..27c137b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -311,6 +311,43 @@ struct __attribute__((packed)) UiMsgFileHeader { uint16_t msg_rec_size; uint8_t _pad[2]; }; + +// ---- Segmented message store (generation 3) ------------------------------ +// The ring is persisted as fixed-capacity SEGMENT files under /msgs: +// seg_.bin, k_ui_seg_records records each. New messages APPEND one +// record to the newest ("active") segment — a ~240 B write instead of the old +// full-ring rewrite (515 KB+ at 2300 messages), which shrinks the hard-cut +// loss window, the SPI bus-collision window, and flash wear all at once. +// Deletes tombstone in RAM and mark the owning segment dirty; compaction +// rewrites JUST that segment from the RAM ring (disk holds exactly the ring's +// record set, so no read pass is ever needed). Corruption quarantines one +// segment instead of the whole history. On FAT backends /msgs is a real +// subdirectory (single child under /meshcomod — stays inside the factory +// wipe's 24-entry cap); on SPIFFS the same name is a legal FLAT file name +// (31-char limit: "/msgs/seg_4294967295.bin" = 24 chars), so the layout is +// identical everywhere and no mkdir is required there. +constexpr const char* k_ui_seg_dir = "/msgs"; +constexpr uint32_t k_ui_seg_magic = 0x55495347; // "UISG" +constexpr uint16_t k_ui_seg_version = 1; +constexpr int k_ui_seg_records = 256; // records per segment (~60 KB at v1 size) +constexpr int k_ui_seg_max = 24; // table cap: 5000/256 = 20 live segments + slack + +struct __attribute__((packed)) UiSegHeader { + uint32_t magic; // k_ui_seg_magic + uint16_t version; // k_ui_seg_version + uint16_t msg_rec_size; // sizeof(UiSegMsg) at write time — self-describing so + // future fields append at the END (same rule as v5+) + uint32_t first_seq; // seq of this segment's first-ever record (== filename key) + uint8_t _pad[4]; +}; + +// On-disk segment record: the v6 record layout + the monotonic seq appended +// (append-at-end evolution — a reader of either size copies min(rec_size) and +// zero-fills the rest, so seq reads 0 from a hypothetical older record). +struct __attribute__((packed)) UiSegMsg { + UiHistoryMsg m; + uint32_t seq; +}; } // namespace #endif @@ -4573,6 +4610,10 @@ static void chatSaveFailText(char* out, size_t cap) { case 'h': stage = "header"; break; case 'b': stage = "write"; break; case 'r': stage = "rename"; break; + case 'a': stage = "append"; break; // segmented store: active-segment append + case 'c': stage = "compact"; break; // segmented store: one-segment rewrite + case 'd': stage = "mkdir"; break; // segmented store: data-dir create + case 's': stage = "scan"; break; // segmented store: segment discovery default: stage = "save"; break; } const int e = s_msgs_write_errno; @@ -41584,6 +41625,276 @@ static bool uiMsgsWriteFail(char stage) { return uiMsgsWriteResult(false); } +// ---- Segmented store: low-level file ops -------------------------------- +// Pure file-layer primitives (no scheduling, no table state — that lives with +// the flush scheduler). All take explicit record arrays (snapshots or the +// ring under the loop task's ownership) and report through the same +// uiMsgsWriteResult/uiMsgsWriteFail health machinery as the old writer, so +// the About diagnostics, failure toasts, sync-fallback and SD-wedge +// arbitration keep working unchanged. Stage codes: 'a' append, 'c' compact, +// 'd' data-dir create, 's' segment scan (plus errno). + +// Root-relative segment file name: "/msgs/seg_.bin". +// Worst case "/msgs/seg_4294967295.bin.tm2" = 28 chars — fits uiDataOpen's +// 80-byte path buffer and SPIFFS' 31-char flat-name limit. +static void uiSegName(uint32_t first_seq, const char* suffix, char* out, size_t cap) { + snprintf(out, cap, "%s/seg_%lu.bin%s", k_ui_seg_dir, (unsigned long)first_seq, suffix ? suffix : ""); +} + +// Ensure the data root + segment dir exist. FAT backends (SD /meshcomod, +// SD_MMC /meshcomod, FFat) need real directories; SPIFFS has a flat namespace +// where mkdir fails harmlessly and slash-in-name files just work — so this is +// best-effort by design. Called at backend resolve and from every remount +// path (a fresh replacement card has neither directory). +static void uiDataEnsureDirs() { + if (!uiDataFsReady()) return; + char p[80]; + if (s_ui_data_root[0]) s_ui_data_fs->mkdir(s_ui_data_root); + snprintf(p, sizeof p, "%s%s", s_ui_data_root, k_ui_seg_dir); + s_ui_data_fs->mkdir(p); // no-op/failure on SPIFFS is fine (flat names) +} + +// Marshal one RAM record into the on-disk segment record (drops the RAM-only +// fields, carries seq). Mirrors the old writer's field list exactly. +static void uiSegMarshal(const UITask::UIMessage& s, UiSegMsg* d) { + memset(d, 0, sizeof(*d)); + d->m.ts = s.ts; + d->m.channel = s.channel ? 1u : 0u; + d->m.outgoing = s.outgoing ? 1u : 0u; + d->m.meta_flags = s.meta_flags; + d->m.path_len = s.path_len; + d->m.snr_q4 = s.snr_q4; + d->m.rssi = s.rssi; + strncpy(d->m.thread, s.thread, sizeof(d->m.thread) - 1); + d->m.thread[sizeof(d->m.thread) - 1] = '\0'; + strncpy(d->m.sender, s.sender, sizeof(d->m.sender) - 1); + d->m.sender[sizeof(d->m.sender) - 1] = '\0'; + strncpy(d->m.text, s.text, sizeof(d->m.text) - 1); + d->m.text[sizeof(d->m.text) - 1] = '\0'; + d->seq = s.seq; +} + +// Chunked record writer shared by append + compact: marshals through a small +// INTERNAL-RAM buffer (the flash/SD drivers must never see a PSRAM source +// pointer), per-record fallback when the heap is tight. Returns false on any +// short write (errno left for the caller's stage report). +static bool uiSegWriteRecords(File& f, const UITask::UIMessage* recs, int n) { + const size_t REC = sizeof(UiSegMsg); + size_t chunk_recs = 6144 / REC; + if (chunk_recs < 1) chunk_recs = 1; + uint8_t* buf = (uint8_t*)malloc(REC * chunk_recs); // internal RAM by default + bool ok = true; + if (buf) { + size_t fill = 0; + for (int k = 0; ok && k < n; ++k) { + uiSegMarshal(recs[k], reinterpret_cast(buf + fill)); + fill += REC; + if (fill == REC * chunk_recs) { + ok = (f.write(buf, fill) == fill); + fill = 0; + } + } + if (ok && fill > 0) ok = (f.write(buf, fill) == fill); + free(buf); + } else { + UiSegMsg rec; + for (int k = 0; ok && k < n; ++k) { + uiSegMarshal(recs[k], &rec); + ok = (f.write(reinterpret_cast(&rec), sizeof(rec)) == sizeof(rec)); + } + } + return ok; +} + +// Append records to a segment file. `create` = the file does not exist yet +// (write the header first). Driven by table state, NOT by File::size() — the +// Tanmatsu FFat metadata layer lies about sizes. A crash mid-append leaves a +// ragged tail the loader truncates to the record boundary; nothing else is at +// risk (the previous records and every other segment are untouched — this is +// the whole point of the segmented layout). +static bool uiSegAppendRecords(uint32_t first_seq, bool create, + const UITask::UIMessage* recs, int n) { + WdtHeavyGuard _wg; + errno = 0; + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + File f = uiDataOpen(name, FILE_APPEND); + if (!f) { + // First failure on a fresh card/dir is usually a missing parent dir — + // create it and retry once before reporting. + uiDataEnsureDirs(); + errno = 0; + f = uiDataOpen(name, FILE_APPEND); + if (!f) return uiMsgsWriteFail('a'); + } + bool ok = true; + if (create) { + UiSegHeader hdr{}; + hdr.magic = k_ui_seg_magic; + hdr.version = k_ui_seg_version; + hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); + hdr.first_seq = first_seq; + ok = (f.write(reinterpret_cast(&hdr), sizeof(hdr)) == sizeof(hdr)); + } + if (ok) ok = uiSegWriteRecords(f, recs, n); + f.close(); + if (!ok) return uiMsgsWriteFail('a'); + return uiMsgsWriteResult(true); +} + +// Rewrite one segment from the given (live, tombstone-free) records — tmp + +// rename, same crash discipline as the old writer but with a one-segment +// blast radius. n == 0 removes the segment file outright (everything in it +// was deleted). `sync_writer` picks the tmp namespace: the loop-task sync +// writer must never share a tmp with a possibly-stalled worker write (two +// truncating opens of one path interleave into garbage — the .tmp/.tm2 +// lesson from the single-file store). +static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, + bool sync_writer) { + WdtHeavyGuard _wg; + errno = 0; + char fin[48], tmp[48]; + uiSegName(first_seq, "", fin, sizeof fin); + if (n <= 0) { + uiDataRemove(fin); + return uiMsgsWriteResult(true); + } + uiSegName(first_seq, sync_writer ? ".tm2" : ".tmp", tmp, sizeof tmp); + File f = uiDataOpen(tmp, "w"); + if (!f) { + uiDataEnsureDirs(); + errno = 0; + f = uiDataOpen(tmp, "w"); + if (!f) return uiMsgsWriteFail('c'); + } + UiSegHeader hdr{}; + hdr.magic = k_ui_seg_magic; + hdr.version = k_ui_seg_version; + hdr.msg_rec_size = (uint16_t)sizeof(UiSegMsg); + hdr.first_seq = first_seq; + bool ok = (f.write(reinterpret_cast(&hdr), sizeof(hdr)) == sizeof(hdr)); + if (ok) ok = uiSegWriteRecords(f, recs, n); + f.close(); + if (!ok) { uiDataRemove(tmp); return uiMsgsWriteFail('c'); } + errno = 0; + if (!uiDataReplaceFile(fin, tmp)) return uiMsgsWriteFail('c'); + return uiMsgsWriteResult(true); +} + +// Remove a retired segment (ring grew past retention; its records are gone +// from RAM too). +static void uiSegRemoveFile(uint32_t first_seq) { + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + uiDataRemove(name); +} + +// Open + validate a segment for reading. Returns the on-disk record size via +// *rec_size_out (self-describing header) and leaves the File positioned at +// the first record. Returns false on a corrupt header — the caller +// quarantines THAT segment only. +static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out) { + char name[48]; + uiSegName(first_seq, "", name, sizeof name); + f = uiDataOpen(name, FILE_READ); + if (!f) return false; + UiSegHeader hdr{}; + if (f.readBytes(reinterpret_cast(&hdr), sizeof(hdr)) != (int)sizeof(hdr) || + hdr.magic != k_ui_seg_magic || + hdr.version == 0 || hdr.version > k_ui_seg_version || + hdr.msg_rec_size == 0 || hdr.msg_rec_size > 4096 || + hdr.first_seq != first_seq) { + f.close(); + return false; + } + *rec_size_out = hdr.msg_rec_size; + return true; +} + +// Read ONE record from an open segment into a RAM record. Returns false at a +// clean EOF or a ragged (crash-truncated) tail — the caller just stops there; +// records already read are valid. Size-agnostic via readHistoryRec (older +// shorter records zero-fill their tail, so a missing seq field reads 0 and +// the loader backfills it). +static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out) { + UiSegMsg rec{}; + if (!readHistoryRec(f, &rec, sizeof(rec), disk_sz)) return false; + memset(out, 0, sizeof(*out)); + out->ts = rec.m.ts; + out->channel = rec.m.channel != 0; + out->outgoing = rec.m.outgoing != 0; + out->meta_flags = rec.m.meta_flags; + out->path_len = rec.m.path_len; + out->snr_q4 = rec.m.snr_q4; + out->rssi = rec.m.rssi; + strncpy(out->thread, rec.m.thread, UITask::MAX_THREAD_NAME); + out->thread[UITask::MAX_THREAD_NAME] = '\0'; + strncpy(out->sender, rec.m.sender, UITask::MAX_SENDER_NAME); + out->sender[UITask::MAX_SENDER_NAME] = '\0'; + strncpy(out->text, rec.m.text, UITask::MAX_MSG_TEXT); + out->text[UITask::MAX_MSG_TEXT] = '\0'; + out->seq = rec.seq; + *seq_out = rec.seq; + return true; +} + +// Discover segment files: fills out_first_seqs sorted ascending, returns the +// count (or -1 when even the scan location can't be opened — distinct from +// "no segments yet"). Handles both directory layouts: a real /msgs dir +// (FAT backends) and SPIFFS' flat namespace (scan "/" and prefix-match the +// name). When sweep_tmps is set, orphaned .tmp/.tm2 leftovers from a crash +// mid-compact are deleted along the way (boot hygiene). +static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps) { + if (!uiDataFsReady()) return -1; + char dirpath[80]; + snprintf(dirpath, sizeof dirpath, "%s%s", s_ui_data_root, k_ui_seg_dir); + File dir = s_ui_data_fs->open(dirpath); + bool flat = false; + if (!dir || !dir.isDirectory()) { + // SPIFFS (flat namespace): enumerate the root and match the name prefix. + if (dir) dir.close(); + flat = true; + dir = s_ui_data_fs->open(s_ui_data_root[0] ? s_ui_data_root : "/"); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return -1; + } + } + const size_t root_len = strlen(s_ui_data_root); + int n = 0; + for (;;) { + String path = dir.getNextFileName(); + if (path.length() == 0) break; + // Normalize to a root-relative name so uiDataRemove/uiDataOpen accept it. + const char* rel = path.c_str(); + if (root_len && strncmp(rel, s_ui_data_root, root_len) == 0) rel += root_len; + const char* base = strrchr(rel, '/'); + base = base ? base + 1 : rel; + if (flat) { + // Flat scan sees every file — keep only "/msgs/seg_*" names. + if (strncmp(rel, k_ui_seg_dir, strlen(k_ui_seg_dir)) != 0) continue; + } + if (strncmp(base, "seg_", 4) != 0) continue; + char* end = nullptr; + const unsigned long fs_val = strtoul(base + 4, &end, 10); + if (!end || end == base + 4) continue; + if (strcmp(end, ".bin") == 0) { + if (n < max_out) out_first_seqs[n++] = (uint32_t)fs_val; + } else if (sweep_tmps && (strcmp(end, ".bin.tmp") == 0 || strcmp(end, ".bin.tm2") == 0)) { + uiDataRemove(rel); + } + } + dir.close(); + // Insertion sort ascending — n is tiny (<= k_ui_seg_max). + for (int i = 1; i < n; ++i) { + const uint32_t v = out_first_seqs[i]; + int j = i - 1; + while (j >= 0 && out_first_seqs[j] > v) { out_first_seqs[j + 1] = out_first_seqs[j]; --j; } + out_first_seqs[j + 1] = v; + } + return n; +} + static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, uint16_t count, uint16_t head, uint32_t msgcount, const char* tmp_path) { From fb0346e249dd735909ae93a2b87c48ebc722ea7f Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 22:48:05 +0200 Subject: [PATCH 21/99] touch: segmented-store loader + verify-then-delete migration (phase 3) loadHistoryFromStorage now prefers segments over the split v6 file over the legacy combined file. The segment loader reads oldest-first into the ring, quarantines corrupt segments INDIVIDUALLY (one bad record used to discard the entire history), truncates crash-ragged tails at the record boundary, keeps the newest <= cap records when the disk set outgrows this boot's ring (three-reversal rotation restores the linear-ring invariant), reconciles the segment table (fully-dropped segments deleted, the boundary segment marked compact-dirty), restores real seqs and seeds the generator, and sweeps orphaned segment tmps. When an old format supplied the ring, migrateRingToSegments() converts it: segments written from the ring, every one read back and verified (count + seq bounds) before the old msgs file is deleted; any failure rolls the whole segment set back so the intact old file stays authoritative next boot, and s_seg_store_ready stays false so the flush scheduler (phase 4) retries the migration instead of writing segments that would shadow it. The companion message counter rides the threads-file header now (the segments don't store it); the mesh re-asserts it per message anyway. Builds all five envs (loader wired; writers wire in phase 4). Per .notes/segmented-store-plan.md phase 3. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 251 +++++++++++++++++++++++++++++++++++++--- src/ui-touch/UITask.h | 2 + 2 files changed, 237 insertions(+), 16 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 27c137b..82c32c1 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4633,6 +4633,36 @@ static void chatSaveFailText(char* out, size_t cap) { } static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size + +// ---- Segmented store: runtime segment table (loop-task-owned) -------------- +// Oldest-first view of the on-disk segment set. Rebuilt by the boot loader, +// consulted and updated by the flush scheduler. The hist_flush worker never +// touches it — it only executes fully-described jobs and reports ok/fail. +struct UiSegInfo { + uint32_t first_seq; // filename key + first record's seq + uint32_t last_seq; // newest seq physically in the FILE + uint32_t bytes; // file size (header + records) — arithmetic, never stat() + uint16_t disk_recs; // records physically in the file + uint16_t live_recs; // of those, how many are still live in the RAM ring + uint8_t compact_dirty; // RAM tombstones/drops within this segment -> rewrite due +}; +static UiSegInfo s_seg[k_ui_seg_max]; +static int s_seg_count = 0; +static volatile uint32_t s_seg_total_bytes = 0; // writer-maintained; About page reads it +static volatile bool s_seg_resync = false; // card swapped/remounted: rewrite everything from RAM +// False until the boot loader read segments OR the one-time migration landed. +// While false the flush scheduler must not append segments (they would shadow +// the still-authoritative old-format file on the next boot) — it retries the +// migration instead. +static bool s_seg_store_ready = false; +// fwd decls — the ops live with the storage code far below. +static void uiDataEnsureDirs(); +static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps); +static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out); +static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out); +static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer); +static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n); +static void uiSegRemoveFile(uint32_t first_seq); static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists static volatile bool s_sdinfo_ok = false; // card present + sizes valid static uint64_t s_sdinfo_tot = 0; @@ -41309,6 +41339,10 @@ bool UITask::loadThreadsFromStorage() { const size_t disk_sz = (hdr.version >= 5 && hdr.thread_rec_size) ? hdr.thread_rec_size : sizeof(UiHistoryThread); if (disk_sz == 0 || disk_sz > 4096) { f.close(); uiDataRemove(k_ui_threads_path); return false; } + // Restore the companion message counter carried here for the segmented msgs + // store (older writers left it 0; the msgs-file loaders overwrite it anyway + // when they run, and the mesh re-asserts it with every message). + if (hdr.msgcount) _msgcount = static_cast(hdr.msgcount); UiHistoryThread t{}; for (int i = 0; i < MAX_UI_THREADS; ++i) { @@ -41414,6 +41448,175 @@ bool UITask::loadMsgsFromStorage() { #endif } +// Segmented-store loader (generation 3). Reads every discovered segment +// oldest-first into the ring, quarantining corrupt segments INDIVIDUALLY (the +// single-file store threw the entire history away on one bad record), keeping +// the newest <= cap records when the on-disk set is larger than this boot's +// ring (card written under the deep cap, loaded under the small one), and +// leaving the ring LINEAR (slot 0 = oldest, head = n) — the same post-load +// invariant every ring consumer relies on. Returns false when no segment +// files exist at all, so the caller can fall back to the older formats. +bool UITask::loadMsgsFromSegments() { +#if defined(ESP32) + uint32_t seqs[k_ui_seg_max]; + const int nseg = uiSegScan(seqs, k_ui_seg_max, true /*sweep orphaned tmps*/); + s_seg_count = 0; + s_seg_total_bytes = 0; + if (nseg <= 0) return false; + + WdtHeavyGuard _wg; // ~20 segment reads on a cold card can take a while + int w = 0; // running chronological index; ring slot = w % cap + uint32_t max_seq = 0; + for (int si = 0; si < nseg; ++si) { + File f; + uint16_t rec_sz = 0; + if (!uiSegOpenValidated(seqs[si], f, &rec_sz)) { + uiSegRemoveFile(seqs[si]); // quarantine THIS segment only + continue; + } + UiSegInfo info{}; + info.first_seq = seqs[si]; + UIMessage rec; + uint32_t seq = 0; + uint16_t nrec = 0; + while (uiSegReadRec(f, rec_sz, &rec, &seq)) { // stops at EOF or a crash-ragged tail + if (rec.seq == 0) rec.seq = max_seq + 1; // defensive backfill (pre-seq record) + if (rec.seq > max_seq) max_seq = rec.seq; + info.last_seq = rec.seq; + _ui_msgs[w % _ui_msg_cap] = rec; + ++w; + ++nrec; + } + f.close(); + info.disk_recs = nrec; + info.live_recs = nrec; + info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz; + if (nrec == 0) { uiSegRemoveFile(seqs[si]); continue; } // header-only husk — reclaim + if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = info; + } + + const int total = w; + const int n_keep = total > _ui_msg_cap ? _ui_msg_cap : total; + if (total > _ui_msg_cap) { + // The ring wrapped while loading: the oldest KEPT record sits at slot + // total % cap. Rotate left by that amount so the ring is linear again. + // Three-reversal rotation: O(cap) record swaps, no large temp buffer. + const int cap = _ui_msg_cap; + const int k = total % cap; + if (k > 0) { + auto rev = [this](int a, int b) { + while (a < b) { + UIMessage t = _ui_msgs[a]; + _ui_msgs[a] = _ui_msgs[b]; + _ui_msgs[b] = t; + ++a; --b; + } + }; + rev(0, k - 1); + rev(k, cap - 1); + rev(0, cap - 1); + } + } + + // Reconcile the table with any dropped-oldest records: fully-dropped + // segments violate the disk == ring invariant and are deleted outright; the + // boundary segment keeps its file but is marked compact-dirty so the next + // compaction (rewritten from the RAM ring) trims it to the kept records. + const uint32_t min_kept_seq = n_keep > 0 ? _ui_msgs[0].seq : 0; + int out = 0; + uint32_t total_bytes = 0; + for (int i = 0; i < s_seg_count; ++i) { + if (n_keep == 0 || s_seg[i].last_seq < min_kept_seq) { + uiSegRemoveFile(s_seg[i].first_seq); + continue; + } + if (s_seg[i].first_seq < min_kept_seq) s_seg[i].compact_dirty = 1; + total_bytes += s_seg[i].bytes; + s_seg[out++] = s_seg[i]; + } + s_seg_count = out; + s_seg_total_bytes = total_bytes; + + _ui_msg_count = n_keep; + _ui_msg_head = n_keep % _ui_msg_cap; + _ui_seq_next = max_seq + 1; + // _msgcount isn't stored in segments; the threads-file header carries it and + // the mesh layer re-asserts it with every message. Keep it monotonic vs seq. + if ((int)max_seq > _msgcount) _msgcount = (int)max_seq; + s_seg_store_ready = true; + return true; +#else + return false; +#endif +} + +// One-time migration: the ring was just loaded from an OLD format (split v6 +// single file or the legacy combined file) — write it out as segments, read +// every segment back and verify counts + seq bounds, and only then delete the +// old msgs file. Any failure rolls the segment set back completely so the +// intact old file stays authoritative on the next boot (a partial segment set +// would otherwise shadow it in the loader's precedence order). +bool UITask::migrateRingToSegments() { +#if defined(ESP32) + uiDataEnsureDirs(); + s_seg_count = 0; + s_seg_total_bytes = 0; + const int n = _ui_msg_count; // ring is linear post-load: slots 0..n-1, oldest first + bool ok = true; + for (int base = 0; base < n && ok; base += k_ui_seg_records) { + int cnt = n - base; + if (cnt > k_ui_seg_records) cnt = k_ui_seg_records; + const uint32_t fseq = _ui_msgs[base].seq; + ok = uiSegCompactWrite(fseq, &_ui_msgs[base], cnt, true /*sync-writer tmp*/); + if (ok && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = fseq; + info.last_seq = _ui_msgs[base + cnt - 1].seq; + info.disk_recs = info.live_recs = (uint16_t)cnt; + info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)cnt * (uint32_t)sizeof(UiSegMsg); + s_seg[s_seg_count++] = info; + } + } + if (ok) { + // Read-back verification: every segment must reproduce its exact record + // count and seq bounds before the old file may be deleted. + for (int i = 0; ok && i < s_seg_count; ++i) { + File f; + uint16_t rec_sz = 0; + if (!uiSegOpenValidated(s_seg[i].first_seq, f, &rec_sz)) { ok = false; break; } + UIMessage r; + uint32_t seq = 0, first = 0, last = 0; + uint16_t cnt = 0; + while (uiSegReadRec(f, rec_sz, &r, &seq)) { + if (cnt == 0) first = seq; + last = seq; + ++cnt; + } + f.close(); + ok = (cnt == s_seg[i].disk_recs && + first == s_seg[i].first_seq && + last == s_seg[i].last_seq); + } + } + if (!ok) { + for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); + s_seg_count = 0; + s_seg_total_bytes = 0; + return false; // old file untouched; the scheduler retries the migration later + } + uint32_t total_bytes = 0; + for (int i = 0; i < s_seg_count; ++i) total_bytes += s_seg[i].bytes; + s_seg_total_bytes = total_bytes; + uiDataRemove(k_ui_msgs_path); + uiDataRemove(k_ui_msgs_tmp_path); + uiDataRemove(k_ui_msgs_tmp2_path); + s_seg_store_ready = true; + return true; +#else + return false; +#endif +} + bool UITask::loadLegacyHistoryFromStorage() { #if defined(ESP32) File f = uiDataOpen(k_ui_history_path, "r"); @@ -41507,18 +41710,28 @@ bool UITask::loadLegacyHistoryFromStorage() { bool UITask::loadHistoryFromStorage() { #if defined(ESP32) - // Hygiene: a crash/power-cut mid-flush can orphan a temp file; they're never - // read (the loader only opens the final path), just reclaim the space. - uiDataRemove(k_ui_msgs_tmp_path); - uiDataRemove(k_ui_msgs_tmp2_path); const bool have_threads = loadThreadsFromStorage(); - const bool have_msgs = loadMsgsFromStorage(); - if (!have_threads && !have_msgs) { - // Neither split file found — try the legacy combined format for migration. - if (!loadLegacyHistoryFromStorage()) return false; - // Schedule writes of the new split files; old file is left in place. - markThreadsDirty(500); - markMsgsDirty(500); + // Format precedence: segments (generation 3) > split v6 single file > + // legacy combined file. The older loaders are retained as migration + // readers; whenever one of them supplies the ring, migrateRingToSegments() + // below converts it (verify-then-delete, rollback on any failure). + const bool from_segments = loadMsgsFromSegments(); + bool have_msgs = from_segments; + if (!from_segments) { + // Single-file-era hygiene: a crash mid-flush can orphan a temp file; + // they're never read, just reclaim the space. (Segment tmps are swept by + // the scan inside loadMsgsFromSegments.) + uiDataRemove(k_ui_msgs_tmp_path); + uiDataRemove(k_ui_msgs_tmp2_path); + have_msgs = loadMsgsFromStorage(); + if (!have_threads && !have_msgs) { + // Neither split file found — try the legacy combined format. + if (!loadLegacyHistoryFromStorage()) return false; + // Schedule the split threads-file write; messages land in segments via + // the migration below (the legacy combined file is left in place, as + // the split migration always did). + markThreadsDirty(500); + } } // Clear persisted unread counts for threads whose messages are no longer in // the ring. The display ring is a fixed-size cache independent of the unread @@ -41533,16 +41746,19 @@ bool UITask::loadHistoryFromStorage() { _ui_threads[i].has_mention = false; } } - // Backfill per-record sequence numbers for records loaded from the pre-segment - // formats (the single-file/legacy records carry no seq on disk): stamp them in - // chronological order and seed the generator past them. The segmented loader - // will restore REAL seqs from disk and skip this backfill. - { + if (!from_segments) { + // Backfill per-record sequence numbers for records loaded from the + // pre-segment formats (their records carry no seq on disk): stamp them in + // chronological order, seed the generator past them, then convert the + // ring to segments. A failed migration leaves the old file authoritative + // and s_seg_store_ready false — the flush scheduler retries it before it + // will write any segment. for (int i = 0; i < _ui_msg_count; ++i) { const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; _ui_msgs[slot].seq = (uint32_t)(i + 1); } _ui_seq_next = (uint32_t)_ui_msg_count + 1; + migrateRingToSegments(); } return true; #else @@ -41562,6 +41778,9 @@ bool UITask::saveThreadsToStorage() { hdr.thread_rec_size = static_cast(sizeof(UiHistoryThread)); hdr.active_thread_idx = static_cast(_active_thread_idx); hdr.active_thread_is_channel = _active_thread_is_channel ? 1u : 0u; + // The segmented msgs store doesn't carry the companion message counter — + // this (frequently-rewritten, small) file does instead. + hdr.msgcount = static_cast(_msgcount); if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; } diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 3093dd8..d52a4f2 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -243,6 +243,8 @@ private: bool loadThreadsFromStorage(); bool loadMsgsFromStorage(); bool loadLegacyHistoryFromStorage(); + bool loadMsgsFromSegments(); // segmented store (generation 3) loader + bool migrateRingToSegments(); // one-time old-format -> segments migration (verify-then-delete) bool saveThreadsToStorage(); bool saveMsgsToStorage(); From 2c7ea98d42ae2f8c2f7a1f50555d4efc13110338 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 22:59:14 +0200 Subject: [PATCH 22/99] touch: segmented-store flush scheduler (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flush pipeline now moves one bounded JOB at a time instead of the whole ring: an append batch into the active segment (a few hundred bytes per message burst) or a one-segment compaction rewritten from the RAM ring. The loop task builds and COMMITS jobs (single table writer); the core-1 hist_flush worker only executes the armed job and reports — same busy/req protocol, same failure ladder (retry backoff, warning toast, hist_sync_after sync-fallback), all ported verbatim. - Durability watermark (s_seg_flushed_seq): ring records above it are the pending-append backlog. Records deleted before ever flushing advance it with no write. Job snapshot buffer is one segment (~80 KB PSRAM) replacing the whole-ring 1.3 MB snapshot. - markMsgsDirty clamps drop from 30 s/10 s (whole-ring-rewrite pacing) to 2 s SD / 5 s SPIFFS — the hard-cut loss window shrinks ~15x. - Deletes (message/thread/purge/web) mark owning segments compact-dirty; eviction unlinks a segment only when ALL its records aged out (no per-message rewrite churn; a partially-evicted boundary segment is trimmed by the next boot's loader). - saveMsgsToStorage is now the synchronous drain (shutdown/reboot/ delete flows/sync-fallback): it retries a failed migration, honors resync, and never uses FILE_APPEND (a worker stalled past the 9 s idle wait could hold an append handle on the same file — the active segment is rewritten via the .tm2 namespace instead, rename-over wins with each candidate internally consistent). - flushHistorySoon (remount paths) now sets s_seg_resync: the segment table is rebuilt from the ring, stale/foreign on-disk segments are removed, and the full history re-lands one segment at a time off-thread — replacing the old 'full-ring write fixes everything' property without the >30 s UI freeze. - Retired: uiWriteMsgsFile, uiHistWorkerFlush, the ring-sized snapshot. Builds verified: all five SD-relevant touch envs. Per .notes/segmented-store-plan.md phase 4. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 530 +++++++++++++++++++++++++++------------- 1 file changed, 360 insertions(+), 170 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 82c32c1..315f8ee 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4584,10 +4584,9 @@ static volatile bool s_sdinfo_request = false; // UI -> worker: rescan SD usag // (Heltec V4); on the loop thread that froze the whole UI, incl. touch wake // (the "ui:hist 6140ms" field stall). The loop thread snapshots the ring, the // worker writes the snapshot; shutdown/reboot still write synchronously. -static volatile bool s_hist_flush_req = false; // snapshot armed, waiting for the worker -static volatile bool s_hist_flush_busy = false; // worker owns the snapshot + the history file +static volatile bool s_hist_flush_req = false; // job armed, waiting for the worker +static volatile bool s_hist_flush_busy = false; // worker owns the job snapshot + its segment file static volatile bool s_hist_flush_ok = true; // last worker write result (retry on false) -static bool uiHistWorkerFlush(); // defined with the storage code below // Message-ring write health. A failing ui_msgs write used to be 100% silent — // the user only found out on the next reboot as "messages from the last N // minutes vanished" (the thread list still showed fresh times because the @@ -4655,6 +4654,33 @@ static volatile bool s_seg_resync = false; // card swapped/remounted: rewr // the still-authoritative old-format file on the next boot) — it retries the // migration instead. static bool s_seg_store_ready = false; +// Durability watermark: highest seq that has landed in a segment file. Ring +// records with seq above it are the pending-append backlog. Loop-task-owned; +// advanced only when a write is COMMITTED (worker job observed ok, or a sync +// write returned true). +static uint32_t s_seg_flushed_seq = 0; + +// One flush job in flight at a time (the existing busy/req protocol). Built +// and committed on the loop task; the hist_flush worker (or the sync path) +// only executes it. The snapshot buffer holds at most one segment's records +// (~80 KB PSRAM) — it replaces the old whole-ring snapshot (1.3 MB). +enum : uint8_t { SEGJOB_NONE = 0, SEGJOB_APPEND, SEGJOB_COMPACT }; +static uint8_t s_segjob_kind = SEGJOB_NONE; +static uint32_t s_segjob_first_seq = 0; // target segment (filename key) +static uint32_t s_segjob_last_seq = 0; // newest seq in the job's records +static bool s_segjob_create = false; // APPEND: file doesn't exist yet (write header) +static int s_segjob_n = 0; +static UITask::UIMessage* s_segjob_buf = nullptr; +// fwd decls — scheduler helpers live with the storage code below. +static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, int head); +static void segCommitJob(); +static bool segMoreWorkPending(uint32_t newest_seq); +static bool uiSegRunArmedJob(); +static void segMarkSeqDirty(uint32_t seq); +static void segNoteEvicted(uint32_t seq); +static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq); +static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out); // fwd decls — the ops live with the storage code far below. static void uiDataEnsureDirs(); static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps); @@ -24642,7 +24668,7 @@ static void histFlushTaskFn(void*) { if (s_hist_flush_req) { s_hist_flush_busy = true; s_hist_flush_req = false; - s_hist_flush_ok = uiHistWorkerFlush(); + s_hist_flush_ok = uiSegRunArmedJob(); // segmented store: one append/compact per job s_hist_flush_busy = false; } vTaskDelay(pdMS_TO_TICKS(100)); @@ -41093,15 +41119,12 @@ void UITask::markThreadsDirty(unsigned long delay_ms) { } void UITask::markMsgsDirty(unsigned long delay_ms) { - // Every flush rewrites the WHOLE ring file on the loop thread, so space the - // writes out: at least 30 s for the deep SD ring (~1.1 MB at 5000 slots) and at - // least 10 s for the small internal-flash ring (~115 KB) — a busy channel used - // to trigger a full rewrite (+ possible SPIFFS GC) ~2 s after EVERY message, - // which read as "the device freezes 1-2 s every ~10 s" on stable beta_31. - // persistHistoryNow() still forces a write on reboot/shutdown, so a hard crash - // costs at most the last few seconds of messages. - if (_ui_msg_cap > MAX_UI_MESSAGES) { if (delay_ms < 30000) delay_ms = 30000; } - else { if (delay_ms < 10000) delay_ms = 10000; } + // Segmented store: a flush is now a small APPEND to the active segment + // (~240 B per message, off-thread), so the old 10/30 s whole-ring-rewrite + // clamps are gone — the hard-cut loss window shrinks to this coalesce. + // SPIFFS keeps a slightly higher floor purely as flash-wear pacing. + if (uiDataFsIsSdCard()) { if (delay_ms < 2000) delay_ms = 2000; } + else { if (delay_ms < 5000) delay_ms = 5000; } const unsigned long deadline = millis() + delay_ms; if (!_msgs_dirty || deadline < _next_msgs_flush_ms) _next_msgs_flush_ms = deadline; @@ -41109,21 +41132,14 @@ void UITask::markMsgsDirty(unsigned long delay_ms) { markThreadsDirty(1500); // thread state (last_ts, unread) changed too — coalesce a message burst into one write } -// Snapshot of the message ring handed to the core-0 worker for the off-thread -// write. Allocated once in PSRAM (ring-sized: ~115 KB SPIFFS ring / ~1.1 MB SD -// ring); the worker only ever touches the snapshot, never the live ring. -static UITask::UIMessage* s_hist_snap = nullptr; -static int s_hist_snap_cap = 0; -static uint16_t s_hist_snap_count = 0; -static uint16_t s_hist_snap_head = 0; -static uint32_t s_hist_snap_msgcount = 0; - // Arm an immediate flush of both history files WITHOUT the min-delay clamp and -// WITHOUT blocking this task: the actual write still happens on the core-0 -// worker (or the sync fallback inside flushHistoryIfDue). Used right after an -// SD remount — a synchronous persistHistoryNow() there froze the UI for >30 s -// on a card that mounts fine but times out on large writes. +// WITHOUT blocking this task: the actual writes happen on the hist_flush +// worker (or the sync fallback inside flushHistoryIfDue). Called only from the +// SD remount paths — the card may be a DIFFERENT one now, so the segment +// table is rebuilt from the ring (s_seg_resync) and the full history re-lands +// one bounded segment at a time. void UITask::flushHistorySoon() { + s_seg_resync = true; _msgs_dirty = true; _next_msgs_flush_ms = millis(); _threads_dirty = true; @@ -41161,12 +41177,20 @@ void UITask::flushHistoryIfDue(unsigned long now) { } else if (saveThreadsToStorage()) _threads_dirty = false; else _next_threads_flush_ms = now + 2000; } + // Observe a finished worker job before anything else: the worker only + // executes and reports; its table effects (segment sizes, the durability + // watermark) are committed HERE on the loop task, so the table has exactly + // one writer. + if (s_segjob_kind != SEGJOB_NONE && !s_hist_flush_busy && !s_hist_flush_req) { + if (s_hist_flush_ok) segCommitJob(); + s_segjob_kind = SEGJOB_NONE; // on failure the !ok branch above already re-armed the retry + } if (_msgs_dirty && now >= _next_msgs_flush_ms) { if (s_hist_flush_busy || s_hist_flush_req) { // A request is armed but the flush task may not be up yet — kick it so a // pending flush can't sit armed forever while messages live only in RAM. ensureHistFlushTaskRunning(); - _next_msgs_flush_ms = now + 1000; // one flush in flight; new messages ride the next one + _next_msgs_flush_ms = now + 1000; // one job in flight; new messages ride the next one return; } #if defined(ESP32) @@ -41183,37 +41207,51 @@ void UITask::flushHistoryIfDue(unsigned long now) { _next_msgs_flush_ms = now + (s_msgs_write_fails >= 5 ? 300000 : 5000); return; } - // Snapshot the ring (a few ms of PSRAM memcpy) and hand the write to the - // core-1 hist_flush task (NOT the core-0/Wi-Fi tile worker — see - // histFlushTaskFn). Messages arriving while it writes stay in the live - // ring and re-arm the dirty flag, so they land in the next flush. - if (!s_hist_snap || s_hist_snap_cap != _ui_msg_cap) { - if (s_hist_snap) { heap_caps_free(s_hist_snap); s_hist_snap = nullptr; } - s_hist_snap = (UITask::UIMessage*)heap_caps_malloc(sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap, - MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - s_hist_snap_cap = s_hist_snap ? _ui_msg_cap : 0; - } - if (s_hist_snap) { - memcpy(s_hist_snap, _ui_msgs, sizeof(UITask::UIMessage) * (size_t)_ui_msg_cap); - s_hist_snap_count = (uint16_t)_ui_msg_count; - s_hist_snap_head = (uint16_t)_ui_msg_head; - s_hist_snap_msgcount = (uint32_t)_msgcount; - if (!ensureHistFlushTaskRunning()) { - // No worker — fall back to a synchronous write instead of clearing dirty - // and losing the snapshot (the whole point of the worker is to stay off - // the loop thread; a rare spawn failure must not drop hours of chat). - if (saveMsgsToStorage()) _msgs_dirty = false; - else _next_msgs_flush_ms = now + 2000; - return; - } - s_hist_flush_req = true; // the flush task picks it up - _msgs_dirty = false; + // A failed boot migration means the old-format file is still the on-disk + // truth — segments must not be written until it converts (they would + // shadow it in the loader's precedence order). The sync drain retries + // the migration. + if (!s_seg_store_ready) { + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + 60000; // store is sick; don't thrash the bus return; } -#endif - // No PSRAM for a snapshot: fall back to the old synchronous write. + // Card remounted (possibly a different/blank card): rebuild the segment + // table from the ring — stale on-disk segments are removed and every + // chunk is marked compact-dirty, so the normal job flow below re-lands + // the whole history one bounded segment at a time, off-thread. + if (s_seg_resync) { + segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + _ui_seq_next ? _ui_seq_next - 1 : 0); + s_seg_resync = false; + } + // Build the next off-thread job: pending-append batch first, else the + // oldest compact-dirty segment. The snapshot is at most ONE segment's + // records (~80 KB) — not the old 1.3 MB whole-ring copy. + const int armed = segBuildAndArmJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head); + if (armed == 0) { _msgs_dirty = false; return; } // everything already durable + if (armed < 0 || !ensureHistFlushTaskRunning()) { + // No snapshot buffer / no worker — synchronous drain instead of losing + // the flush (a rare spawn failure must not drop hours of chat). + s_hist_flush_req = false; + s_segjob_kind = SEGJOB_NONE; + if (saveMsgsToStorage()) { _msgs_dirty = false; return; } + _next_msgs_flush_ms = now + 2000; + return; + } + // One job armed for the worker. A multi-segment backlog (burst bigger + // than one segment, or several dirty segments) keeps _msgs_dirty set and + // reschedules shortly; each cycle moves one segment. + if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) { + _next_msgs_flush_ms = now + 1500; + return; + } + _msgs_dirty = false; + return; +#else if (saveMsgsToStorage()) _msgs_dirty = false; else _next_msgs_flush_ms = now + 2000; +#endif } } @@ -41540,6 +41578,7 @@ bool UITask::loadMsgsFromSegments() { _ui_msg_count = n_keep; _ui_msg_head = n_keep % _ui_msg_cap; _ui_seq_next = max_seq + 1; + s_seg_flushed_seq = max_seq; // everything loaded IS on disk by definition // _msgcount isn't stored in segments; the threads-file header carries it and // the mesh layer re-asserts it with every message. Keep it monotonic vs seq. if ((int)max_seq > _msgcount) _msgcount = (int)max_seq; @@ -41610,6 +41649,8 @@ bool UITask::migrateRingToSegments() { uiDataRemove(k_ui_msgs_path); uiDataRemove(k_ui_msgs_tmp_path); uiDataRemove(k_ui_msgs_tmp2_path); + s_seg_flushed_seq = s_seg_count ? s_seg[s_seg_count - 1].last_seq : 0; + s_seg_resync = false; // a full write IS the resync s_seg_store_ready = true; return true; #else @@ -42114,130 +42155,272 @@ static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps) { return n; } -static bool uiWriteMsgsFile(const UITask::UIMessage* msgs, int cap, - uint16_t count, uint16_t head, uint32_t msgcount, - const char* tmp_path) { -#if defined(ESP32) - WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC - errno = 0; - File f = uiDataOpen(tmp_path, "w"); - if (!f) return uiMsgsWriteFail('o'); +// ---- Segmented store: flush scheduling ------------------------------------ +// All of this state is LOOP-TASK-owned; the hist_flush worker only runs +// uiSegRunArmedJob() and reports through s_hist_flush_ok. - UiMsgFileHeader hdr{}; - hdr.magic = k_ui_msgs_magic; - hdr.version = k_ui_history_version; - hdr.msg_rec_size = static_cast(sizeof(UiHistoryMsg)); - // Compact write: emit only the `count` USED records, oldest-first, instead of one - // record per ring slot (cap). A user with 200 messages writes ~48 KB, not the full - // ~1.2 MB ring, so the delete/clear/reboot flush no longer hitches the UI (that - // full-ring rewrite was the "deleting a message is slow" cause). The reader derives - // its slot count from the file size and linearizes by (head,count), so an - // oldest-first file with head=0 loads byte-for-byte identically. - const int wcap = cap > 0 ? cap : 1; - const int used = (int)count > cap ? cap : (int)count; - const int oldst = (((int)head - used) % wcap + wcap) % wcap; - // Deleted messages are tombstones IN RAM (thread[0] == '\0'; the ring can't - // compact in place — slot indexes are live UI handles), but writing them out - // kept the file at its high-water size forever: delete 1000 messages and - // every flush still rewrote them all as full blank records. Skip them here — - // the file compacts to the LIVE records on the next flush, shrinking both - // the write time and the flash wear. Geometry is unaffected: records stay - // oldest-first with head=0 and count = live records actually emitted. - int live = 0; - for (int k = 0; k < used; ++k) - if (msgs[(oldst + k) % wcap].thread[0]) ++live; - hdr.ui_msg_count = static_cast(live); - hdr.ui_msg_head = 0; // written oldest-first => the file's ring head sits at 0 - hdr.msgcount = msgcount; - if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { - f.close(); uiDataRemove(tmp_path); - return uiMsgsWriteFail('h'); // was silently un-counted before +// Snapshot buffer for one job (<= one segment of records). Lazy, kept for the +// session — it replaces the old whole-ring snapshot at ~6% of the size. +static UITask::UIMessage* segEnsureJobBuf() { + if (!s_segjob_buf) { + const size_t sz = sizeof(UITask::UIMessage) * (size_t)k_ui_seg_records; + s_segjob_buf = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!s_segjob_buf) s_segjob_buf = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_8BIT); } - - const size_t REC = sizeof(UiHistoryMsg); - size_t chunk_recs = 6144 / REC; - if (chunk_recs < 1) chunk_recs = 1; - uint8_t* buf = (uint8_t*)malloc(REC * chunk_recs); // internal RAM by default - bool ok = true; - if (buf) { - size_t fill = 0; - for (int k = 0; ok && k < used; ++k) { - const int i = (oldst + k) % wcap; - if (!msgs[i].thread[0]) continue; // tombstone (deleted) — never hits the file - UiHistoryMsg* m = reinterpret_cast(buf + fill); - memset(m, 0, REC); - m->ts = msgs[i].ts; - m->channel = msgs[i].channel ? 1u : 0u; - m->outgoing = msgs[i].outgoing ? 1u : 0u; - m->meta_flags = msgs[i].meta_flags; - m->path_len = msgs[i].path_len; - m->snr_q4 = msgs[i].snr_q4; - m->rssi = msgs[i].rssi; - strncpy(m->thread, msgs[i].thread, sizeof(m->thread) - 1); - m->thread[sizeof(m->thread) - 1] = '\0'; - strncpy(m->sender, msgs[i].sender, sizeof(m->sender) - 1); - m->sender[sizeof(m->sender) - 1] = '\0'; - strncpy(m->text, msgs[i].text, sizeof(m->text) - 1); - m->text[sizeof(m->text) - 1] = '\0'; - fill += REC; - if (fill == REC * chunk_recs) { - ok = (f.write(buf, fill) == fill); - fill = 0; - } - } - if (ok && fill > 0) ok = (f.write(buf, fill) == fill); - free(buf); - } else { - UiHistoryMsg m{}; - for (int k = 0; ok && k < used; ++k) { - const int i = (oldst + k) % wcap; - if (!msgs[i].thread[0]) continue; // tombstone (deleted) — never hits the file - memset(&m, 0, sizeof(m)); - m.ts = msgs[i].ts; - m.channel = msgs[i].channel ? 1u : 0u; - m.outgoing = msgs[i].outgoing ? 1u : 0u; - m.meta_flags = msgs[i].meta_flags; - m.path_len = msgs[i].path_len; - m.snr_q4 = msgs[i].snr_q4; - m.rssi = msgs[i].rssi; - strncpy(m.thread, msgs[i].thread, sizeof(m.thread) - 1); - m.thread[sizeof(m.thread) - 1] = '\0'; - strncpy(m.sender, msgs[i].sender, sizeof(m.sender) - 1); - m.sender[sizeof(m.sender) - 1] = '\0'; - strncpy(m.text, msgs[i].text, sizeof(m.text) - 1); - m.text[sizeof(m.text) - 1] = '\0'; - ok = (f.write(reinterpret_cast(&m), sizeof(m)) == sizeof(m)); - } - } - f.close(); - if (!ok) { uiDataRemove(tmp_path); return uiMsgsWriteFail('b'); } - errno = 0; - if (!uiDataReplaceFile(k_ui_msgs_path, tmp_path)) return uiMsgsWriteFail('r'); - return uiMsgsWriteResult(true); -#else - (void)msgs; (void)cap; (void)count; (void)head; (void)msgcount; (void)tmp_path; - return false; -#endif + return s_segjob_buf; } -// Worker-side entry: write the snapshot the loop thread armed (core-0 task; the -// loop thread never waits on this, which is the whole point). -static bool uiHistWorkerFlush() { - if (!s_hist_snap || s_hist_snap_cap <= 0) return false; // armed without a snapshot — retry - return uiWriteMsgsFile(s_hist_snap, s_hist_snap_cap, - s_hist_snap_count, s_hist_snap_head, s_hist_snap_msgcount, - k_ui_msgs_tmp_path); +// Live records with seq in [lo, hi], chronological, from the ring. +static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out) { + int n = 0; + for (int i = 0; i < count && n < max_out; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (!m.thread[0]) continue; // tombstone + if (m.seq < lo || m.seq > hi) continue; + out[n++] = m; + } + return n; +} + +// Build the next job into the descriptor + snapshot buffer and raise req. +// Returns 1 = job armed, 0 = nothing pending (all durable), -1 = no buffer. +static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, int head) { + if (!segEnsureJobBuf()) return -1; + // 1) Pending appends: live records newer than the durability watermark, + // chronological, capped to the room left in the active segment. + bool create = true; + uint32_t target = 0; + int room = k_ui_seg_records; + if (s_seg_count > 0 && s_seg[s_seg_count - 1].disk_recs < k_ui_seg_records) { + create = false; + target = s_seg[s_seg_count - 1].first_seq; + room = k_ui_seg_records - (int)s_seg[s_seg_count - 1].disk_recs; + } + int n = 0; + uint32_t last = s_seg_flushed_seq; + for (int i = 0; i < count; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (m.seq <= s_seg_flushed_seq) continue; + if (m.thread[0]) { + if (n >= room) break; // segment full — the rest rides the next cycle + s_segjob_buf[n++] = m; + } + if (m.seq > last) last = m.seq; // deleted-before-flush records advance the + // watermark with no write (never hit disk) + } + if (n > 0) { + s_segjob_kind = SEGJOB_APPEND; + s_segjob_first_seq = create ? s_segjob_buf[0].seq : target; + s_segjob_last_seq = last; + s_segjob_create = create; + s_segjob_n = n; + s_hist_flush_req = true; + return 1; + } + if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail + // 2) Oldest compact-dirty segment: rewrite it from the ring's live records. + for (int i = 0; i < s_seg_count; ++i) { + if (!s_seg[i].compact_dirty) continue; + const int cn = segGatherRange(ring, cap, count, head, + s_seg[i].first_seq, s_seg[i].last_seq, + s_segjob_buf, k_ui_seg_records); + s_segjob_kind = SEGJOB_COMPACT; + s_segjob_first_seq = s_seg[i].first_seq; + s_segjob_last_seq = cn > 0 ? s_segjob_buf[cn - 1].seq : 0; + s_segjob_create = false; + s_segjob_n = cn; // 0 = every record gone -> unlink the file + s_hist_flush_req = true; + return 1; + } + return 0; +} + +// Apply a successfully-executed job to the table (loop task only). +static void segCommitJob() { + if (s_segjob_kind == SEGJOB_APPEND) { + if (s_segjob_create && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = s_segjob_first_seq; + info.bytes = (uint32_t)sizeof(UiSegHeader); + s_seg[s_seg_count++] = info; + s_seg_total_bytes = s_seg_total_bytes + (uint32_t)sizeof(UiSegHeader); + } + if (s_seg_count > 0) { + UiSegInfo& a = s_seg[s_seg_count - 1]; + a.disk_recs = (uint16_t)(a.disk_recs + s_segjob_n); + a.live_recs = (uint16_t)(a.live_recs + s_segjob_n); + if (s_segjob_last_seq > a.last_seq) a.last_seq = s_segjob_last_seq; + const uint32_t add = (uint32_t)s_segjob_n * (uint32_t)sizeof(UiSegMsg); + a.bytes += add; + s_seg_total_bytes = s_seg_total_bytes + add; + } + if (s_segjob_last_seq > s_seg_flushed_seq) s_seg_flushed_seq = s_segjob_last_seq; + } else if (s_segjob_kind == SEGJOB_COMPACT) { + for (int i = 0; i < s_seg_count; ++i) { + if (s_seg[i].first_seq != s_segjob_first_seq) continue; + s_seg_total_bytes = (s_seg_total_bytes >= s_seg[i].bytes) + ? s_seg_total_bytes - s_seg[i].bytes : 0; + if (s_segjob_n == 0) { + for (int k = i; k + 1 < s_seg_count; ++k) s_seg[k] = s_seg[k + 1]; + --s_seg_count; + } else { + s_seg[i].disk_recs = s_seg[i].live_recs = (uint16_t)s_segjob_n; + s_seg[i].last_seq = s_segjob_last_seq; + s_seg[i].bytes = (uint32_t)sizeof(UiSegHeader) + + (uint32_t)s_segjob_n * (uint32_t)sizeof(UiSegMsg); + s_seg[i].compact_dirty = 0; + s_seg_total_bytes = s_seg_total_bytes + s_seg[i].bytes; + } + break; + } + } +} + +static bool segMoreWorkPending(uint32_t newest_seq) { + if (newest_seq > s_seg_flushed_seq) return true; + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].compact_dirty) return true; + return false; +} + +// Executed by the hist_flush worker (or inline by the sync drain's caller). +static bool uiSegRunArmedJob() { + switch (s_segjob_kind) { + case SEGJOB_APPEND: + return uiSegAppendRecords(s_segjob_first_seq, s_segjob_create, s_segjob_buf, s_segjob_n); + case SEGJOB_COMPACT: + return uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, s_segjob_n, false); + default: + return true; + } +} + +// A record with this seq was tombstoned (deleted) — mark its segment for +// compaction. Unflushed records (seq above the watermark) need nothing: the +// append builder skips tombstones, so they simply never reach disk. +static void segMarkSeqDirty(uint32_t seq) { + if (seq == 0 || seq > s_seg_flushed_seq) return; + for (int i = 0; i < s_seg_count; ++i) { + if (seq >= s_seg[i].first_seq && seq <= s_seg[i].last_seq) { + s_seg[i].compact_dirty = 1; + return; + } + } +} + +// The ring overwrote its oldest record (capacity eviction). Deliberately does +// NOT trigger a rewrite per eviction (that would be write-amplification per +// message all over again): the segment file only gets unlinked once ALL its +// records have aged out; a partially-evicted boundary segment keeps its stale +// tail on disk until the next boot's loader trims it (bounded: one segment). +static void segNoteEvicted(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < s_seg_count; ++i) { + if (seq < s_seg[i].first_seq || seq > s_seg[i].last_seq) continue; + if (s_seg[i].live_recs > 0) --s_seg[i].live_recs; + if (s_seg[i].live_recs == 0) s_seg[i].compact_dirty = 1; // job -> unlink (n gathers to 0) + return; + } +} + +// Card remounted (wedge recovery / reinsert / FM insert): the on-disk segment +// set may belong to a different card entirely. Start clean: remove whatever +// segments are found, rebuild the table from the ring's live records with +// every chunk marked compact-dirty — the normal job flow then re-lands the +// full history one bounded segment at a time, and the watermark parks at +// newest_seq so nothing double-appends meanwhile. +static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, + uint32_t newest_seq) { + uint32_t seqs[k_ui_seg_max]; + const int nseg = uiSegScan(seqs, k_ui_seg_max, true); + for (int i = 0; i < nseg; ++i) uiSegRemoveFile(seqs[i]); + s_seg_count = 0; + s_seg_total_bytes = 0; + UiSegInfo cur{}; + int in_chunk = 0; + for (int i = 0; i < count; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (!m.thread[0]) continue; + if (in_chunk == 0) { + cur = UiSegInfo{}; + cur.first_seq = m.seq; + cur.compact_dirty = 1; // "needs writing" — disk_recs stays 0 until the job lands + } + cur.last_seq = m.seq; + ++in_chunk; + if (in_chunk == k_ui_seg_records) { + cur.live_recs = (uint16_t)in_chunk; + if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = cur; + in_chunk = 0; + } + } + if (in_chunk > 0 && s_seg_count < k_ui_seg_max) { + cur.live_recs = (uint16_t)in_chunk; + s_seg[s_seg_count++] = cur; + } + s_seg_flushed_seq = newest_seq; } bool UITask::saveMsgsToStorage() { #if defined(ESP32) - // Synchronous write of the LIVE ring — shutdown/reboot and the no-PSRAM - // fallback only; the periodic flush goes through the worker snapshot. - return uiWriteMsgsFile(_ui_msgs, _ui_msg_cap, - static_cast(_ui_msg_count), - static_cast(_ui_msg_head), - static_cast(_msgcount), - k_ui_msgs_tmp2_path); // own tmp — never collide with a stalled worker write + // Synchronous drain of ALL outstanding message-store work on the LOOP task — + // shutdown/reboot, the delete flows (persistHistoryNow), the sync-fallback + // and the no-worker fallback all land here. Loop-task writes are the + // empirically reliable path on the shared SPI bus. Returns true only when + // everything is durable. + if (!uiDataFsReady()) return false; + if (!s_seg_store_ready) { + // A failed boot migration left the old-format file authoritative — + // converting it IS the flush (verify-then-delete inside). + return migrateRingToSegments(); + } + if (s_seg_resync) { + segRetableFromRing(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + _ui_seq_next ? _ui_seq_next - 1 : 0); + s_seg_resync = false; + } + // Drain job by job, executing each here. Appends are NEVER FILE_APPEND on + // this path: a worker stalled past uiHistWaitWorkerIdle's 9 s cap could + // still hold an append handle on the same file, and two appenders + // interleave records — instead the whole (bounded, <= ~60 KB) active + // segment is rewritten from the ring via the sync tmp namespace, where + // rename-over is last-writer-wins with each candidate internally + // consistent. + for (int guard = 0; guard < k_ui_seg_max + 4; ++guard) { + const int armed = segBuildAndArmJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head); + if (armed == 0) return true; + if (armed < 0) return false; // no snapshot buffer + s_hist_flush_req = false; // executed here, not by the worker + bool ok; + if (s_segjob_kind == SEGJOB_APPEND) { + if (s_segjob_create && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = s_segjob_first_seq; + s_seg[s_seg_count++] = info; + } + const int n2 = segGatherRange(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + s_segjob_first_seq, s_segjob_last_seq, + s_segjob_buf, k_ui_seg_records); + ok = uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, n2, true); + if (ok) { + const uint32_t newest = s_segjob_last_seq; + s_segjob_kind = SEGJOB_COMPACT; // commit with absolute-value semantics + s_segjob_n = n2; + segCommitJob(); + if (newest > s_seg_flushed_seq) s_seg_flushed_seq = newest; + } + } else { + ok = uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, s_segjob_n, true); + if (ok) segCommitJob(); + } + s_segjob_kind = SEGJOB_NONE; + if (!ok) return false; + } + return !segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0); #else return false; #endif @@ -42290,6 +42473,7 @@ bool UITask::removeThread(int idx) { _ui_msgs[i].thread[0] = '\0'; _ui_msgs[i].text[0] = '\0'; _ui_msgs[i].sender[0] = '\0'; + segMarkSeqDirty(_ui_msgs[i].seq); purged_any = true; } // Persist the purge — without this the deleted thread's messages survive in @@ -42512,6 +42696,9 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te int t_idx = findOrCreateThread(thread, channel); if (t_idx < 0) return -1; UIMessage& m = _ui_msgs[_ui_msg_head]; + // Full ring: this append overwrites the oldest record — tell the segment + // table so a fully-aged-out segment file gets unlinked. + if (_ui_msg_count == _ui_msg_cap) segNoteEvicted(m.seq); // Bubble timestamps must match the status-bar clock, which reads the NTP-synced ESP32 // system clock (time(nullptr)) — NOT the mesh RTC (getRTCClock()). On boards whose // hardware RTC chip reads BCD/century garbage, getCurrentTime() returned wrong epochs @@ -43850,6 +44037,7 @@ bool UITask::deleteMessageBySlot(int msg_idx) { m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); // the owning segment needs a compaction pass _msgs_dirty = true; return true; } @@ -43866,6 +44054,7 @@ int UITask::clearThreadHistory(int thread_idx) { m.thread[0] = '\0'; m.text[0] = '\0'; m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); ++cleared; } } @@ -44584,6 +44773,7 @@ bool UITask::sendSignalProbe() { static bool uiHistWaitWorkerIdle() { const bool cancelled = s_hist_flush_req; s_hist_flush_req = false; + if (cancelled) s_segjob_kind = SEGJOB_NONE; // job never ran — its data stays pending in the ring const uint32_t t0 = millis(); while (s_hist_flush_busy && (uint32_t)(millis() - t0) < 9000) delay(10); return cancelled; From 1f01a267dd4f4fcc24719630648d925c12af7f24 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 23:03:37 +0200 Subject: [PATCH 23/99] touch: segmented-store integration + lifecycle fixes (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - About 'Chat store' shows the segment count + writer-maintained byte total (zero file I/O at 1 Hz — the old size probe blocked on the FatFs volume lock behind a sick write) and flags a pending migration. - uiDataEnsureDirs() runs at boot (before the loader scans) and on all three SD remount paths, so a fresh replacement card gets the segment dir immediately. - sdEnsureMeshcomodFolders (post-FAT32-format) now also recreates /meshcomod and /meshcomod/msgs — an in-session format used to break every chat/telemetry write with ENOENT until a reboot. - The boot SPIFFS->SD adoption creates /meshcomod/msgs so flat-named SPIFFS segments ('/msgs/seg_*.bin') copy onto the FAT card instead of being silently skipped (missing parent dir). - sdRestoreApply ('Copy internal data to SD') persists chat history BEFORE the copy — it used to reboot without persisting, silently dropping everything since the last lazy flush. - Dead s_ui_data_resolved removed; stale writer comments updated. Builds verified: all five SD-relevant touch envs. Per .notes/segmented-store-plan.md phase 5. Signed-off-by: Dan Vybiral --- src/main.cpp | 2 ++ src/ui-touch/UITask.cpp | 47 ++++++++++++++++++++++------------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 3cca636..97760fc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -215,6 +215,8 @@ bool meshcomodMigrateSpiffsToSd(bool force) { SD.mkdir("/meshcomod/identity"); SD.mkdir("/meshcomod/bl"); SD.mkdir("/meshcomod/lock"); + SD.mkdir("/meshcomod/msgs"); // chat segments: SPIFFS names them flat ("/msgs/seg_*.bin"), + // but the FAT card needs the real parent dir or every copy fails bool identity_ok = false; int copied = 0, failed = 0; static uint8_t buf[4096]; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 315f8ee..eef0330 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4591,8 +4591,8 @@ static volatile bool s_hist_flush_ok = true; // last worker write result (re // the user only found out on the next reboot as "messages from the last N // minutes vanished" (the thread list still showed fresh times because the // small threads file kept writing while the big ring write failed). Updated -// inside uiWriteMsgsFile from EITHER task (volatile stores only); read by the -// About page + the repeated-failure toast in flushHistoryIfDue. +// inside the segment writers from EITHER task (volatile stores only); read by +// the About page + the repeated-failure toast in flushHistoryIfDue. static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success @@ -10189,20 +10189,11 @@ static void sysInfoTextLive(char* buf, size_t cap) { // succeeding, so the thread list looks fresh while messages silently stop // persisting. { - // File size is CACHED and refreshed at most every 30 s, never while the - // worker is mid-flush: this label rebuilds at 1 Hz on the UI task, and an - // open() here blocks on the FatFs volume lock behind a slow/failing ring - // write — that was a UI freeze you could trigger just by WATCHING this - // page while the store was sick. - static size_t s_chat_fsz_cached = 0; - static uint32_t s_chat_fsz_ms = 0; + // Segmented store: the byte total is writer-maintained (volatile), so this + // 1 Hz label costs ZERO file I/O — the old single-file size probe blocked + // on the FatFs volume lock behind a sick write and froze the UI just by + // WATCHING this page. const uint32_t nowms = (uint32_t)millis(); - if (!s_hist_flush_busy && - (s_chat_fsz_ms == 0 || (uint32_t)(nowms - s_chat_fsz_ms) > 30000u)) { - s_chat_fsz_ms = nowms ? nowms : 1; - File f = uiDataOpen(k_ui_msgs_path, "r"); - if (f) { s_chat_fsz_cached = f.size(); f.close(); } - } char stat[112]; if (s_msgs_write_fails) { char why[64]; @@ -10218,9 +10209,11 @@ static void sysInfoTextLive(char* buf, size_t cap) { else snprintf(stat, sizeof stat, "none this session"); p += snprintf(buf + p, cap - p, - "Chat store\n %s file: %u KB\n last save: %s\n\n", + "Chat store\n %s %d seg / %u KB%s\n last save: %s\n\n", uiDataFsIsSdCard() ? "SD /meshcomod" : "Internal flash", - (unsigned)(s_chat_fsz_cached / 1024), stat); + s_seg_count, (unsigned)(s_seg_total_bytes / 1024u), + s_seg_store_ready ? "" : " (migration pending!)", + stat); } #endif (void)p; (void)cap; @@ -10479,6 +10472,9 @@ static void sdRestoreApply() { if (!fmSdTryMount()) { g_lv.task->showAlert(TR("No SD card"), 1600); return; } g_lv.task->showAlert(TR("Copying internal data to SD..."), 6000); lv_refr_now(nullptr); // paint the notice before the blocking copy + // Land everything in RAM first — this path used to reboot WITHOUT persisting, + // silently dropping every message since the last lazy flush. + g_lv.task->persistHistoryNow(); disableLoopWDT(); const bool ok = meshcomodMigrateSpiffsToSd(true); enableLoopWDT(); @@ -18424,7 +18420,11 @@ static void fmSdMountOrFormatCb(lv_event_t* e) { // SETTINGS (exported config), plus MAPS/LOGS. Idempotent (mkdir-if-absent), so // it's also safe to re-run on an already-set-up card. static void sdEnsureMeshcomodFolders() { - static const char* const folders[] = { "/BINS", "/UPDATES", "/RECBCK", "/SETTINGS", "/MAPS", "/LOGS" }; + // /meshcomod + the chat-segment dir were MISSING here for years: after an + // in-session FAT32 format every chat/telemetry write failed at open (ENOENT) + // until a remount or reboot recreated the data root. + static const char* const folders[] = { "/BINS", "/UPDATES", "/RECBCK", "/SETTINGS", "/MAPS", "/LOGS", + "/meshcomod", "/meshcomod/msgs" }; for (unsigned i = 0; i < sizeof(folders) / sizeof(folders[0]); ++i) if (!SD.exists(folders[i])) SD.mkdir(folders[i]); if (!SD.exists("/README.TXT")) { @@ -24677,9 +24677,9 @@ static void histFlushTaskFn(void*) { static bool ensureHistFlushTaskRunning() { if (s_hist_flush_task != nullptr) return true; if (!s_hist_flush_stack) { - // uiWriteMsgsFile keeps its bulk buffer on the heap; stack use is File - // objects + FatFs path work. 6 KB leaves ample headroom (the tile worker's - // overflow-into-globals history earned the caution). + // The segment writers keep their bulk buffers on the heap; stack use is + // File objects + FatFs path work. 6 KB leaves ample headroom (the tile + // worker's overflow-into-globals history earned the caution). static const size_t k_sz = 6 * 1024; s_hist_flush_stack = (StackType_t*)heap_caps_malloc(k_sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (!s_hist_flush_stack) return false; @@ -41264,7 +41264,6 @@ void UITask::flushHistoryIfDue(unsigned long now) { // the chat to the SD card. static fs::FS* s_ui_data_fs = nullptr; static char s_ui_data_root[16] = ""; -static bool s_ui_data_resolved = false; static bool uiDataFsReady() { if (s_ui_data_fs != nullptr) return true; // cache SUCCESS only — a failed resolve MUST stay retryable #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) @@ -43432,6 +43431,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // linearizes a history file written under either capacity. #if defined(ESP32) _ui_msg_cap = uiDataFsIsSdCard() ? MAX_UI_MESSAGES_SD : MAX_UI_MESSAGES; + uiDataEnsureDirs(); // segment dir exists before the loader scans / the first append #endif size_t msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap; const size_t threads_bytes = sizeof(UIThread) * MAX_UI_THREADS; @@ -45164,6 +45164,7 @@ static void sdHealthTick() { // a fresh replacement card has no /meshcomod yet, and without it every // history flush fails silently. SD.mkdir("/meshcomod"); + uiDataEnsureDirs(); // segment dir too — a fresh replacement card has neither if (g_lv.task) g_lv.task->flushHistorySoon(); #endif } else { @@ -45205,6 +45206,7 @@ static void sdHealthTick() { if (g_lv.task) g_lv.task->showAlert(TR("SD card remounted"), 1800); #if defined(HAS_TDECK_GT911) SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + uiDataEnsureDirs(); // segment dir too if (g_lv.task) g_lv.task->flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif } else { @@ -45663,6 +45665,7 @@ void UITask::loop() { showAlert(TR("SD card inserted"), 1500); #if defined(HAS_TDECK_GT911) SD.mkdir("/meshcomod"); // fresh replacement card: recreate the data root + uiDataEnsureDirs(); // segment dir too flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears From eeefa47fa2e1f551d2930d07a1222db1ed9dab70 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Sun, 26 Jul 2026 23:37:43 +0200 Subject: [PATCH 24/99] touch: fix seven review findings in the segmented-store scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review pass over the refactor surfaced these; the first is a silent-permanent-loss bug: - CRITICAL: the finished-job check ran AFTER the failure branch had already reset s_hist_flush_ok, so a FAILED worker job read as ok and got committed — advancing the durability watermark over records that never reached disk. The completion check now runs strictly first, and a failed job just drops its descriptor (data stays pending). - A message deleted while its APPEND job was in flight resurrected on reboot: segMarkSeqDirty skipped it (seq above the watermark = 'never reaches disk') but the in-flight job then put it on disk. After a committed append the ring's tombstones inside the newly-flushed range now get their compaction marks. - segCommitJob cleared compact_dirty unconditionally, erasing marks for deletes that landed WHILE the compact job was writing (its snapshot predates them). A redirty flag keeps the segment dirty for a follow-up pass. - The sync drain shared the worker's job descriptor, snapshot buffer and req flag: a worker stalled past the 9 s idle-wait cap could read a torn buffer, and the raised req let the worker steal and double-execute a job the drain was running inline. segBuildJob is now pure (fills a caller buffer + local SegJob); only the async path arms the worker statics, and the drain owns a separate buffer. - An in-session FAT32 format erased all segments while the table (and watermark) still claimed them — old records permanently lost and the next append targeted a missing file. The format path now runs the same resync as a card swap (retable + re-land off-thread). - uiSegAppendRecords with create=true used FILE_APPEND, so a stale same-key file (crash residue) got a second header mid-file; create now opens truncating. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 220 ++++++++++++++++++++++++++-------------- 1 file changed, 145 insertions(+), 75 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index eef0330..c2139d2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4665,15 +4665,31 @@ static uint32_t s_seg_flushed_seq = 0; // only executes it. The snapshot buffer holds at most one segment's records // (~80 KB PSRAM) — it replaces the old whole-ring snapshot (1.3 MB). enum : uint8_t { SEGJOB_NONE = 0, SEGJOB_APPEND, SEGJOB_COMPACT }; +struct SegJob { + uint8_t kind = SEGJOB_NONE; + uint32_t first_seq = 0; // target segment (filename key) + uint32_t last_seq = 0; // newest seq covered by the job + bool create = false; // APPEND: file doesn't exist yet (write header) + int n = 0; +}; +// The WORKER's armed job. Written only while !(busy || req); the sync drain +// uses its own local SegJob + its own buffer so a worker stalled past the 9 s +// idle-wait cap can never observe a rebuilt descriptor or a reused buffer. static uint8_t s_segjob_kind = SEGJOB_NONE; -static uint32_t s_segjob_first_seq = 0; // target segment (filename key) -static uint32_t s_segjob_last_seq = 0; // newest seq in the job's records -static bool s_segjob_create = false; // APPEND: file doesn't exist yet (write header) +static uint32_t s_segjob_first_seq = 0; +static uint32_t s_segjob_last_seq = 0; +static bool s_segjob_create = false; static int s_segjob_n = 0; -static UITask::UIMessage* s_segjob_buf = nullptr; +static UITask::UIMessage* s_segjob_buf = nullptr; // worker jobs only +static UITask::UIMessage* s_segsync_buf = nullptr; // sync-drain jobs only +// A delete landed inside the segment a COMPACT job is currently rewriting — +// its snapshot predates the tombstone, so the segment must stay dirty when +// the job commits (a second compaction picks the deletion up). +static bool s_segjob_redirty = false; // fwd decls — scheduler helpers live with the storage code below. -static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, int head); -static void segCommitJob(); +static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, + UITask::UIMessage* buf, SegJob* out); +static void segCommitJob(const SegJob& job); static bool segMoreWorkPending(uint32_t newest_seq); static bool uiSegRunArmedJob(); static void segMarkSeqDirty(uint32_t seq); @@ -4681,6 +4697,7 @@ static void segNoteEvicted(uint32_t seq); static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq); static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out); +static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot); // fwd decls — the ops live with the storage code far below. static void uiDataEnsureDirs(); static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps); @@ -41147,6 +41164,38 @@ void UITask::flushHistorySoon() { } void UITask::flushHistoryIfDue(unsigned long now) { + // Observe a finished worker job FIRST — and strictly BEFORE the failure + // branch below resets s_hist_flush_ok, or a FAILED job would read as ok + // here and get committed, advancing the durability watermark over records + // that never reached disk (silent permanent loss). + if (s_segjob_kind != SEGJOB_NONE && !s_hist_flush_busy && !s_hist_flush_req) { + if (s_hist_flush_ok) { + SegJob done{}; + done.kind = s_segjob_kind; + done.first_seq = s_segjob_first_seq; + done.last_seq = s_segjob_last_seq; + done.create = s_segjob_create; + done.n = s_segjob_n; + const uint32_t pre_wm = s_seg_flushed_seq; + segCommitJob(done); + // Deletes that raced the in-flight APPEND: segMarkSeqDirty skipped them + // while their seq was above the watermark ("never reaches disk") — but + // this job just put them on disk. Give their segments the compaction + // mark now. + if (s_seg_flushed_seq > pre_wm) { + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[slot]; + if (!m.thread[0] && m.seq > pre_wm && m.seq <= s_seg_flushed_seq) { + segMarkSeqDirty(m.seq); + _msgs_dirty = true; // schedule the follow-up compaction + } + } + } + } + s_segjob_kind = SEGJOB_NONE; // failed job: data stays pending below the watermark + s_segjob_redirty = false; + } // A worker write failed (storage hiccup): re-arm and try again. Surface // repeated failures — a persistently failing ring write was previously // 100% silent and read as "messages from the last N minutes vanished" on @@ -41177,14 +41226,6 @@ void UITask::flushHistoryIfDue(unsigned long now) { } else if (saveThreadsToStorage()) _threads_dirty = false; else _next_threads_flush_ms = now + 2000; } - // Observe a finished worker job before anything else: the worker only - // executes and reports; its table effects (segment sizes, the durability - // watermark) are committed HERE on the loop task, so the table has exactly - // one writer. - if (s_segjob_kind != SEGJOB_NONE && !s_hist_flush_busy && !s_hist_flush_req) { - if (s_hist_flush_ok) segCommitJob(); - s_segjob_kind = SEGJOB_NONE; // on failure the !ok branch above already re-armed the retry - } if (_msgs_dirty && now >= _next_msgs_flush_ms) { if (s_hist_flush_busy || s_hist_flush_req) { // A request is armed but the flush task may not be up yet — kick it so a @@ -41228,17 +41269,26 @@ void UITask::flushHistoryIfDue(unsigned long now) { // Build the next off-thread job: pending-append batch first, else the // oldest compact-dirty segment. The snapshot is at most ONE segment's // records (~80 KB) — not the old 1.3 MB whole-ring copy. - const int armed = segBuildAndArmJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head); + SegJob job{}; + const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, + segEnsureBuf(&s_segjob_buf), &job); if (armed == 0) { _msgs_dirty = false; return; } // everything already durable if (armed < 0 || !ensureHistFlushTaskRunning()) { // No snapshot buffer / no worker — synchronous drain instead of losing // the flush (a rare spawn failure must not drop hours of chat). - s_hist_flush_req = false; - s_segjob_kind = SEGJOB_NONE; if (saveMsgsToStorage()) { _msgs_dirty = false; return; } _next_msgs_flush_ms = now + 2000; return; } + // Arm the worker: descriptor written strictly before req (the worker + // reads it only after seeing req). + s_segjob_kind = job.kind; + s_segjob_first_seq = job.first_seq; + s_segjob_last_seq = job.last_seq; + s_segjob_create = job.create; + s_segjob_n = job.n; + s_segjob_redirty = false; + s_hist_flush_req = true; // One job armed for the worker. A multi-segment backlog (burst bigger // than one segment, or several dirty segments) keeps _msgs_dirty set and // reschedules shortly; each cycle moves one segment. @@ -41977,13 +42027,16 @@ static bool uiSegAppendRecords(uint32_t first_seq, bool create, errno = 0; char name[48]; uiSegName(first_seq, "", name, sizeof name); - File f = uiDataOpen(name, FILE_APPEND); + // create: open truncating — a stale file under the same key (crash residue) + // must not end up with a second header appended mid-file. + const char* mode = create ? "w" : FILE_APPEND; + File f = uiDataOpen(name, mode); if (!f) { // First failure on a fresh card/dir is usually a missing parent dir — // create it and retry once before reporting. uiDataEnsureDirs(); errno = 0; - f = uiDataOpen(name, FILE_APPEND); + f = uiDataOpen(name, mode); if (!f) return uiMsgsWriteFail('a'); } bool ok = true; @@ -42159,14 +42212,14 @@ static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps) { // uiSegRunArmedJob() and reports through s_hist_flush_ok. // Snapshot buffer for one job (<= one segment of records). Lazy, kept for the -// session — it replaces the old whole-ring snapshot at ~6% of the size. -static UITask::UIMessage* segEnsureJobBuf() { - if (!s_segjob_buf) { +// session — two exist (worker vs sync drain) so the writers can never share. +static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot) { + if (!*slot) { const size_t sz = sizeof(UITask::UIMessage) * (size_t)k_ui_seg_records; - s_segjob_buf = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - if (!s_segjob_buf) s_segjob_buf = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_8BIT); + *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!*slot) *slot = (UITask::UIMessage*)heap_caps_malloc(sz, MALLOC_CAP_8BIT); } - return s_segjob_buf; + return *slot; } // Live records with seq in [lo, hi], chronological, from the ring. @@ -42183,10 +42236,14 @@ static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int return n; } -// Build the next job into the descriptor + snapshot buffer and raise req. -// Returns 1 = job armed, 0 = nothing pending (all durable), -1 = no buffer. -static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, int head) { - if (!segEnsureJobBuf()) return -1; +// Build the next job into `buf` + `out`. PURE with respect to the worker +// descriptor — arming (copying into the statics + raising req) is the async +// caller's step, so the sync drain can use this with its own buffer and a +// local job without the worker ever seeing it. Returns 1 = job built, +// 0 = nothing pending (all durable), -1 = no buffer. +static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, + UITask::UIMessage* buf, SegJob* out) { + if (!buf) return -1; // 1) Pending appends: live records newer than the durability watermark, // chronological, capped to the room left in the active segment. bool create = true; @@ -42205,18 +42262,17 @@ static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, if (m.seq <= s_seg_flushed_seq) continue; if (m.thread[0]) { if (n >= room) break; // segment full — the rest rides the next cycle - s_segjob_buf[n++] = m; + buf[n++] = m; } if (m.seq > last) last = m.seq; // deleted-before-flush records advance the // watermark with no write (never hit disk) } if (n > 0) { - s_segjob_kind = SEGJOB_APPEND; - s_segjob_first_seq = create ? s_segjob_buf[0].seq : target; - s_segjob_last_seq = last; - s_segjob_create = create; - s_segjob_n = n; - s_hist_flush_req = true; + out->kind = SEGJOB_APPEND; + out->first_seq = create ? buf[0].seq : target; + out->last_seq = last; + out->create = create; + out->n = n; return 1; } if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail @@ -42225,52 +42281,54 @@ static int segBuildAndArmJob(const UITask::UIMessage* ring, int cap, int count, if (!s_seg[i].compact_dirty) continue; const int cn = segGatherRange(ring, cap, count, head, s_seg[i].first_seq, s_seg[i].last_seq, - s_segjob_buf, k_ui_seg_records); - s_segjob_kind = SEGJOB_COMPACT; - s_segjob_first_seq = s_seg[i].first_seq; - s_segjob_last_seq = cn > 0 ? s_segjob_buf[cn - 1].seq : 0; - s_segjob_create = false; - s_segjob_n = cn; // 0 = every record gone -> unlink the file - s_hist_flush_req = true; + buf, k_ui_seg_records); + out->kind = SEGJOB_COMPACT; + out->first_seq = s_seg[i].first_seq; + out->last_seq = cn > 0 ? buf[cn - 1].seq : 0; + out->create = false; + out->n = cn; // 0 = every record gone -> unlink the file return 1; } return 0; } // Apply a successfully-executed job to the table (loop task only). -static void segCommitJob() { - if (s_segjob_kind == SEGJOB_APPEND) { - if (s_segjob_create && s_seg_count < k_ui_seg_max) { +static void segCommitJob(const SegJob& job) { + if (job.kind == SEGJOB_APPEND) { + if (job.create && s_seg_count < k_ui_seg_max) { UiSegInfo info{}; - info.first_seq = s_segjob_first_seq; + info.first_seq = job.first_seq; info.bytes = (uint32_t)sizeof(UiSegHeader); s_seg[s_seg_count++] = info; s_seg_total_bytes = s_seg_total_bytes + (uint32_t)sizeof(UiSegHeader); } if (s_seg_count > 0) { UiSegInfo& a = s_seg[s_seg_count - 1]; - a.disk_recs = (uint16_t)(a.disk_recs + s_segjob_n); - a.live_recs = (uint16_t)(a.live_recs + s_segjob_n); - if (s_segjob_last_seq > a.last_seq) a.last_seq = s_segjob_last_seq; - const uint32_t add = (uint32_t)s_segjob_n * (uint32_t)sizeof(UiSegMsg); + a.disk_recs = (uint16_t)(a.disk_recs + job.n); + a.live_recs = (uint16_t)(a.live_recs + job.n); + if (job.last_seq > a.last_seq) a.last_seq = job.last_seq; + const uint32_t add = (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); a.bytes += add; s_seg_total_bytes = s_seg_total_bytes + add; } - if (s_segjob_last_seq > s_seg_flushed_seq) s_seg_flushed_seq = s_segjob_last_seq; - } else if (s_segjob_kind == SEGJOB_COMPACT) { + if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; + } else if (job.kind == SEGJOB_COMPACT) { for (int i = 0; i < s_seg_count; ++i) { - if (s_seg[i].first_seq != s_segjob_first_seq) continue; + if (s_seg[i].first_seq != job.first_seq) continue; s_seg_total_bytes = (s_seg_total_bytes >= s_seg[i].bytes) ? s_seg_total_bytes - s_seg[i].bytes : 0; - if (s_segjob_n == 0) { + if (job.n == 0) { for (int k = i; k + 1 < s_seg_count; ++k) s_seg[k] = s_seg[k + 1]; --s_seg_count; } else { - s_seg[i].disk_recs = s_seg[i].live_recs = (uint16_t)s_segjob_n; - s_seg[i].last_seq = s_segjob_last_seq; + s_seg[i].disk_recs = s_seg[i].live_recs = (uint16_t)job.n; + s_seg[i].last_seq = job.last_seq; s_seg[i].bytes = (uint32_t)sizeof(UiSegHeader) - + (uint32_t)s_segjob_n * (uint32_t)sizeof(UiSegMsg); - s_seg[i].compact_dirty = 0; + + (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); + // A delete that landed while this compact was writing predates + // nothing: the snapshot was taken BEFORE it, so the record is still + // in the file — keep the segment dirty for a follow-up pass. + s_seg[i].compact_dirty = s_segjob_redirty ? 1 : 0; s_seg_total_bytes = s_seg_total_bytes + s_seg[i].bytes; } break; @@ -42305,6 +42363,11 @@ static void segMarkSeqDirty(uint32_t seq) { for (int i = 0; i < s_seg_count; ++i) { if (seq >= s_seg[i].first_seq && seq <= s_seg[i].last_seq) { s_seg[i].compact_dirty = 1; + // Racing an in-flight compact of this very segment: its snapshot was + // taken before this delete, so the commit must not clear the flag. + if (s_segjob_kind == SEGJOB_COMPACT && s_segjob_first_seq == s_seg[i].first_seq && + (s_hist_flush_busy || s_hist_flush_req)) + s_segjob_redirty = true; return; } } @@ -42389,34 +42452,35 @@ bool UITask::saveMsgsToStorage() { // segment is rewritten from the ring via the sync tmp namespace, where // rename-over is last-writer-wins with each candidate internally // consistent. + UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); // NEVER the worker's buffer: + // a worker stalled past the 9 s idle-wait cap may still be reading its own. for (int guard = 0; guard < k_ui_seg_max + 4; ++guard) { - const int armed = segBuildAndArmJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head); + SegJob job{}; + const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, buf, &job); if (armed == 0) return true; if (armed < 0) return false; // no snapshot buffer - s_hist_flush_req = false; // executed here, not by the worker bool ok; - if (s_segjob_kind == SEGJOB_APPEND) { - if (s_segjob_create && s_seg_count < k_ui_seg_max) { + if (job.kind == SEGJOB_APPEND) { + if (job.create && s_seg_count < k_ui_seg_max) { UiSegInfo info{}; - info.first_seq = s_segjob_first_seq; + info.first_seq = job.first_seq; s_seg[s_seg_count++] = info; } const int n2 = segGatherRange(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, - s_segjob_first_seq, s_segjob_last_seq, - s_segjob_buf, k_ui_seg_records); - ok = uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, n2, true); + job.first_seq, job.last_seq, + buf, k_ui_seg_records); + ok = uiSegCompactWrite(job.first_seq, buf, n2, true); if (ok) { - const uint32_t newest = s_segjob_last_seq; - s_segjob_kind = SEGJOB_COMPACT; // commit with absolute-value semantics - s_segjob_n = n2; - segCommitJob(); + const uint32_t newest = job.last_seq; + job.kind = SEGJOB_COMPACT; // commit with absolute-value semantics + job.n = n2; + segCommitJob(job); if (newest > s_seg_flushed_seq) s_seg_flushed_seq = newest; } } else { - ok = uiSegCompactWrite(s_segjob_first_seq, s_segjob_buf, s_segjob_n, true); - if (ok) segCommitJob(); + ok = uiSegCompactWrite(job.first_seq, buf, job.n, true); + if (ok) segCommitJob(job); } - s_segjob_kind = SEGJOB_NONE; if (!ok) return false; } return !segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0); @@ -46172,7 +46236,13 @@ void UITask::loop() { if (ok && SD.cardType() != CARD_NONE) { s_sd_mounted = true; s_sd_size = SD.cardSize(); - sdEnsureMeshcomodFolders(); // BINS / RECBCK / SETTINGS / MAPS / LOGS + sdEnsureMeshcomodFolders(); // BINS / RECBCK / SETTINGS / MAPS / LOGS / meshcomod(+msgs) + // The format just erased every chat segment while the table still + // claims they exist — without a resync the durability watermark keeps + // pretending the old records are on disk (permanently lost) and the + // next append targets a segment file that is gone. Rebuild + re-land. + uiDataEnsureDirs(); + flushHistorySoon(); char done[56], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(done, sizeof done, TR("SD formatted - %s (MESHCOMOD)"), cs); From 8bb2b47cd7de397df7a393819136a2733874ab2d Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Mon, 27 Jul 2026 11:30:14 +0200 Subject: [PATCH 25/99] touch: chat-store chip on the Commander legend row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a compact store-status chip sharing the TX/RX legend row above the home chart, parked on the right half of the chart width (fixed width + right-aligned + CLIP, so it can never grow into the traffic counts at any UI scale). Small font (g_font_12), not clickable — the legend still owns this row's taps. Shows a floppy glyph plus, in order of precedence: 'FAIL xN' in red while saves are failing, 'migrating' in amber while an old-format store hasn't converted yet, else the segment count and total bytes ('9s 512K' / '21s 1.1M') in the normal subdued colour. Refreshed independently of the TX/RX chart so the big-screen scaled layout (which drops the chart) still shows it, and re-flowed with the legend on Expansion-Kit boards. The full diagnosis (backend, failure stage + errno) stays on the About page's Chat store panel. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c2139d2..499700a 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -16428,6 +16428,7 @@ static lv_obj_t* s_home_adv_btn = nullptr; #endif // Compact legend label above the chart showing live TX/RX totals. static lv_obj_t* s_home_chart_legend = nullptr; +static lv_obj_t* s_home_store = nullptr; // chat-store chip, right half of the legend row static lv_obj_t* s_home_chart_sig = nullptr; // live signal chip drawn inside the graph box #if CAP_LARGE_SCREEN static lv_obj_t* s_home_info = nullptr; // Commander info-panel values column (big screen) — refreshed live @@ -22099,6 +22100,30 @@ static void homeControlPanelCb(lv_event_t* e) { // "Control panel" launcher -> if (lv_event_get_code(e) == LV_EVENT_CLICKED) toggleControlCenter(); } +// Chat-store chip for the Commander legend row (right of the TX/RX totals). +// Deliberately terse — it shares one line with the traffic counts and must fit +// half the chart width at g_font_12. The full story (backend, failure stage + +// errno) stays on the About page's "Chat store" panel; this chip only has to +// make a sick store impossible to miss from the home screen. Returns the text +// colour to use. +static uint32_t homeStoreChipText(char* out, size_t cap) { + if (s_msgs_write_fails) { // saves are failing right now + snprintf(out, cap, LV_SYMBOL_SAVE " FAIL x%u", (unsigned)s_msgs_write_fails); + return 0xE05252; // red — history is NOT landing + } + if (!s_seg_store_ready) { // old-format file not converted yet + snprintf(out, cap, LV_SYMBOL_SAVE " migrating"); + return 0xF5A623; // amber + } + const uint32_t b = s_seg_total_bytes; + if (b >= 1024u * 1024u) // 1 MB+ (deep SD ring): one decimal keeps it narrow + snprintf(out, cap, LV_SYMBOL_SAVE " %ds %u.%uM", s_seg_count, + (unsigned)(b / (1024u * 1024u)), (unsigned)((b / 104858u) % 10u)); + else + snprintf(out, cap, LV_SYMBOL_SAVE " %ds %uK", s_seg_count, (unsigned)(b / 1024u)); + return COLOR_SUB; +} + static void makeHome(lv_obj_t* tab) { // Layout (240 wide × 282 tall): title + heartbeat + battery at top, status // lines, TX/RX chart in the middle, Send Advert button at the bottom. @@ -22132,6 +22157,7 @@ static void makeHome(lv_obj_t* tab) { s_home_batt_pct = nullptr; s_home_batt_icon = nullptr; s_home_chart_sig = nullptr; + s_home_store = nullptr; // created with the legend row below g_lv.home_apps = nullptr; // set below iff the right-hand launcher column is built (landscape) #if defined(HAS_EXPANSION_KIT) s_home_env_chart = nullptr; @@ -22262,6 +22288,23 @@ static void makeHome(lv_obj_t* tab) { #else const int chart_w = cw; #endif + // Chat-store chip: shares the legend row with the TX/RX totals, parked on the + // RIGHT HALF of the chart width (fixed width + right-aligned + CLIP, so it can + // never grow into the traffic counts on the left, at any UI scale). Not + // clickable — the legend owns this row's taps (signal/traffic popup). + s_home_store = lv_label_create(tab); + lv_label_set_long_mode(s_home_store, LV_LABEL_LONG_CLIP); + lv_obj_set_width(s_home_store, chart_w / 2); + lv_obj_set_style_text_align(s_home_store, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN); + lv_obj_set_style_text_font(s_home_store, &g_font_12, LV_PART_MAIN); + { + char chip[24]; + const uint32_t col = homeStoreChipText(chip, sizeof chip); + lv_label_set_text(s_home_store, chip); + lv_obj_set_style_text_color(s_home_store, lv_color_hex(col), LV_PART_MAIN); + } + lv_obj_align(s_home_store, LV_ALIGN_TOP_LEFT, chart_w - chart_w / 2, chart_y); + // Fit the chart in the remaining vertical space: content height minus the // tab padding, the chart's top offset, and the Send-advert button + gaps. // Portrait keeps the full 96 px; landscape (short screen) shrinks it so the @@ -38099,6 +38142,10 @@ static void relayoutHomeCharts() { const int legend_y = env_chart_y + SC(34) + SC(12); lv_obj_set_pos(s_home_chart_legend, 0, legend_y); + if (s_home_store) { // chat-store chip rides the same row + lv_obj_set_width(s_home_store, chart_w / 2); + lv_obj_set_pos(s_home_store, chart_w - chart_w / 2, legend_y); + } const int home_avail = tabContentH() - 20; int chart_h = home_avail - (legend_y + 16) - 4 - (home_land ? 0 : (8 + 36)); @@ -38148,6 +38195,19 @@ static void refreshStatusLabels() { if (g_lv.tabview) active_tab = lv_tabview_get_tab_act(g_lv.tabview); const bool home_active = (active_tab == HOME_TAB_INDEX); if (home_active) refreshHomeBattery(); + // Chat-store chip (legend row). Updated independently of the TX/RX chart — + // the big-screen scaled layout drops the chart entirely, and the store state + // still has to be visible there. + if (home_active && s_home_store) { + char chip[24]; + const uint32_t col = homeStoreChipText(chip, sizeof chip); + setLabelIfChanged(s_home_store, chip); + static uint32_t s_home_store_col = 0; // avoid re-styling (and invalidating) every tick + if (col != s_home_store_col) { + s_home_store_col = col; + lv_obj_set_style_text_color(s_home_store, lv_color_hex(col), LV_PART_MAIN); + } + } // Push a TX/RX sample onto the home chart: delta packets since last tick. if (home_active && s_home_chart && s_home_chart_tx && s_home_chart_rx) { static uint32_t last_tx = 0; From 41eb4a559fc3ffbea179a788fbba1a3ee2226c92 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Mon, 27 Jul 2026 12:29:41 +0200 Subject: [PATCH 26/99] touch: close eleven crash/failure-path holes in the segmented store A second adversarial review pass (11 confirmed findings, 2 critical) hit the crash and failure paths the on-device happy path never touches. Six root causes, fixed at the root rather than per-symptom: 1. Migration was not crash-atomic (CRITICAL). Chunks landed one rename at a time, so a power cut mid-migration left a valid oldest-first PREFIX that the loader preferred over the still-intact old file -- permanently showing only the oldest few hundred messages. There is now a commit marker (/msgs/store.ok) written only after full verification: its absence means the segment set is provisional, and the loader wipes those segments and re-reads the old file. The 60 s migration retry on a sick card therefore no longer risks history. 2. Migration assumed a linear ring (CRITICAL) but its retry paths run mid-session, after appendMessage may have wrapped it -- producing segments in slot order (first_seq > last_seq, scrambled history that per-segment read-back could not detect) before deleting the old file. It now walks the ring chronologically, skips tombstones (which it used to persist as ghost records), and verification additionally requires strictly increasing seqs within and across segments. 3. A partially-failed append was retried by appending the same batch again, duplicating records (or misaligning every later record if the failure split one). Segments now carry a rewrite_open flag meaning "file content untrusted": the scheduler REPAIRS such a segment with a full rewrite (header + records, tmp+rename) before any append can target it, and the repair absorbs the unflushed tail. 4. Post-resync appends targeted deleted files (headerless segments) and could push a chunk's range past the 256-record gather cap, silently dropping records the watermark already called durable. Fixed by the same repair-first rule: retable marks every chunk rewrite_open. 5. Resync deleted every on-disk segment up front, so a same-card wedge recovery left the whole history RAM-only for minutes. It no longer deletes anything: same-key files are replaced by each repair's rename, and leftovers are swept only once the re-land is complete. 6. A stale worker descriptor could be committed twice after persistHistoryNow drained past a busy worker (double-counted totals, duplicate table entries), and a leaked redirty flag could make the drain re-compact one segment ~28 times and then report failure. uiHistWaitWorkerIdle now always drops the descriptor and the flag, marking the abandoned job's segment for repair instead. The drain also refuses to run at all while a worker is still writing -- with FF_FS_LOCK=0, removing or renaming a path under a stalled open handle can cross-link clusters and corrupt the volume. Also: the loader applies a strictly-monotonic seq filter, so duplicate or out-of-order records from any interrupted write are dropped at load and the affected segment is rewritten; append commits credit their own segment by key instead of blindly crediting the last table entry. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 253 ++++++++++++++++++++++++++++++++++------ 1 file changed, 219 insertions(+), 34 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 499700a..59e38cd 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -327,6 +327,13 @@ struct __attribute__((packed)) UiMsgFileHeader { // (31-char limit: "/msgs/seg_4294967295.bin" = 24 chars), so the layout is // identical everywhere and no mkdir is required there. constexpr const char* k_ui_seg_dir = "/msgs"; +// Commit marker. Written (last) only once a migration has been fully verified, +// so its ABSENCE means "the segment set on disk is a partial migration" — the +// loader then wipes those segments and falls back to the still-intact +// old-format file. Without it, a power cut between chunk writes left a valid +// oldest-first PREFIX that shadowed the old file forever: the UI came up +// showing only the oldest few hundred messages, permanently. +constexpr const char* k_ui_seg_ok = "/msgs/store.ok"; constexpr uint32_t k_ui_seg_magic = 0x55495347; // "UISG" constexpr uint16_t k_ui_seg_version = 1; constexpr int k_ui_seg_records = 256; // records per segment (~60 KB at v1 size) @@ -4613,6 +4620,7 @@ static void chatSaveFailText(char* out, size_t cap) { case 'c': stage = "compact"; break; // segmented store: one-segment rewrite case 'd': stage = "mkdir"; break; // segmented store: data-dir create case 's': stage = "scan"; break; // segmented store: segment discovery + case 'm': stage = "commit"; break; // segmented store: migration commit marker default: stage = "save"; break; } const int e = s_msgs_write_errno; @@ -4644,11 +4652,22 @@ struct UiSegInfo { uint16_t disk_recs; // records physically in the file uint16_t live_recs; // of those, how many are still live in the RAM ring uint8_t compact_dirty; // RAM tombstones/drops within this segment -> rewrite due + // The FILE's content is untrusted: it may be missing, partially written (a + // failed append), or stale (post-resync). Repair = a full rewrite from the + // ring that also ABSORBS the unflushed tail, so no append may target it + // until that lands. Set on append failure and by segRetableFromRing. + uint8_t rewrite_open; }; static UiSegInfo s_seg[k_ui_seg_max]; static int s_seg_count = 0; static volatile uint32_t s_seg_total_bytes = 0; // writer-maintained; About page reads it static volatile bool s_seg_resync = false; // card swapped/remounted: rewrite everything from RAM +// Set by segRetableFromRing: on-disk segment files that are NOT part of the +// rebuilt table must be unlinked — but only once the re-land has landed, so a +// same-card recovery keeps its durable history readable the whole time +// (deleting up front turned a recovery into a multi-minute window where a +// power cut lost everything that had been safe on disk). +static bool s_seg_stale_purge = false; // False until the boot loader read segments OR the one-time migration landed. // While false the flush scheduler must not append segments (they would shadow // the still-authoritative old-format file on the next boot) — it retries the @@ -4670,6 +4689,7 @@ struct SegJob { uint32_t first_seq = 0; // target segment (filename key) uint32_t last_seq = 0; // newest seq covered by the job bool create = false; // APPEND: file doesn't exist yet (write header) + bool repair = false; // COMPACT of an untrusted file: also absorbs the unflushed tail int n = 0; }; // The WORKER's armed job. Written only while !(busy || req); the sync drain @@ -4695,6 +4715,8 @@ static bool uiSegRunArmedJob(); static void segMarkSeqDirty(uint32_t seq); static void segNoteEvicted(uint32_t seq); static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq); +static void segPurgeStaleFiles(); +static bool uiMsgsWriteFail(char stage); // fwd — failure stage/errno bookkeeping (writer block below) static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t lo, uint32_t hi, UITask::UIMessage* out, int max_out); static UITask::UIMessage* segEnsureBuf(UITask::UIMessage** slot); @@ -41332,7 +41354,13 @@ void UITask::flushHistoryIfDue(unsigned long now) { SegJob job{}; const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, segEnsureBuf(&s_segjob_buf), &job); - if (armed == 0) { _msgs_dirty = false; return; } // everything already durable + if (armed == 0) { + // Everything durable. A resync's leftover files can go now that every + // table entry has a real, current file on disk. + if (s_seg_stale_purge) segPurgeStaleFiles(); + _msgs_dirty = false; + return; + } if (armed < 0 || !ensureHistFlushTaskRunning()) { // No snapshot buffer / no worker — synchronous drain instead of losing // the flush (a rare spawn failure must not drop hours of chat). @@ -41610,6 +41638,19 @@ bool UITask::loadMsgsFromSegments() { s_seg_count = 0; s_seg_total_bytes = 0; if (nseg <= 0) return false; + // Commit marker missing => this segment set is an unfinished migration (a + // power cut between chunk writes). Wipe it and let the caller fall back to + // the old-format file, which migrateRingToSegments only deletes after full + // verification. + { + File okf = uiDataOpen(k_ui_seg_ok, FILE_READ); + const bool have_marker = (bool)okf; + if (okf) okf.close(); + if (!have_marker) { + for (int i = 0; i < nseg; ++i) uiSegRemoveFile(seqs[i]); + return false; + } + } WdtHeavyGuard _wg; // ~20 segment reads on a cold card can take a while int w = 0; // running chronological index; ring slot = w % cap @@ -41626,9 +41667,16 @@ bool UITask::loadMsgsFromSegments() { UIMessage rec; uint32_t seq = 0; uint16_t nrec = 0; + bool skipped = false; // duplicates dropped -> file != table -> rewrite it while (uiSegReadRec(f, rec_sz, &rec, &seq)) { // stops at EOF or a crash-ragged tail + // Strictly monotonic seq filter. Segments are read in ascending + // first_seq order, so a record that does not advance the sequence is a + // duplicate or an out-of-order leftover (an interrupted repair, a stale + // file a crash left behind, a re-appended batch) — dropping it keeps the + // ring chronological and idempotent no matter what the disk holds. + if (rec.seq != 0 && rec.seq <= max_seq) { skipped = true; continue; } if (rec.seq == 0) rec.seq = max_seq + 1; // defensive backfill (pre-seq record) - if (rec.seq > max_seq) max_seq = rec.seq; + max_seq = rec.seq; info.last_seq = rec.seq; _ui_msgs[w % _ui_msg_cap] = rec; ++w; @@ -41638,6 +41686,9 @@ bool UITask::loadMsgsFromSegments() { info.disk_recs = nrec; info.live_recs = nrec; info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz; + // The file holds records the table doesn't account for — rewrite it so disk + // and table agree again (also drops the dead weight for good). + if (skipped) info.rewrite_open = 1; if (nrec == 0) { uiSegRemoveFile(seqs[si]); continue; } // header-only husk — reclaim if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = info; } @@ -41707,23 +41758,48 @@ bool UITask::loadMsgsFromSegments() { bool UITask::migrateRingToSegments() { #if defined(ESP32) uiDataEnsureDirs(); + // The marker goes away FIRST: from here until the verified end of this + // migration the on-disk segment set is provisional, and a boot that finds it + // without the marker wipes it and re-reads the (still intact) old file. + uiDataRemove(k_ui_seg_ok); + { // clear any earlier/partial set so leftovers can't survive as extra segments + uint32_t old_seqs[k_ui_seg_max]; + const int old_n = uiSegScan(old_seqs, k_ui_seg_max, true); + for (int i = 0; i < old_n; ++i) uiSegRemoveFile(old_seqs[i]); + } s_seg_count = 0; s_seg_total_bytes = 0; - const int n = _ui_msg_count; // ring is linear post-load: slots 0..n-1, oldest first + UITask::UIMessage* buf = segEnsureBuf(&s_segsync_buf); + if (!buf) return false; + + // Walk the ring CHRONOLOGICALLY and skip tombstones. Both matter: this runs + // not only at boot (ring linear) but also as a retry after a failed boot + // migration, by which time appendMessage may have WRAPPED the ring and the + // user may have deleted messages. Slicing raw slots there produced segments + // in slot order (first_seq > last_seq, scrambled history that read-back + // verification cannot detect) and persisted tombstones as ghost records. bool ok = true; - for (int base = 0; base < n && ok; base += k_ui_seg_records) { - int cnt = n - base; - if (cnt > k_ui_seg_records) cnt = k_ui_seg_records; - const uint32_t fseq = _ui_msgs[base].seq; - ok = uiSegCompactWrite(fseq, &_ui_msgs[base], cnt, true /*sync-writer tmp*/); - if (ok && s_seg_count < k_ui_seg_max) { - UiSegInfo info{}; - info.first_seq = fseq; - info.last_seq = _ui_msgs[base + cnt - 1].seq; - info.disk_recs = info.live_recs = (uint16_t)cnt; - info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)cnt * (uint32_t)sizeof(UiSegMsg); - s_seg[s_seg_count++] = info; + int in_chunk = 0; + for (int i = 0; i <= _ui_msg_count && ok; ++i) { + const bool flush_chunk = (i == _ui_msg_count) || (in_chunk == k_ui_seg_records); + if (flush_chunk && in_chunk > 0) { + const uint32_t fseq = buf[0].seq; + ok = uiSegCompactWrite(fseq, buf, in_chunk, true /*sync-writer tmp*/); + if (ok && s_seg_count < k_ui_seg_max) { + UiSegInfo info{}; + info.first_seq = fseq; + info.last_seq = buf[in_chunk - 1].seq; + info.disk_recs = info.live_recs = (uint16_t)in_chunk; + info.bytes = (uint32_t)sizeof(UiSegHeader) + + (uint32_t)in_chunk * (uint32_t)sizeof(UiSegMsg); + s_seg[s_seg_count++] = info; + } + in_chunk = 0; } + if (i == _ui_msg_count) break; + const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap; + if (!_ui_msgs[slot].thread[0]) continue; // tombstone — never persisted + buf[in_chunk++] = _ui_msgs[slot]; } if (ok) { // Read-back verification: every segment must reproduce its exact record @@ -41735,16 +41811,21 @@ bool UITask::migrateRingToSegments() { UIMessage r; uint32_t seq = 0, first = 0, last = 0; uint16_t cnt = 0; + bool ordered = true; while (uiSegReadRec(f, rec_sz, &r, &seq)) { if (cnt == 0) first = seq; + else if (seq <= last) ordered = false; // seqs must strictly increase within a segment last = seq; ++cnt; } f.close(); - ok = (cnt == s_seg[i].disk_recs && + ok = (ordered && cnt == s_seg[i].disk_recs && first == s_seg[i].first_seq && last == s_seg[i].last_seq); } + // Segments must also be ordered relative to each other. + for (int i = 1; ok && i < s_seg_count; ++i) + ok = (s_seg[i].first_seq > s_seg[i - 1].last_seq); } if (!ok) { for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); @@ -41752,6 +41833,18 @@ bool UITask::migrateRingToSegments() { s_seg_total_bytes = 0; return false; // old file untouched; the scheduler retries the migration later } + // Verified. Commit: marker, then drop the old-format files. + { + File okf = uiDataOpen(k_ui_seg_ok, "w"); + if (!okf) { // can't commit -> leave the old file authoritative and retry later + for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq); + s_seg_count = 0; + s_seg_total_bytes = 0; + return uiMsgsWriteFail('m'); + } + okf.print("segstore v1\n"); + okf.close(); + } uint32_t total_bytes = 0; for (int i = 0; i < s_seg_count; ++i) total_bytes += s_seg[i].bytes; s_seg_total_bytes = total_bytes; @@ -41760,6 +41853,7 @@ bool UITask::migrateRingToSegments() { uiDataRemove(k_ui_msgs_tmp2_path); s_seg_flushed_seq = s_seg_count ? s_seg[s_seg_count - 1].last_seq : 0; s_seg_resync = false; // a full write IS the resync + s_seg_stale_purge = false; s_seg_store_ready = true; return true; #else @@ -42110,7 +42204,19 @@ static bool uiSegAppendRecords(uint32_t first_seq, bool create, } if (ok) ok = uiSegWriteRecords(f, recs, n); f.close(); - if (!ok) return uiMsgsWriteFail('a'); + if (!ok) { + // A short write can have landed whole records already. The file content is + // now untrusted: appending the retry after that tail would duplicate (or, + // if the failure split a record, MISALIGN) records the loader accepts as + // history. Flag the segment so the scheduler repairs it with a full + // rewrite before any further append. Volatile-only work — safe from the + // worker task; the entry lookup runs on the loop task's table but only + // writes a byte the loop task re-reads. + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].first_seq == first_seq) { s_seg[i].rewrite_open = 1; break; } + if (create) uiSegRemoveFile(first_seq); // nothing valid in it yet + return uiMsgsWriteFail('a'); + } return uiMsgsWriteResult(true); } @@ -42304,6 +42410,27 @@ static int segGatherRange(const UITask::UIMessage* ring, int cap, int count, int static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int head, UITask::UIMessage* buf, SegJob* out) { if (!buf) return -1; + // 0) REPAIR FIRST. A segment whose file content is untrusted (failed append + // left a partial tail, or a resync left it stale/absent) must be fully + // rewritten before anything appends to it — otherwise a retry appends the + // same batch after the partial tail (duplicate or misaligned records the + // loader would ingest as history) or creates a HEADERLESS file. The + // rewrite absorbs the unflushed tail too, bounded by the segment size; + // whatever doesn't fit stays above the watermark and lands as a normal + // append into the NEXT segment afterwards. + for (int i = 0; i < s_seg_count; ++i) { + if (!s_seg[i].rewrite_open) continue; + const uint32_t hi = (i + 1 < s_seg_count) ? (s_seg[i + 1].first_seq - 1) : 0xFFFFFFFFu; + const int rn = segGatherRange(ring, cap, count, head, s_seg[i].first_seq, hi, + buf, k_ui_seg_records); + out->kind = SEGJOB_COMPACT; // a compact IS the repair (tmp + rename, header written) + out->first_seq = s_seg[i].first_seq; + out->last_seq = rn > 0 ? buf[rn - 1].seq : 0; + out->create = false; + out->n = rn; // 0 -> unlink (every record in the range is gone) + out->repair = true; // commit also clears rewrite_open + advances the watermark + return 1; + } // 1) Pending appends: live records newer than the durability watermark, // chronological, capped to the room left in the active segment. bool create = true; @@ -42362,16 +42489,20 @@ static void segCommitJob(const SegJob& job) { s_seg[s_seg_count++] = info; s_seg_total_bytes = s_seg_total_bytes + (uint32_t)sizeof(UiSegHeader); } - if (s_seg_count > 0) { - UiSegInfo& a = s_seg[s_seg_count - 1]; + // Credit the job to ITS OWN segment (looked up by key), never blindly to + // the last table entry — the table can have changed between arm and commit. + for (int i = 0; i < s_seg_count; ++i) { + if (s_seg[i].first_seq != job.first_seq) continue; + UiSegInfo& a = s_seg[i]; a.disk_recs = (uint16_t)(a.disk_recs + job.n); a.live_recs = (uint16_t)(a.live_recs + job.n); if (job.last_seq > a.last_seq) a.last_seq = job.last_seq; const uint32_t add = (uint32_t)job.n * (uint32_t)sizeof(UiSegMsg); a.bytes += add; s_seg_total_bytes = s_seg_total_bytes + add; + if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; + break; } - if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; } else if (job.kind == SEGJOB_COMPACT) { for (int i = 0; i < s_seg_count; ++i) { if (s_seg[i].first_seq != job.first_seq) continue; @@ -42390,6 +42521,12 @@ static void segCommitJob(const SegJob& job) { // in the file — keep the segment dirty for a follow-up pass. s_seg[i].compact_dirty = s_segjob_redirty ? 1 : 0; s_seg_total_bytes = s_seg_total_bytes + s_seg[i].bytes; + if (job.repair) { + // The file now matches the table exactly, so it is trustworthy again + // and the records it absorbed from the unflushed tail are durable. + s_seg[i].rewrite_open = 0; + if (job.last_seq > s_seg_flushed_seq) s_seg_flushed_seq = job.last_seq; + } } break; } @@ -42399,7 +42536,7 @@ static void segCommitJob(const SegJob& job) { static bool segMoreWorkPending(uint32_t newest_seq) { if (newest_seq > s_seg_flushed_seq) return true; for (int i = 0; i < s_seg_count; ++i) - if (s_seg[i].compact_dirty) return true; + if (s_seg[i].compact_dirty || s_seg[i].rewrite_open) return true; return false; } @@ -42448,17 +42585,22 @@ static void segNoteEvicted(uint32_t seq) { } } -// Card remounted (wedge recovery / reinsert / FM insert): the on-disk segment -// set may belong to a different card entirely. Start clean: remove whatever -// segments are found, rebuild the table from the ring's live records with -// every chunk marked compact-dirty — the normal job flow then re-lands the -// full history one bounded segment at a time, and the watermark parks at -// newest_seq so nothing double-appends meanwhile. +// Card remounted (wedge recovery / reinsert / FM insert / post-format): the +// on-disk segment set may be stale, foreign, or gone. Rebuild the table from +// the ring's live records with every chunk marked UNTRUSTED, so the normal job +// flow rewrites each one (header + records, tmp + rename) one bounded segment +// at a time. +// +// Deliberately does NOT delete anything up front: on a same-card recovery the +// existing files still hold the very history we are re-landing, and unlinking +// them first turned recovery into a multi-minute window where a power cut lost +// data that had been perfectly durable. Same-key files are simply replaced by +// each repair's rename; anything left over (different chunk boundaries, or a +// genuinely foreign card) is swept once the re-land is complete — and the +// loader's seq-monotonic filter makes a leftover harmless even if we crash +// before that sweep. static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count, int head, uint32_t newest_seq) { - uint32_t seqs[k_ui_seg_max]; - const int nseg = uiSegScan(seqs, k_ui_seg_max, true); - for (int i = 0; i < nseg; ++i) uiSegRemoveFile(seqs[i]); s_seg_count = 0; s_seg_total_bytes = 0; UiSegInfo cur{}; @@ -42469,8 +42611,8 @@ static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count if (!m.thread[0]) continue; if (in_chunk == 0) { cur = UiSegInfo{}; - cur.first_seq = m.seq; - cur.compact_dirty = 1; // "needs writing" — disk_recs stays 0 until the job lands + cur.first_seq = m.seq; + cur.rewrite_open = 1; // file content untrusted -> repair rewrites it } cur.last_seq = m.seq; ++in_chunk; @@ -42485,6 +42627,23 @@ static void segRetableFromRing(const UITask::UIMessage* ring, int cap, int count s_seg[s_seg_count++] = cur; } s_seg_flushed_seq = newest_seq; + s_seg_stale_purge = true; // sweep leftovers once every chunk has landed +} + +// Unlink segment files that are not part of the table. Runs only when no work +// is pending (so every table entry has a real, current file) — see the purge +// rationale on segRetableFromRing. +static void segPurgeStaleFiles() { + uint32_t seqs[k_ui_seg_max]; + const int nseg = uiSegScan(seqs, k_ui_seg_max, true); + if (nseg < 0) return; // scan location unreadable — try again later + for (int i = 0; i < nseg; ++i) { + bool keep = false; + for (int k = 0; k < s_seg_count; ++k) + if (s_seg[k].first_seq == seqs[i]) { keep = true; break; } + if (!keep) uiSegRemoveFile(seqs[i]); + } + s_seg_stale_purge = false; } bool UITask::saveMsgsToStorage() { @@ -42495,6 +42654,14 @@ bool UITask::saveMsgsToStorage() { // empirically reliable path on the shared SPI bus. Returns true only when // everything is durable. if (!uiDataFsReady()) return false; + // A worker that outlived uiHistWaitWorkerIdle's 9 s cap still holds a segment + // file OPEN. Writing here would remove/rename a path under that open handle: + // with FF_FS_LOCK=0 the stalled handle keeps writing into clusters the FS has + // freed and may re-allocate, cross-linking chains and corrupting the volume. + // Report failure instead (the caller surfaces "Chat history save FAILED"); + // the data stays in the RAM ring and the segment is already flagged for a + // repair rewrite by uiHistWaitWorkerIdle. + if (s_hist_flush_busy) return false; if (!s_seg_store_ready) { // A failed boot migration left the old-format file authoritative — // converting it IS the flush (verify-then-delete inside). @@ -42517,7 +42684,10 @@ bool UITask::saveMsgsToStorage() { for (int guard = 0; guard < k_ui_seg_max + 4; ++guard) { SegJob job{}; const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, buf, &job); - if (armed == 0) return true; + if (armed == 0) { + if (s_seg_stale_purge) segPurgeStaleFiles(); // everything landed — drop leftovers + return true; + } if (armed < 0) return false; // no snapshot buffer bool ok; if (job.kind == SEGJOB_APPEND) { @@ -44896,10 +45066,25 @@ bool UITask::sendSignalProbe() { // was never written, so the caller must treat the ring as dirty and write it. static bool uiHistWaitWorkerIdle() { const bool cancelled = s_hist_flush_req; + const uint8_t had_kind = s_segjob_kind; + const uint32_t had_seq = s_segjob_first_seq; s_hist_flush_req = false; - if (cancelled) s_segjob_kind = SEGJOB_NONE; // job never ran — its data stays pending in the ring const uint32_t t0 = millis(); while (s_hist_flush_busy && (uint32_t)(millis() - t0) < 9000) delay(10); + // Drop the worker descriptor UNCONDITIONALLY: the caller is about to write + // synchronously from the ring, and leaving it armed let the next + // flushHistoryIfDue observe a stale job and commit it a SECOND time + // (double-counted record/byte totals, or a duplicate table entry). + s_segjob_kind = SEGJOB_NONE; + s_segjob_redirty = false; + // If the job was already in the worker's hands its fate is now unknown — it + // may have written some or all of its records without being credited. Flag + // the target segment untrusted so the synchronous drain REWRITES it from the + // ring instead of appending on top (which would duplicate whatever landed). + if (had_kind != SEGJOB_NONE && !cancelled) { + for (int i = 0; i < s_seg_count; ++i) + if (s_seg[i].first_seq == had_seq) { s_seg[i].rewrite_open = 1; break; } + } return cancelled; } From 5627c90bb34c5ac533bf79ee14615d43e7b1f1ca Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Mon, 27 Jul 2026 14:07:27 +0200 Subject: [PATCH 27/99] touch: chat-store readouts show the save TIME, not an age The home chip now carries the clock time of the last successful save (HH:MM, 12/24h-aware, with a '-D' suffix once it is a day or more old) instead of the segment count and byte total, and the About page's 'last save' line switches from 'OK 12s ago' / 'FAIL xN (12s ago)' to the same timestamp form. A clock time answers 'is my history safe right now?' directly, and the day suffix stops a store that quietly stopped saving yesterday from reading as fresh. Segment count, byte total and the failure stage + errno stay on the About panel. Backed by new wall-clock stamps taken alongside the existing millis ones in uiMsgsWriteResult; they read 0 (rendered '--:--') when the system clock wasn't set at save time, since no meaningful timestamp exists then. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 56 +++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 59e38cd..c5f1c4b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4602,6 +4602,11 @@ static volatile bool s_hist_flush_ok = true; // last worker write result (re // the About page + the repeated-failure toast in flushHistoryIfDue. static volatile uint32_t s_msgs_write_ok_ms = 0; // millis() of last successful ring write (0 = none yet) static volatile uint32_t s_msgs_write_fail_ms = 0; // millis() of last failed ring write +// Wall-clock epochs for the same two events, so the readouts can show WHEN a +// save happened instead of how long ago. 0 = never, or the system clock wasn't +// set yet at the time (a relative age would be meaningless anyway). +static volatile uint32_t s_msgs_write_ok_epoch = 0; +static volatile uint32_t s_msgs_write_fail_epoch = 0; static volatile uint16_t s_msgs_write_fails = 0; // consecutive failures since the last success static volatile uint8_t s_msgs_write_stage = 0; // where the last failure hit: 'o' open, 'h' header, 'b' body, 'r' rename static volatile int s_msgs_write_errno = 0; // errno at that failure (ENFILE/EMFILE = VFS file table full, ENOSPC = card full, EIO = card error) @@ -4638,6 +4643,24 @@ static void chatSaveFailText(char* out, size_t cap) { } snprintf(out, cap, "%s failed: %s (e%d)", stage, why, e); } +static void fmtClockHM(char* buf, size_t cap, const struct tm* t); // fwd: 12/24h-aware HH:MM +// "When did the chat store last save" as a CLOCK TIME rather than an age: +// HH:MM (honouring the 12/24h pref), with a "-D" suffix once it is a day or +// more old, so a stale save is obvious at a glance instead of having to read a +// growing seconds counter. "--:--" when the event never happened, or happened +// before the system clock was set (no meaningful timestamp exists). +static void chatSaveStamp(char* out, size_t cap, uint32_t epoch) { + if (!epoch) { snprintf(out, cap, "--:--"); return; } + const time_t t = (time_t)epoch; + struct tm tmv; + if (!localtime_r(&t, &tmv)) { snprintf(out, cap, "--:--"); return; } + char hm[12]; + fmtClockHM(hm, sizeof hm, &tmv); + const time_t now_t = time(nullptr); + const uint32_t days = (now_t > (time_t)epoch) ? (uint32_t)((now_t - (time_t)epoch) / 86400) : 0; + if (days) snprintf(out, cap, "%s-%uD", hm, (unsigned)days); + else snprintf(out, cap, "%s", hm); +} static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size @@ -10234,17 +10257,18 @@ static void sysInfoTextLive(char* buf, size_t cap) { // WATCHING this page. const uint32_t nowms = (uint32_t)millis(); char stat[112]; + char when[16]; if (s_msgs_write_fails) { char why[64]; chatSaveFailText(why, sizeof why); - snprintf(stat, sizeof stat, "FAIL x%u (%lus ago)\n %s", - (unsigned)s_msgs_write_fails, - (unsigned long)((nowms - s_msgs_write_fail_ms) / 1000u), - why); + chatSaveStamp(when, sizeof when, s_msgs_write_fail_epoch); + snprintf(stat, sizeof stat, "FAIL x%u (%s)\n %s", + (unsigned)s_msgs_write_fails, when, why); + } + else if (s_msgs_write_ok_ms) { + chatSaveStamp(when, sizeof when, s_msgs_write_ok_epoch); + snprintf(stat, sizeof stat, "OK %s", when); } - else if (s_msgs_write_ok_ms) - snprintf(stat, sizeof stat, "OK %lus ago", - (unsigned long)((nowms - s_msgs_write_ok_ms) / 1000u)); else snprintf(stat, sizeof stat, "none this session"); p += snprintf(buf + p, cap - p, @@ -22137,12 +22161,14 @@ static uint32_t homeStoreChipText(char* out, size_t cap) { snprintf(out, cap, LV_SYMBOL_SAVE " migrating"); return 0xF5A623; // amber } - const uint32_t b = s_seg_total_bytes; - if (b >= 1024u * 1024u) // 1 MB+ (deep SD ring): one decimal keeps it narrow - snprintf(out, cap, LV_SYMBOL_SAVE " %ds %u.%uM", s_seg_count, - (unsigned)(b / (1024u * 1024u)), (unsigned)((b / 104858u) % 10u)); - else - snprintf(out, cap, LV_SYMBOL_SAVE " %ds %uK", s_seg_count, (unsigned)(b / 1024u)); + // Healthy: WHEN the store last saved. A clock time answers "is my history + // safe right now?" directly, where a size doesn't — and the segment count / + // byte total (plus the failure stage + errno) stay one tap away on the About + // page. Older than a day carries a "-D" suffix so a store that quietly + // stopped saving yesterday can't read as fresh. + char when[16]; + chatSaveStamp(when, sizeof when, s_msgs_write_ok_epoch); + snprintf(out, cap, LV_SYMBOL_SAVE " %s", when); return COLOR_SUB; } @@ -42066,11 +42092,15 @@ bool UITask::saveThreadsToStorage() { // a wedged card gets arbitrated instead of the write failing silently forever. static bool uiMsgsWriteResult(bool ok) { const uint32_t m = (uint32_t)millis(); + const time_t now_t = time(nullptr); + const uint32_t ep = (now_t > 1700000000) ? (uint32_t)now_t : 0; // 0 when the clock is unset if (ok) { s_msgs_write_ok_ms = m ? m : 1; + s_msgs_write_ok_epoch = ep; s_msgs_write_fails = 0; } else { s_msgs_write_fail_ms = m ? m : 1; + s_msgs_write_fail_epoch = ep; if (s_msgs_write_fails < 0xFFFFu) s_msgs_write_fails = s_msgs_write_fails + 1; #if defined(HAS_TDECK_GT911) if (s_ui_data_fs == &SD) sdNoteIoFailure(); From f2c1b73636496b2b8dda2a461ff6bc74aff02c73 Mon Sep 17 00:00:00 2001 From: Dan Vybiral Date: Mon, 27 Jul 2026 15:28:25 +0200 Subject: [PATCH 28/99] touch: tell the map when SD storage comes back After a reinsert the chat store re-landed but the map stayed blank: the tile layer was never told the storage changed. It keeps decoded tiles in s_map_tiles across renders and only renders on pan / zoom / tab-open, so a card that came back produced no reload; a boot with no card never adopted a later-inserted one as the tile cache at all. mapNoteStorageChanged() now runs from every mount-state transition (wedge-recovery remount, 30 s reinsert watch, file-manager insert poll, post-format, and both card-lost paths), right next to the existing flushHistorySoon(). It adopts the card as the tile backend when this boot resolved none, drops the decoded tiles (a swapped card holds different content), clears the fetch dedup ring so downloads that failed during the outage are retried instead of being remembered as in-flight forever, and re-renders immediately when the map is the visible tab. Builds verified: all five SD-relevant touch envs. Signed-off-by: Dan Vybiral --- src/ui-touch/UITask.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c5f1c4b..eb27574 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -4419,6 +4419,7 @@ static void refreshMapInfoLabel(); static void renderMapTiles(); static void renderMapMarkers(); static void freeMapTiles(); +static void mapNoteStorageChanged(); // tile storage appeared/vanished: adopt, invalidate, re-render // Single entry point used by tabChangedCb. Recenters on self GPS and // rebuilds the tile grid. Defined alongside the map state below. static void onMapTabActivated(); @@ -25036,6 +25037,35 @@ static void freeMapTiles() { for (int _ti = 0; _ti < k_map_visible_tiles_max; ++_ti) freeMapTileSlot(s_map_tiles[_ti]); } +// The tile storage changed underneath us: a card was remounted (wedge recovery, +// reinsert watch, file-manager insert poll, post-format) or lost. The map keeps +// decoded tiles in s_map_tiles across renders and only renders on pan / zoom / +// tab-open, so without this a card that came back showed a permanently blank +// map — the chat store re-landed (flushHistorySoon) while the map was never +// told anything had happened. +// +// * adopts the card as the tile cache when this boot had no backend at all +// (booted card-less, or the "tiles" partition is absent — Launcher installs) +// * drops the decoded tiles, since a swapped card holds different content +// * clears the fetch dedup ring so tiles whose download failed during the +// outage are retried instead of being remembered as in-flight forever +// * re-renders immediately when the map is the visible tab +static void mapNoteStorageChanged() { +#if CAP_SD + if (s_sd_mounted && !s_tiles_fs_ready) { + // No tile backend was resolved at boot; the card can serve as one now + // (same layout the boot fallback uses: SD ROOT /tiles///). + s_tile_fs = &SD; + s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + } +#endif + for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; + s_tile_fetch_dedup_head = 0; + freeMapTiles(); + if (g_lv.tabview && lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX) renderMapTiles(); +} + #if defined(ESP32) // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { @@ -45506,6 +45536,7 @@ static void sdHealthTick() { uiDataEnsureDirs(); // segment dir too — a fresh replacement card has neither if (g_lv.task) g_lv.task->flushHistorySoon(); #endif + mapNoteStorageChanged(); // the map layer has to be told too, or it stays blank } else { SD.end(); // leave cardType() honestly CARD_NONE } @@ -45548,11 +45579,13 @@ static void sdHealthTick() { uiDataEnsureDirs(); // segment dir too if (g_lv.task) g_lv.task->flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif + mapNoteStorageChanged(); } else { // Ladder failed — card needs a real power cycle (reinsert). s_sd_mounted is // now false and SD.cardType() reads CARD_NONE, so features degrade honestly // instead of silently failing, and the usual remount paths keep retrying. if (g_lv.task) g_lv.task->showAlert(TR("SD card lost - reinsert to recover"), 2600); + mapNoteStorageChanged(); // drop tiles read off the card that just went away } } #endif @@ -46007,6 +46040,7 @@ void UITask::loop() { uiDataEnsureDirs(); // segment dir too flushHistorySoon(); // land the RAM ring promptly (off-thread — no UI stall) #endif + mapNoteStorageChanged(); if (!s_fm_fs) fmShowRoots(); // refresh roots so it appears } } else if (!s_hist_flush_busy && !s_hist_flush_req && !sdProbeAlive()) { @@ -46018,6 +46052,7 @@ void UITask::loop() { // later write on it fails FR_INVALID_OBJECT -> EBADF ("bad file // number") — recovery must not sabotage the very write it protects. fmSdUnmount(); + mapNoteStorageChanged(); showAlert(TR("SD card removed"), 1500); if (s_fm_fs == &SD || !s_fm_fs) fmShowRoots(); } @@ -46518,6 +46553,7 @@ void UITask::loop() { // next append targets a segment file that is gone. Rebuild + re-land. uiDataEnsureDirs(); flushHistorySoon(); + mapNoteStorageChanged(); char done[56], cs[16]; fmFmtSize64(s_sd_size, cs, sizeof cs); snprintf(done, sizeof done, TR("SD formatted - %s (MESHCOMOD)"), cs); From e26b406e021f46366ae5b49fa32f415df4953dad Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 29 Jul 2026 14:15:17 +0200 Subject: [PATCH 29/99] touch: P4 honours the UI-size pref again (text size was a dead no-op) beta_49 hard-pinned s_ui_fscale=100 on the P4 to stop >100% chrome overflow, but the "UI size" dropdown stays shown (CAP_LARGE_SCREEN), so changing it did nothing (reported: "changing text size doesn't work on the P4"). Honour the saved pref like the Tanmatsu does. Large/Huge can still make some P4 chrome that uses unscaled dims tight; that's a per-screen SC() follow-up, not a reason to disable scaling. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c9b6143..5725aa9 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -534,15 +534,12 @@ static void initTouchFontFallbacks() { // upscaling a low-res frame). g_font_12/14/16 are what the whole UI draws with, so this scales // every screen at once. The colour-emoji + non-Latin fallbacks stay their baked sizes (they don't // grow), which is fine for Latin text. g_font_tab is pinned to 16 px so the bottom bar never grows. -#if defined(HAS_TDISPLAY_P4) - // The P4 is a NARROW 284 px portrait: bigger fonts overflow the fixed-height/width chrome (the - // status bar, home grid, and list/settings rows all use unscaled dims), so 100% is the only size - // that fits. Ignore any Large/Huge pref (a unit can inherit one from adopted T-Deck prefs). The - // Tanmatsu is 800 px landscape and keeps the option. GH: P4 text overflow above 100%. - s_ui_fscale = 100; -#else + // UI scale from the saved pref (Normal/Large/Huge -> 100/140/170%). The P4 honours it like the + // Tanmatsu — its "UI size" dropdown is shown (CAP_LARGE_SCREEN), so pinning 100% here made that + // setting a dead no-op (reported: "changing text size doesn't work"). At Large/Huge some P4 chrome + // that still uses unscaled dims (status bar, home grid, list rows) can be tight; that is the lesser + // problem and a per-screen SC() follow-up, not a reason to disable scaling outright. switch (touchPrefsGetUiScale()) { case 1: s_ui_fscale = 140; break; case 2: s_ui_fscale = 170; break; default: s_ui_fscale = 100; break; } -#endif switch (s_ui_fscale) { case 140: g_font_12 = lv_font_montserrat_16; g_font_14 = lv_font_montserrat_20; g_font_16 = lv_font_montserrat_24; break; // Large ~1.4x case 170: g_font_12 = lv_font_montserrat_20; g_font_14 = lv_font_montserrat_24; g_font_16 = lv_font_montserrat_28; break; // Huge ~1.7x From 0a51e7e5048f5f28c1c5f2bd99ec4e5abfc21fb4 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 29 Jul 2026 14:32:47 +0200 Subject: [PATCH 30/99] release-notes: beta_51 --- release-notes/beta_51.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 release-notes/beta_51.txt diff --git a/release-notes/beta_51.txt b/release-notes/beta_51.txt new file mode 100644 index 0000000..3ca31c4 --- /dev/null +++ b/release-notes/beta_51.txt @@ -0,0 +1,14 @@ +# beta_51 - chat layout, an Airtime tool, and a lockscreen fix +# One user-facing note per non-blank, non-# line; # lines are section comments. + +A community drop. Thanks to Yazutsu, scratchdiver and jacobpretorius. + +# Chat +Message bubbles now show the time on the top row next to the sender, so more messages fit on screen. Applies to channels, DMs and room-server threads. (scratchdiver) + +# New: Airtime tool +New Airtime tool in the Apps drawer measures how busy the LoRa channel really is, so you can check congestion instead of guessing. It shows channel use over time, packets lost at the radio, live signal and noise, the LoRa preset, and a plain verdict on whether congestion is likely. (jacobpretorius) + +# Fixed +Lockscreen wallpaper no longer turns into coloured noise after you use the map. A custom JPG wallpaper could show garbage until a reboot. (Yazutsu, fixes #127) +T-Display P4: the text size setting works again. It was stuck at 100% no matter what you picked. From 077bc12f248cb018804565bfdbca4b49d8f52ab0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 29 Jul 2026 15:11:46 +0200 Subject: [PATCH 31/99] chore: refresh Mesh America catalog for beta_50 --- deploy/meshamerica-catalog.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/deploy/meshamerica-catalog.json b/deploy/meshamerica-catalog.json index 1cb2595..0c76e19 100644 --- a/deploy/meshamerica-catalog.json +++ b/deploy/meshamerica-catalog.json @@ -17,19 +17,19 @@ "role": "gui", "title": "Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-heltec-v4-tft-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-heltec-v4-tft.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft.bin", "title": "Update (app only)" } ] @@ -49,19 +49,19 @@ "role": "gui", "title": "Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tdeck-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tdeck.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck.bin", "title": "Update (app only)" } ] @@ -81,19 +81,19 @@ "role": "gui", "title": "[LR1121] Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tlora-pager-lr1121-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tlora-pager-lr1121.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121.bin", "title": "Update (app only)" } ] @@ -104,19 +104,19 @@ "role": "gui", "title": "[SX1262] Touch UI", "version": { - "beta_46": { - "notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.", + "beta_50": { + "notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.", "files": [ { "type": "flash-wipe", "name": "wadamesh-tlora-pager-sx1262-merged.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262-merged.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262-merged.bin", "title": "Full install (bootloader + firmware)" }, { "type": "flash-update", "name": "wadamesh-tlora-pager-sx1262.bin", - "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262.bin", + "url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262.bin", "title": "Update (app only)" } ] From dd7ba1f9f96d858d594a8e99563bceb2e09be05b Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 29 Jul 2026 16:07:53 +0200 Subject: [PATCH 32/99] touch: fix segmented-store latch at the segment-table cap (SD I/O dies after hours) Root cause of the reported "SD writes fail after a few hours of uptime": when the segment table is full (s_seg_count == k_ui_seg_max), segBuildJob still emits a CREATE-append, but segCommitJob only tables a create while s_seg_count < the cap. The new segment file is written but never recorded, the durability watermark (s_seg_flushed_seq) never advances, so the same append re-arms every flush cycle (permanent write-spin). As the ring evicts the frozen records the target first_seq shifts, so a new orphan seg_*.bin is written each cycle until the card fills and every SD write fails; records evicted before they are credited are lost. It surfaces only after hours because s_seg_count creeps from its ~20 steady state (5000/256) up to the 24 cap slowly, via delete-driven fragmentation and step-1-append starving step-2 retirement. Fix: at the cap, defer the create-append instead of emitting an untableable one. segBuildJob skips step 1 (before the gather/watermark advance, so the watermark is never moved past un-written records) and falls through to step 2, which retires the oldest segment first. At the cap the oldest 256 records have aged out of the ring (24*256 > 5000), so that segment is fully evicted -> compact_dirty -> unlinked, freeing a slot with no live-record loss; the deferred records are the newest (above the watermark, evicted last) and land next cycle. saveMsgsToStorage's early "armed == 0 -> durable" return is guarded with segMoreWorkPending so a deferred append is reported as a failure, not a false success. Below the cap: no-op. Builds green on T-Deck + Heltec V4 TFT. Needs a multi-hour on-device soak to confirm against the original repro. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 64 ++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index eb27574..a12d431 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -42501,28 +42501,44 @@ static int segBuildJob(const UITask::UIMessage* ring, int cap, int count, int he target = s_seg[s_seg_count - 1].first_seq; room = k_ui_seg_records - (int)s_seg[s_seg_count - 1].disk_recs; } - int n = 0; - uint32_t last = s_seg_flushed_seq; - for (int i = 0; i < count; ++i) { - const int slot = (head - count + i + cap) % cap; - const UITask::UIMessage& m = ring[slot]; - if (m.seq <= s_seg_flushed_seq) continue; - if (m.thread[0]) { - if (n >= room) break; // segment full — the rest rides the next cycle - buf[n++] = m; + // At the table cap (s_seg_count == k_ui_seg_max) a CREATE-append cannot be + // recorded: segCommitJob only tables a create while s_seg_count < the cap, so + // the file it writes becomes an untabled orphan AND the durability watermark + // never advances -> the same append re-arms every cycle (permanent write-spin), + // orphaned seg_*.bin pile up as the ring shifts the target first_seq, and the + // card eventually fills ("SD I/O fails after a few hours"). DEFER the append: + // skip step 1 entirely (crucially, do NOT run the gather/watermark advance + // below on un-written records) and fall through to step 2, which retires the + // oldest segment first. At the cap the oldest 256 records have aged out of the + // 5000-record ring (24*256 disk slots > ring), so that segment is fully + // evicted -> compact_dirty (segNoteEvicted) -> step 2 unlinks it and frees a + // slot with NO live-record loss. The deferred records are the NEWEST (above + // the watermark, so the ring evicts them last); they wait safely in RAM and + // land next cycle once a slot is free. Below the cap this is a no-op. + if (!(create && s_seg_count >= k_ui_seg_max)) { + int n = 0; + uint32_t last = s_seg_flushed_seq; + for (int i = 0; i < count; ++i) { + const int slot = (head - count + i + cap) % cap; + const UITask::UIMessage& m = ring[slot]; + if (m.seq <= s_seg_flushed_seq) continue; + if (m.thread[0]) { + if (n >= room) break; // segment full — the rest rides the next cycle + buf[n++] = m; + } + if (m.seq > last) last = m.seq; // deleted-before-flush records advance the + // watermark with no write (never hit disk) } - if (m.seq > last) last = m.seq; // deleted-before-flush records advance the - // watermark with no write (never hit disk) + if (n > 0) { + out->kind = SEGJOB_APPEND; + out->first_seq = create ? buf[0].seq : target; + out->last_seq = last; + out->create = create; + out->n = n; + return 1; + } + if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail } - if (n > 0) { - out->kind = SEGJOB_APPEND; - out->first_seq = create ? buf[0].seq : target; - out->last_seq = last; - out->create = create; - out->n = n; - return 1; - } - if (last > s_seg_flushed_seq) s_seg_flushed_seq = last; // pure-tombstone tail // 2) Oldest compact-dirty segment: rewrite it from the ring's live records. for (int i = 0; i < s_seg_count; ++i) { if (!s_seg[i].compact_dirty) continue; @@ -42745,6 +42761,14 @@ bool UITask::saveMsgsToStorage() { SegJob job{}; const int armed = segBuildJob(_ui_msgs, _ui_msg_cap, _ui_msg_count, _ui_msg_head, buf, &job); if (armed == 0) { + // segBuildJob returns 0 for "all durable" AND for a create-append it had to + // DEFER at the segment cap with no slot freeable this pass (rare + // fragmentation edge — the common at-cap path retires the oldest evicted + // segment inside this loop and resolves). Only the former is saved; report + // the latter as a failure so the caller surfaces it and retries, instead of + // dropping the newest records on a false success. Normal case: + // segMoreWorkPending is false here, so this is a no-op. + if (segMoreWorkPending(_ui_seq_next ? _ui_seq_next - 1 : 0)) return false; if (s_seg_stale_purge) segPurgeStaleFiles(); // everything landed — drop leftovers return true; } From 8fff2811edc2b7afc941fef40cfadcdd13a034f5 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Wed, 29 Jul 2026 17:08:50 +0200 Subject: [PATCH 33/99] release-notes: beta_52 (RISKY - #179 storage rework, needs testing) --- release-notes/beta_52.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 release-notes/beta_52.txt diff --git a/release-notes/beta_52.txt b/release-notes/beta_52.txt new file mode 100644 index 0000000..166e1f2 --- /dev/null +++ b/release-notes/beta_52.txt @@ -0,0 +1,13 @@ +# beta_52 - major SD chat-history storage rework (community) - RISKY, NEEDS TESTING +# One user-facing note per non-blank, non-# line; # lines are section comments. + +WARNING - RISKY TEST BUILD. This beta reworks how chat history is stored on the SD card. It has NOT been tested over long uptime yet and may have data-loss bugs. +WARNING - The chat-history format CHANGES and the change is ONE-WAY. If you flash an older firmware after running this, your saved chat history is lost. Back it up first if it matters to you. +WARNING - Do not run this as your only or daily device unless you are OK risking your chat history. We specifically need people to run it for hours or days and report any SD errors or history-not-saving problems. + +# What changed +Major SD storage rework, contributed by the community (Vybo): the device now recovers from an SD card that wedges or is pulled at runtime instead of needing a reboot; the About page shows chat-store health (backend, save time, failures); and chat history is now written as small append-friendly segment files instead of rewriting the whole file on every save. +Fixed the follow-on bug where SD writes started failing after a few hours of uptime once the segment store's table filled up. + +# Everything from beta_51 is also included +Chat bubbles show the time on the top row, the new Airtime channel-utilization tool, the lockscreen wallpaper corruption fix, and the T-Display P4 text-size fix. From dc684a763e51968f4dc698cc6078153a6851d6c9 Mon Sep 17 00:00:00 2001 From: Fr Cyril Date: Wed, 29 Jul 2026 09:07:02 -0700 Subject: [PATCH 34/99] =?UTF-8?q?Add=20Romanian=20(Rom=C3=A2n=C4=83)=20UI?= =?UTF-8?q?=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fr Cyril --- src/ui-touch/i18n.cpp | 1460 ++++++++++++++++++++--------------------- src/ui-touch/i18n.h | 2 +- 2 files changed, 731 insertions(+), 731 deletions(-) diff --git a/src/ui-touch/i18n.cpp b/src/ui-touch/i18n.cpp index 57f194c..6ae104f 100644 --- a/src/ui-touch/i18n.cpp +++ b/src/ui-touch/i18n.cpp @@ -5,7 +5,7 @@ const char* const kUiLangNames[LANG_COUNT] = { "English", "Nederlands", "Deutsch", "Français", "Español", "Italiano", "Русский", "Українська", "Български", "Српски", "Ελληνικά", - "Português (BR)", + "Português (BR)", "Română", }; static uint8_t s_ui_lang = LANG_EN; @@ -23,19 +23,19 @@ uint8_t i18nGetLang() { return s_ui_lang; } // frame), so order does not matter and you can append anywhere. struct I18nEntry { const char* en; const char* tr[LANG_COUNT]; }; -// NL DE FR ES IT RU UK BG SR EL +// NL DE FR ES IT RU UK BG SR EL PT_BR RO static const I18nEntry kI18n[] = { - { "Profile", { 0, "Profiel", "Profil", "Profil", "Perfil", "Profilo", "Профиль", "Профіль", "Профил", "Профил", "Προφίλ" ,"Perfil"} }, - { "Radio & Mesh", { 0, "Radio & Mesh", "Funk & Mesh", "Radio et mesh", "Radio y mesh", "Radio e mesh", "Радио и меш", "Радіо та меш", "Радио и меш", "Радио и меш", "Ράδιο & Mesh" ,"Rádio & Mesh"} }, - { "Display", { 0, "Scherm", "Anzeige", "Affichage", "Pantalla", "Schermo", "Экран", "Екран", "Дисплей", "Приказ", "Οθόνη" ,"Tela"} }, - { "Keyboard", { 0, "Toetsenbord", "Tastatur", "Clavier", "Teclado", "Tastiera", "Клавиатура", "Клавіатура", "Клавиатура", "Тастатура", "Πληκτρολόγιο" ,"Teclado"} }, - { "Quick replies", { 0, "Snelle antwoorden","Schnellantworten", "Réponses rapides", "Respuestas rápidas", "Risposte rapide", "Быстрые ответы", "Швидкі відповіді", "Бързи отговори", "Брзи одговори", "Γρήγορες απαντήσεις" ,"Respostas rápidas"} }, - { "Sound", { 0, "Geluid", "Ton", "Son", "Sonido", "Suono", "Звук", "Звук", "Звук", "Звук", "Ήχος" ,"Som"} }, - { "Lock screen", { 0, "Vergrendelscherm", "Sperrbildschirm", "Écran de verrouillage", "Pantalla de bloqueo", "Schermata di blocco", "Экран блокировки", "Екран блокування", "Заключен екран", "Закључани екран", "Οθόνη κλειδώματος" ,"Tela de bloqueio"} }, - { "System", { 0, "Systeem", "System", "Système", "Sistema", "Sistema", "Система", "Система", "Система", "Систем", "Σύστημα" ,"Sistema"} }, - { "About", { 0, "Over", "Über", "À propos", "Acerca de", "Informazioni", "О программе", "Про програму", "Относно", "О програму", "Σχετικά" ,"Sobre"} }, - { "Language", { 0, "Taal", "Sprache", "Langue", "Idioma", "Lingua", "Язык", "Мова", "Език", "Језик", "Γλώσσα" ,"Idioma"} }, - { "Settings", { 0, "Instellingen", "Einstellungen", "Paramètres", "Ajustes", "Impostazioni", "Настройки", "Налаштування", "Настройки", "Подешавања", "Ρυθμίσεις" ,"Ajustes"} }, + { "Profile", { 0, "Profiel", "Profil", "Profil", "Perfil", "Profilo", "Профиль", "Профіль", "Профил", "Профил", "Προφίλ" ,"Perfil","Profil" } }, + { "Radio & Mesh", { 0, "Radio & Mesh", "Funk & Mesh", "Radio et mesh", "Radio y mesh", "Radio e mesh", "Радио и меш", "Радіо та меш", "Радио и меш", "Радио и меш", "Ράδιο & Mesh" ,"Rádio & Mesh","Radio şi mesh" } }, + { "Display", { 0, "Scherm", "Anzeige", "Affichage", "Pantalla", "Schermo", "Экран", "Екран", "Дисплей", "Приказ", "Οθόνη" ,"Tela","Afişaj" } }, + { "Keyboard", { 0, "Toetsenbord", "Tastatur", "Clavier", "Teclado", "Tastiera", "Клавиатура", "Клавіатура", "Клавиатура", "Тастатура", "Πληκτρολόγιο" ,"Teclado","Tastatură" } }, + { "Quick replies", { 0, "Snelle antwoorden","Schnellantworten", "Réponses rapides", "Respuestas rápidas", "Risposte rapide", "Быстрые ответы", "Швидкі відповіді", "Бързи отговори", "Брзи одговори", "Γρήγορες απαντήσεις" ,"Respostas rápidas","Răspunsuri rapide" } }, + { "Sound", { 0, "Geluid", "Ton", "Son", "Sonido", "Suono", "Звук", "Звук", "Звук", "Звук", "Ήχος" ,"Som","Sunet" } }, + { "Lock screen", { 0, "Vergrendelscherm", "Sperrbildschirm", "Écran de verrouillage", "Pantalla de bloqueo", "Schermata di blocco", "Экран блокировки", "Екран блокування", "Заключен екран", "Закључани екран", "Οθόνη κλειδώματος" ,"Tela de bloqueio","Ecran de blocare" } }, + { "System", { 0, "Systeem", "System", "Système", "Sistema", "Sistema", "Система", "Система", "Система", "Систем", "Σύστημα" ,"Sistema","Sistem" } }, + { "About", { 0, "Over", "Über", "À propos", "Acerca de", "Informazioni", "О программе", "Про програму", "Относно", "О програму", "Σχετικά" ,"Sobre","Despre" } }, + { "Language", { 0, "Taal", "Sprache", "Langue", "Idioma", "Lingua", "Язык", "Мова", "Език", "Језик", "Γλώσσα" ,"Idioma","Limbă" } }, + { "Settings", { 0, "Instellingen", "Einstellungen", "Paramètres", "Ajustes", "Impostazioni", "Настройки", "Налаштування", "Настройки", "Подешавања", "Ρυθμίσεις" ,"Ajustes","Setări" } }, { "Tap a language; the device reboots to apply.", { 0, "Tik op een taal; het apparaat herstart.", "Sprache antippen; Gerät startet neu.", @@ -46,748 +46,748 @@ static const I18nEntry kI18n[] = { "Виберіть мову; пристрій перезавантажиться.", "Изберете език; устройството ще се рестартира.", "Изаберите језик; уређај ће се рестартовати.", - "Πατήστε μια γλώσσα· η συσκευή επανεκκινεί." ,"Toque num idioma; o aparelho reinicia para aplicar."} }, + "Πατήστε μια γλώσσα· η συσκευή επανεκκινεί." ,"Toque num idioma; o aparelho reinicia para aplicar.","Atinge o limbă; dispozitivul reporneşte pentru aplicare." } }, // ---- Settings detail pages (sweep pass 1: Display / Keyboard / Sound / System) ---- - { "Save", { 0, "Opslaan","Speichern","Enregistrer","Guardar","Salva","Сохранить","Зберегти","Запази","Сачувај","Αποθήκευση" ,"Salvar"} }, - { "Toggle GPS", { 0, "GPS aan/uit","GPS umschalten","GPS activer/désactiver","Alternar GPS","Attiva/disattiva GPS","Переключить GPS","Перемкнути GPS","Превключи GPS","Укључи/искључи GPS","Εναλλαγή GPS" ,"Alternar GPS"} }, - { "GPS serial baud", { 0, "GPS seriële baud","GPS-Baudrate","Débit série GPS","Baudios serie GPS","Baud seriale GPS","Скорость GPS (бод)","Швидкість GPS (бод)","GPS сериен baud","GPS серијски baud","GPS σειριακό baud" ,"Baud serial do GPS"} }, - { "Sync clock from system", { 0, "Klok synchroniseren","Uhr synchronisieren","Synchroniser l'horloge","Sincronizar reloj","Sincronizza orologio","Синхронизировать часы","Синхронізувати годинник","Синхронизирай часовника","Синхронизуј сат","Συγχρονισμός ρολογιού" ,"Sincronizar relógio do sistema"} }, - { "Send advert now", { 0, "Nu advert verzenden","Advert jetzt senden","Envoyer l'annonce","Enviar anuncio ahora","Invia advert ora","Отправить advert","Надіслати advert","Изпрати advert","Пошаљи advert","Αποστολή advert" ,"Enviar advert agora"} }, - { "Reboot device", { 0, "Apparaat herstarten","Gerät neu starten","Redémarrer l'appareil","Reiniciar dispositivo","Riavvia dispositivo","Перезагрузить устройство","Перезавантажити пристрій","Рестартирай устройството","Рестартуј уређај","Επανεκκίνηση συσκευής" ,"Reiniciar aparelho"} }, - { "Run setup again", { 0, "Setup opnieuw uitvoeren","Setup erneut ausführen","Relancer la configuration","Ejecutar configuración de nuevo","Esegui di nuovo la configurazione","Запустить настройку снова","Запустити налаштування знову","Стартирай настройката отново","Поново покрени подешавање","Εκτέλεση ρύθμισης ξανά" ,"Executar configuração de novo"} }, - { "Distance in miles", { 0, "Afstand in mijlen","Entfernung in Meilen","Distance en miles","Distancia en millas","Distanza in miglia","Расстояние в милях","Відстань у милях","Разстояние в мили","Растојање у миљама","Απόσταση σε μίλια" ,"Distância em milhas"} }, - { "Colourful chat bubbles", { 0, "Kleurrijke chatbubbels","Bunte Chat-Blasen","Bulles de chat colorées","Burbujas de chat coloridas","Fumetti chat colorati","Цветные пузыри чата","Кольорові бульбашки чату","Цветни чат балончета","Шарени облачићи ћаскања","Χρωματιστές φούσκες" ,"Balões de chat coloridos"} }, - { "Theme colour", { 0, "Themakleur","Themenfarbe","Couleur du thème","Color del tema","Colore tema","Цвет темы","Колір теми","Цвят на темата","Боја теме","Χρώμα θέματος" ,"Cor do tema"} }, - { "Pick colour", { 0, "Kies kleur","Farbe wählen","Choisir la couleur","Elegir color","Scegli colore","Выбрать цвет","Вибрати колір","Избери цвят","Изабери боју","Επιλογή χρώματος" ,"Escolher cor"} }, - { "Secondary keyboards", { 0, "Secundaire toetsenborden","Sekundäre Tastaturen","Claviers secondaires","Teclados secundarios","Tastiere secondarie","Дополнительные клавиатуры","Додаткові клавіатури","Допълнителни клавиатури","Секундарне тастатуре","Δευτερεύοντα πληκτρολόγια" ,"Teclados secundários"} }, - { "Accent popups", { 0, "Accent-popups","Akzent-Popups","Fenêtres d'accents","Ventanas de acentos","Popup accenti","Подсказки акцентов","Підказки акцентів","Изскачащи акценти","Искачући акценти","Αναδυόμενα τόνων" ,"Pop-ups de acento"} }, - { "Screen timeout (s, 0 = never)", { 0, "Schermtime-out (s, 0 = nooit)","Bildschirm-Timeout (s, 0 = nie)","Délai d'écran (s, 0 = jamais)","Tiempo de pantalla (s, 0 = nunca)","Timeout schermo (s, 0 = mai)","Тайм-аут экрана (с, 0 = никогда)","Тайм-аут екрана (с, 0 = ніколи)","Изключване на екрана (с, 0 = никога)","Истек екрана (с, 0 = никад)","Λήξη οθόνης (s, 0 = ποτέ)" ,"Tempo de tela (s, 0 = nunca)"} }, - { "Orientation (tap to rotate, reboots)", { 0, "Oriëntatie (tik om te draaien, herstart)","Ausrichtung (tippen zum Drehen, Neustart)","Orientation (toucher pour pivoter, redémarre)","Orientación (toca para girar, reinicia)","Orientamento (tocca per ruotare, riavvia)","Ориентация (нажмите для поворота, перезагрузка)","Орієнтація (торкніться, щоб обернути)","Ориентация (докоснете за завъртане, рестарт)","Оријентација (додирните за ротацију)","Προσανατολισμός (πατήστε για περιστροφή)" ,"Orientação (toque para girar, reinicia)"} }, - { "Lock screen wallpaper", { 0, "Vergrendelscherm-achtergrond","Sperrbildschirm-Hintergrund","Fond d'écran de verrouillage","Fondo de pantalla de bloqueo","Sfondo schermata di blocco","Обои экрана блокировки","Шпалери екрана блокування","Тапет на заключен екран","Позадина закључаног екрана","Ταπετσαρία οθόνης κλειδώματος" ,"Papel de parede do bloqueio"} }, - { "Lock text colour", { 0, "Tekstkleur vergrendeling","Sperrtext-Farbe","Couleur du texte de verrouillage","Color del texto de bloqueo","Colore testo blocco","Цвет текста блокировки","Колір тексту блокування","Цвят на текста при заключване","Боја текста закључавања","Χρώμα κειμένου κλειδώματος" ,"Cor do texto de bloqueio"} }, - { "Time offset (vs automatic)", { 0, "Tijdverschuiving (t.o.v. automatisch)","Zeitversatz (vs. automatisch)","Décalage horaire (vs auto)","Desfase horario (vs automático)","Sfasamento orario (vs automatico)","Смещение времени (от авто)","Зсув часу (від авто)","Изместване на времето (спрямо авто)","Помак времена (у односу на ауто)","Μετατόπιση ώρας (έναντι αυτόματης)" ,"Fuso horário (vs automático)"} }, - { "Store data on SD (reboot)", { 0, "Gegevens op SD opslaan (herstart)","Daten auf SD speichern (Neustart)","Stocker les données sur SD (redémarrage)","Guardar datos en SD (reinicio)","Salva dati su SD (riavvio)","Хранить данные на SD (перезагрузка)","Зберігати дані на SD (перезавантаження)","Съхранявай данните на SD (рестарт)","Чувај податке на SD (рестарт)","Αποθήκευση δεδομένων σε SD (επανεκκίνηση)" ,"Salvar dados no SD (reinicia)"} }, - { "Calibrate battery (full = 100%)", { 0, "Batterij kalibreren (vol = 100%)","Batterie kalibrieren (voll = 100%)","Étalonner la batterie (plein = 100%)","Calibrar batería (lleno = 100%)","Calibra batteria (pieno = 100%)","Калибровать батарею (полная = 100%)","Калібрувати батарею (повна = 100%)","Калибрирай батерията (пълна = 100%)","Калибриши батерију (пуна = 100%)","Βαθμονόμηση μπαταρίας (πλήρης = 100%)" ,"Calibrar bateria (cheia = 100%)"} }, - { "Sound: %s", { 0, "Geluid: %s","Ton: %s","Son : %s","Sonido: %s","Suono: %s","Звук: %s","Звук: %s","Звук: %s","Звук: %s","Ήχος: %s" ,"Som: %s"} }, - { "on", { 0, "aan","an","activé","activado","attivo","вкл","увімк","включен","укључен","ενεργός" ,"ligado"} }, - { "off", { 0, "uit","aus","désactivé","desactivado","disattivo","выкл","вимк","изключен","искључен","ανενεργός" ,"desligado"} }, - { "Model:", { 0, "Model:","Modell:","Modèle :","Modelo:","Modello:","Модель:","Модель:","Модел:","Модел:","Μοντέλο:" ,"Modelo:"} }, - { "Public key:", { 0, "Publieke sleutel:","Öffentl. Schlüssel:","Clé publique :","Clave pública:","Chiave pubblica:","Открытый ключ:","Відкритий ключ:","Публичен ключ:","Јавни кључ:","Δημόσιο κλειδί:" ,"Chave pública:"} }, - { "Contacts:", { 0, "Contacten:","Kontakte:","Contacts :","Contactos:","Contatti:","Контакты:","Контакти:","Контакти:","Контакти:","Επαφές:" ,"Contatos:"} }, - { "Channels:", { 0, "Kanalen:","Kanäle:","Canaux :","Canales:","Canali:","Каналы:","Канали:","Канали:","Канали:","Κανάλια:" ,"Canais:"} }, - { "Battery:", { 0, "Batterij:","Batterie:","Batterie :","Batería:","Batteria:","Батарея:","Батарея:","Батерия:","Батерија:","Μπαταρία:" ,"Bateria:"} }, - { "Device time:", { 0, "Apparaattijd:","Gerätezeit:","Heure de l'appareil :","Hora del dispositivo:","Ora dispositivo:","Время устройства:","Час пристрою:","Време на устройството:","Време уређаја:","Ώρα συσκευής:" ,"Hora do aparelho:"} }, + { "Save", { 0, "Opslaan","Speichern","Enregistrer","Guardar","Salva","Сохранить","Зберегти","Запази","Сачувај","Αποθήκευση" ,"Salvar","Salvează" } }, + { "Toggle GPS", { 0, "GPS aan/uit","GPS umschalten","GPS activer/désactiver","Alternar GPS","Attiva/disattiva GPS","Переключить GPS","Перемкнути GPS","Превключи GPS","Укључи/искључи GPS","Εναλλαγή GPS" ,"Alternar GPS","Comută GPS" } }, + { "GPS serial baud", { 0, "GPS seriële baud","GPS-Baudrate","Débit série GPS","Baudios serie GPS","Baud seriale GPS","Скорость GPS (бод)","Швидкість GPS (бод)","GPS сериен baud","GPS серијски baud","GPS σειριακό baud" ,"Baud serial do GPS","Baud serial GPS" } }, + { "Sync clock from system", { 0, "Klok synchroniseren","Uhr synchronisieren","Synchroniser l'horloge","Sincronizar reloj","Sincronizza orologio","Синхронизировать часы","Синхронізувати годинник","Синхронизирай часовника","Синхронизуј сат","Συγχρονισμός ρολογιού" ,"Sincronizar relógio do sistema","Sincronizează ceasul din sistem" } }, + { "Send advert now", { 0, "Nu advert verzenden","Advert jetzt senden","Envoyer l'annonce","Enviar anuncio ahora","Invia advert ora","Отправить advert","Надіслати advert","Изпрати advert","Пошаљи advert","Αποστολή advert" ,"Enviar advert agora","Trimite advert acum" } }, + { "Reboot device", { 0, "Apparaat herstarten","Gerät neu starten","Redémarrer l'appareil","Reiniciar dispositivo","Riavvia dispositivo","Перезагрузить устройство","Перезавантажити пристрій","Рестартирай устройството","Рестартуј уређај","Επανεκκίνηση συσκευής" ,"Reiniciar aparelho","Reporneşte dispozitivul" } }, + { "Run setup again", { 0, "Setup opnieuw uitvoeren","Setup erneut ausführen","Relancer la configuration","Ejecutar configuración de nuevo","Esegui di nuovo la configurazione","Запустить настройку снова","Запустити налаштування знову","Стартирай настройката отново","Поново покрени подешавање","Εκτέλεση ρύθμισης ξανά" ,"Executar configuração de novo","Rulează configurarea din nou" } }, + { "Distance in miles", { 0, "Afstand in mijlen","Entfernung in Meilen","Distance en miles","Distancia en millas","Distanza in miglia","Расстояние в милях","Відстань у милях","Разстояние в мили","Растојање у миљама","Απόσταση σε μίλια" ,"Distância em milhas","Distanţă în mile" } }, + { "Colourful chat bubbles", { 0, "Kleurrijke chatbubbels","Bunte Chat-Blasen","Bulles de chat colorées","Burbujas de chat coloridas","Fumetti chat colorati","Цветные пузыри чата","Кольорові бульбашки чату","Цветни чат балончета","Шарени облачићи ћаскања","Χρωματιστές φούσκες" ,"Balões de chat coloridos","Baloane de chat colorate" } }, + { "Theme colour", { 0, "Themakleur","Themenfarbe","Couleur du thème","Color del tema","Colore tema","Цвет темы","Колір теми","Цвят на темата","Боја теме","Χρώμα θέματος" ,"Cor do tema","Culoare temă" } }, + { "Pick colour", { 0, "Kies kleur","Farbe wählen","Choisir la couleur","Elegir color","Scegli colore","Выбрать цвет","Вибрати колір","Избери цвят","Изабери боју","Επιλογή χρώματος" ,"Escolher cor","Alege culoarea" } }, + { "Secondary keyboards", { 0, "Secundaire toetsenborden","Sekundäre Tastaturen","Claviers secondaires","Teclados secundarios","Tastiere secondarie","Дополнительные клавиатуры","Додаткові клавіатури","Допълнителни клавиатури","Секундарне тастатуре","Δευτερεύοντα πληκτρολόγια" ,"Teclados secundários","Tastaturi secundare" } }, + { "Accent popups", { 0, "Accent-popups","Akzent-Popups","Fenêtres d'accents","Ventanas de acentos","Popup accenti","Подсказки акцентов","Підказки акцентів","Изскачащи акценти","Искачући акценти","Αναδυόμενα τόνων" ,"Pop-ups de acento","Popupuri cu accente" } }, + { "Screen timeout (s, 0 = never)", { 0, "Schermtime-out (s, 0 = nooit)","Bildschirm-Timeout (s, 0 = nie)","Délai d'écran (s, 0 = jamais)","Tiempo de pantalla (s, 0 = nunca)","Timeout schermo (s, 0 = mai)","Тайм-аут экрана (с, 0 = никогда)","Тайм-аут екрана (с, 0 = ніколи)","Изключване на екрана (с, 0 = никога)","Истек екрана (с, 0 = никад)","Λήξη οθόνης (s, 0 = ποτέ)" ,"Tempo de tela (s, 0 = nunca)","Stingere ecran (s, 0 = niciodată)" } }, + { "Orientation (tap to rotate, reboots)", { 0, "Oriëntatie (tik om te draaien, herstart)","Ausrichtung (tippen zum Drehen, Neustart)","Orientation (toucher pour pivoter, redémarre)","Orientación (toca para girar, reinicia)","Orientamento (tocca per ruotare, riavvia)","Ориентация (нажмите для поворота, перезагрузка)","Орієнтація (торкніться, щоб обернути)","Ориентация (докоснете за завъртане, рестарт)","Оријентација (додирните за ротацију)","Προσανατολισμός (πατήστε για περιστροφή)" ,"Orientação (toque para girar, reinicia)","Orientare (atinge pentru rotire, reporneşte)" } }, + { "Lock screen wallpaper", { 0, "Vergrendelscherm-achtergrond","Sperrbildschirm-Hintergrund","Fond d'écran de verrouillage","Fondo de pantalla de bloqueo","Sfondo schermata di blocco","Обои экрана блокировки","Шпалери екрана блокування","Тапет на заключен екран","Позадина закључаног екрана","Ταπετσαρία οθόνης κλειδώματος" ,"Papel de parede do bloqueio","Fundal ecran de blocare" } }, + { "Lock text colour", { 0, "Tekstkleur vergrendeling","Sperrtext-Farbe","Couleur du texte de verrouillage","Color del texto de bloqueo","Colore testo blocco","Цвет текста блокировки","Колір тексту блокування","Цвят на текста при заключване","Боја текста закључавања","Χρώμα κειμένου κλειδώματος" ,"Cor do texto de bloqueio","Culoare text blocare" } }, + { "Time offset (vs automatic)", { 0, "Tijdverschuiving (t.o.v. automatisch)","Zeitversatz (vs. automatisch)","Décalage horaire (vs auto)","Desfase horario (vs automático)","Sfasamento orario (vs automatico)","Смещение времени (от авто)","Зсув часу (від авто)","Изместване на времето (спрямо авто)","Помак времена (у односу на ауто)","Μετατόπιση ώρας (έναντι αυτόματης)" ,"Fuso horário (vs automático)","Decalaj orar (faţă de automat)" } }, + { "Store data on SD (reboot)", { 0, "Gegevens op SD opslaan (herstart)","Daten auf SD speichern (Neustart)","Stocker les données sur SD (redémarrage)","Guardar datos en SD (reinicio)","Salva dati su SD (riavvio)","Хранить данные на SD (перезагрузка)","Зберігати дані на SD (перезавантаження)","Съхранявай данните на SD (рестарт)","Чувај податке на SD (рестарт)","Αποθήκευση δεδομένων σε SD (επανεκκίνηση)" ,"Salvar dados no SD (reinicia)","Stochează datele pe SD (repornire)" } }, + { "Calibrate battery (full = 100%)", { 0, "Batterij kalibreren (vol = 100%)","Batterie kalibrieren (voll = 100%)","Étalonner la batterie (plein = 100%)","Calibrar batería (lleno = 100%)","Calibra batteria (pieno = 100%)","Калибровать батарею (полная = 100%)","Калібрувати батарею (повна = 100%)","Калибрирай батерията (пълна = 100%)","Калибриши батерију (пуна = 100%)","Βαθμονόμηση μπαταρίας (πλήρης = 100%)" ,"Calibrar bateria (cheia = 100%)","Calibrează bateria (plină = 100%)" } }, + { "Sound: %s", { 0, "Geluid: %s","Ton: %s","Son : %s","Sonido: %s","Suono: %s","Звук: %s","Звук: %s","Звук: %s","Звук: %s","Ήχος: %s" ,"Som: %s","Sunet: %s" } }, + { "on", { 0, "aan","an","activé","activado","attivo","вкл","увімк","включен","укључен","ενεργός" ,"ligado","pornit" } }, + { "off", { 0, "uit","aus","désactivé","desactivado","disattivo","выкл","вимк","изключен","искључен","ανενεργός" ,"desligado","oprit" } }, + { "Model:", { 0, "Model:","Modell:","Modèle :","Modelo:","Modello:","Модель:","Модель:","Модел:","Модел:","Μοντέλο:" ,"Modelo:","Model:" } }, + { "Public key:", { 0, "Publieke sleutel:","Öffentl. Schlüssel:","Clé publique :","Clave pública:","Chiave pubblica:","Открытый ключ:","Відкритий ключ:","Публичен ключ:","Јавни кључ:","Δημόσιο κλειδί:" ,"Chave pública:","Cheie publică:" } }, + { "Contacts:", { 0, "Contacten:","Kontakte:","Contacts :","Contactos:","Contatti:","Контакты:","Контакти:","Контакти:","Контакти:","Επαφές:" ,"Contatos:","Contacte:" } }, + { "Channels:", { 0, "Kanalen:","Kanäle:","Canaux :","Canales:","Canali:","Каналы:","Канали:","Канали:","Канали:","Κανάλια:" ,"Canais:","Canale:" } }, + { "Battery:", { 0, "Batterij:","Batterie:","Batterie :","Batería:","Batteria:","Батарея:","Батарея:","Батерия:","Батерија:","Μπαταρία:" ,"Bateria:","Baterie:" } }, + { "Device time:", { 0, "Apparaattijd:","Gerätezeit:","Heure de l'appareil :","Hora del dispositivo:","Ora dispositivo:","Время устройства:","Час пристрою:","Време на устройството:","Време уређаја:","Ώρα συσκευής:" ,"Hora do aparelho:","Ora dispozitivului:" } }, // ---- Common buttons / actions (whole-OS) ---- - { "Close", { 0, "Sluiten","Schließen","Fermer","Cerrar","Chiudi","Закрыть","Закрити","Затвори","Затвори","Κλείσιμο" ,"Fechar"} }, - { "Cancel", { 0, "Annuleren","Abbrechen","Annuler","Cancelar","Annulla","Отмена","Скасувати","Отказ","Откажи","Άκυρο" ,"Cancelar"} }, - { "Add", { 0, "Toevoegen","Hinzufügen","Ajouter","Añadir","Aggiungi","Добавить","Додати","Добави","Додај","Προσθήκη" ,"Adicionar"} }, - { "Apply", { 0, "Toepassen","Anwenden","Appliquer","Aplicar","Applica","Применить","Застосувати","Приложи","Примени","Εφαρμογή" ,"Aplicar"} }, - { "Clear", { 0, "Wissen","Löschen","Effacer","Borrar","Cancella","Очистить","Очистити","Изчисти","Обриши","Εκκαθάριση" ,"Limpar"} }, - { "Reset", { 0, "Herstellen","Zurücksetzen","Réinitialiser","Restablecer","Ripristina","Сброс","Скинути","Нулиране","Ресетуј","Επαναφορά" ,"Redefinir"} }, - { "Create", { 0, "Aanmaken","Erstellen","Créer","Crear","Crea","Создать","Створити","Създай","Направи","Δημιουργία" ,"Criar"} }, - { "Join", { 0, "Deelnemen","Beitreten","Rejoindre","Unirse","Unisciti","Присоединиться","Приєднатися","Присъедини се","Придружи се","Συμμετοχή" ,"Entrar"} }, - { "Find", { 0, "Zoeken","Suchen","Rechercher","Buscar","Trova","Найти","Знайти","Намери","Пронађи","Εύρεση" ,"Buscar"} }, - { "Unblock", { 0, "Deblokkeren","Entsperren","Débloquer","Desbloquear","Sblocca","Разблокировать","Розблокувати","Отблокирай","Одблокирај","Ξεμπλοκάρισμα" ,"Desbloquear"} }, - { "Add contact", { 0, "Contact toevoegen","Kontakt hinzufügen","Ajouter un contact","Añadir contacto","Aggiungi contatto","Добавить контакт","Додати контакт","Добави контакт","Додај контакт","Προσθήκη επαφής" ,"Adicionar contato"} }, - { "Add channel", { 0, "Kanaal toevoegen","Kanal hinzufügen","Ajouter un canal","Añadir canal","Aggiungi canale","Добавить канал","Додати канал","Добави канал","Додај канал","Προσθήκη καναλιού" ,"Adicionar canal"} }, - { "Apply radio params", { 0, "Radioparameters toepassen","Funkparameter anwenden","Appliquer les paramètres radio","Aplicar parámetros de radio","Applica parametri radio","Применить параметры радио","Застосувати параметри радіо","Приложи радио параметри","Примени радио параметре","Εφαρμογή παραμέτρων ράδιο" ,"Aplicar parâmetros de rádio"} }, - { "Export settings", { 0, "Instellingen exporteren","Einstellungen exportieren","Exporter les réglages","Exportar ajustes","Esporta impostazioni","Экспорт настроек","Експорт налаштувань","Експорт на настройки","Извези подешавања","Εξαγωγή ρυθμίσεων" ,"Exportar ajustes"} }, - { "Import settings", { 0, "Instellingen importeren","Einstellungen importieren","Importer les réglages","Importar ajustes","Importa impostazioni","Импорт настроек","Імпорт налаштувань","Импорт на настройки","Увези подешавања","Εισαγωγή ρυθμίσεων" ,"Importar ajustes"} }, - { "Memory detail", { 0, "Geheugendetail","Speicherdetails","Détail mémoire","Detalle de memoria","Dettaglio memoria","Сведения о памяти","Деталі пам'яті","Подробности за паметта","Детаљи меморије","Λεπτομέρειες μνήμης" ,"Detalhe de memória"} }, - { "Map options", { 0, "Kaartopties","Kartenoptionen","Options de carte","Opciones de mapa","Opzioni mappa","Параметры карты","Параметри карти","Опции на картата","Опције мапе","Επιλογές χάρτη" ,"Opções do mapa"} }, - { "Message info", { 0, "Berichtinfo","Nachrichteninfo","Infos du message","Info del mensaje","Info messaggio","Информация о сообщении","Інформація про повідомлення","Информация за съобщението","Подаци о поруци","Πληροφορίες μηνύματος" ,"Info da mensagem"} }, - { "Channel settings", { 0, "Kanaalinstellingen","Kanaleinstellungen","Réglages du canal","Ajustes del canal","Impostazioni canale","Настройки канала","Налаштування каналу","Настройки на канала","Подешавања канала","Ρυθμίσεις καναλιού" ,"Ajustes do canal"} }, - { "Blocked users", { 0, "Geblokkeerde gebruikers","Blockierte Benutzer","Utilisateurs bloqués","Usuarios bloqueados","Utenti bloccati","Заблокированные","Заблоковані","Блокирани потребители","Блокирани корисници","Αποκλεισμένοι χρήστες" ,"Usuários bloqueados"} }, - { "Save name", { 0, "Naam opslaan","Namen speichern","Enregistrer le nom","Guardar nombre","Salva nome","Сохранить имя","Зберегти ім'я","Запази името","Сачувај име","Αποθήκευση ονόματος" ,"Salvar nome"} }, - { "Save position", { 0, "Positie opslaan","Position speichern","Enregistrer la position","Guardar posición","Salva posizione","Сохранить позицию","Зберегти позицію","Запази позицията","Сачувај позицију","Αποθήκευση θέσης" ,"Salvar posição"} }, - { "Save policy", { 0, "Beleid opslaan","Richtlinie speichern","Enregistrer la règle","Guardar política","Salva criterio","Сохранить политику","Зберегти політику","Запази правилото","Сачувај правило","Αποθήκευση πολιτικής" ,"Salvar política"} }, - { "Copy secret", { 0, "Geheim kopiëren","Geheimnis kopieren","Copier le secret","Copiar secreto","Copia segreto","Копировать секрет","Копіювати секрет","Копирай тайната","Копирај тајну","Αντιγραφή μυστικού" ,"Copiar segredo"} }, - { "Select network", { 0, "Netwerk kiezen","Netzwerk wählen","Choisir le réseau","Seleccionar red","Seleziona rete","Выбрать сеть","Вибрати мережу","Избери мрежа","Изабери мрежу","Επιλογή δικτύου" ,"Selecionar rede"} }, + { "Close", { 0, "Sluiten","Schließen","Fermer","Cerrar","Chiudi","Закрыть","Закрити","Затвори","Затвори","Κλείσιμο" ,"Fechar","Închide" } }, + { "Cancel", { 0, "Annuleren","Abbrechen","Annuler","Cancelar","Annulla","Отмена","Скасувати","Отказ","Откажи","Άκυρο" ,"Cancelar","Anulează" } }, + { "Add", { 0, "Toevoegen","Hinzufügen","Ajouter","Añadir","Aggiungi","Добавить","Додати","Добави","Додај","Προσθήκη" ,"Adicionar","Adaugă" } }, + { "Apply", { 0, "Toepassen","Anwenden","Appliquer","Aplicar","Applica","Применить","Застосувати","Приложи","Примени","Εφαρμογή" ,"Aplicar","Aplică" } }, + { "Clear", { 0, "Wissen","Löschen","Effacer","Borrar","Cancella","Очистить","Очистити","Изчисти","Обриши","Εκκαθάριση" ,"Limpar","Goleşte" } }, + { "Reset", { 0, "Herstellen","Zurücksetzen","Réinitialiser","Restablecer","Ripristina","Сброс","Скинути","Нулиране","Ресетуј","Επαναφορά" ,"Redefinir","Resetează" } }, + { "Create", { 0, "Aanmaken","Erstellen","Créer","Crear","Crea","Создать","Створити","Създай","Направи","Δημιουργία" ,"Criar","Creează" } }, + { "Join", { 0, "Deelnemen","Beitreten","Rejoindre","Unirse","Unisciti","Присоединиться","Приєднатися","Присъедини се","Придружи се","Συμμετοχή" ,"Entrar","Intră" } }, + { "Find", { 0, "Zoeken","Suchen","Rechercher","Buscar","Trova","Найти","Знайти","Намери","Пронађи","Εύρεση" ,"Buscar","Caută" } }, + { "Unblock", { 0, "Deblokkeren","Entsperren","Débloquer","Desbloquear","Sblocca","Разблокировать","Розблокувати","Отблокирай","Одблокирај","Ξεμπλοκάρισμα" ,"Desbloquear","Deblochează" } }, + { "Add contact", { 0, "Contact toevoegen","Kontakt hinzufügen","Ajouter un contact","Añadir contacto","Aggiungi contatto","Добавить контакт","Додати контакт","Добави контакт","Додај контакт","Προσθήκη επαφής" ,"Adicionar contato","Adaugă contact" } }, + { "Add channel", { 0, "Kanaal toevoegen","Kanal hinzufügen","Ajouter un canal","Añadir canal","Aggiungi canale","Добавить канал","Додати канал","Добави канал","Додај канал","Προσθήκη καναλιού" ,"Adicionar canal","Adaugă canal" } }, + { "Apply radio params", { 0, "Radioparameters toepassen","Funkparameter anwenden","Appliquer les paramètres radio","Aplicar parámetros de radio","Applica parametri radio","Применить параметры радио","Застосувати параметри радіо","Приложи радио параметри","Примени радио параметре","Εφαρμογή παραμέτρων ράδιο" ,"Aplicar parâmetros de rádio","Aplică parametrii radio" } }, + { "Export settings", { 0, "Instellingen exporteren","Einstellungen exportieren","Exporter les réglages","Exportar ajustes","Esporta impostazioni","Экспорт настроек","Експорт налаштувань","Експорт на настройки","Извези подешавања","Εξαγωγή ρυθμίσεων" ,"Exportar ajustes","Exportă setările" } }, + { "Import settings", { 0, "Instellingen importeren","Einstellungen importieren","Importer les réglages","Importar ajustes","Importa impostazioni","Импорт настроек","Імпорт налаштувань","Импорт на настройки","Увези подешавања","Εισαγωγή ρυθμίσεων" ,"Importar ajustes","Importă setările" } }, + { "Memory detail", { 0, "Geheugendetail","Speicherdetails","Détail mémoire","Detalle de memoria","Dettaglio memoria","Сведения о памяти","Деталі пам'яті","Подробности за паметта","Детаљи меморије","Λεπτομέρειες μνήμης" ,"Detalhe de memória","Detalii memorie" } }, + { "Map options", { 0, "Kaartopties","Kartenoptionen","Options de carte","Opciones de mapa","Opzioni mappa","Параметры карты","Параметри карти","Опции на картата","Опције мапе","Επιλογές χάρτη" ,"Opções do mapa","Opţiuni hartă" } }, + { "Message info", { 0, "Berichtinfo","Nachrichteninfo","Infos du message","Info del mensaje","Info messaggio","Информация о сообщении","Інформація про повідомлення","Информация за съобщението","Подаци о поруци","Πληροφορίες μηνύματος" ,"Info da mensagem","Informaţii mesaj" } }, + { "Channel settings", { 0, "Kanaalinstellingen","Kanaleinstellungen","Réglages du canal","Ajustes del canal","Impostazioni canale","Настройки канала","Налаштування каналу","Настройки на канала","Подешавања канала","Ρυθμίσεις καναλιού" ,"Ajustes do canal","Setări canal" } }, + { "Blocked users", { 0, "Geblokkeerde gebruikers","Blockierte Benutzer","Utilisateurs bloqués","Usuarios bloqueados","Utenti bloccati","Заблокированные","Заблоковані","Блокирани потребители","Блокирани корисници","Αποκλεισμένοι χρήστες" ,"Usuários bloqueados","Utilizatori blocaţi" } }, + { "Save name", { 0, "Naam opslaan","Namen speichern","Enregistrer le nom","Guardar nombre","Salva nome","Сохранить имя","Зберегти ім'я","Запази името","Сачувај име","Αποθήκευση ονόματος" ,"Salvar nome","Salvează numele" } }, + { "Save position", { 0, "Positie opslaan","Position speichern","Enregistrer la position","Guardar posición","Salva posizione","Сохранить позицию","Зберегти позицію","Запази позицията","Сачувај позицију","Αποθήκευση θέσης" ,"Salvar posição","Salvează poziţia" } }, + { "Save policy", { 0, "Beleid opslaan","Richtlinie speichern","Enregistrer la règle","Guardar política","Salva criterio","Сохранить политику","Зберегти політику","Запази правилото","Сачувај правило","Αποθήκευση πολιτικής" ,"Salvar política","Salvează politica" } }, + { "Copy secret", { 0, "Geheim kopiëren","Geheimnis kopieren","Copier le secret","Copiar secreto","Copia segreto","Копировать секрет","Копіювати секрет","Копирай тайната","Копирај тајну","Αντιγραφή μυστικού" ,"Copiar segredo","Copiază secretul" } }, + { "Select network", { 0, "Netwerk kiezen","Netzwerk wählen","Choisir le réseau","Seleccionar red","Seleziona rete","Выбрать сеть","Вибрати мережу","Избери мрежа","Изабери мрежу","Επιλογή δικτύου" ,"Selecionar rede","Selectează reţeaua" } }, // ---- Common status / toast messages (whole-OS) ---- - { "Added to contacts", { 0, "Toegevoegd aan contacten","Zu Kontakten hinzugefügt","Ajouté aux contacts","Añadido a contactos","Aggiunto ai contatti","Добавлено в контакты","Додано до контактів","Добавено в контактите","Додато у контакте","Προστέθηκε στις επαφές" ,"Adicionado aos contatos"} }, - { "Contact added", { 0, "Contact toegevoegd","Kontakt hinzugefügt","Contact ajouté","Contacto añadido","Contatto aggiunto","Контакт добавлен","Контакт додано","Контактът е добавен","Контакт додат","Η επαφή προστέθηκε" ,"Contato adicionado"} }, - { "Contact deleted", { 0, "Contact verwijderd","Kontakt gelöscht","Contact supprimé","Contacto eliminado","Contatto eliminato","Контакт удалён","Контакт видалено","Контактът е изтрит","Контакт обрисан","Η επαφή διαγράφηκε" ,"Contato excluído"} }, - { "Channel created", { 0, "Kanaal aangemaakt","Kanal erstellt","Canal créé","Canal creado","Canale creato","Канал создан","Канал створено","Каналът е създаден","Канал направљен","Το κανάλι δημιουργήθηκε" ,"Canal criado"} }, - { "Channel joined", { 0, "Deelgenomen aan kanaal","Kanal beigetreten","Canal rejoint","Unido al canal","Canale unito","Вы присоединились к каналу","Приєднано до каналу","Присъединено към канала","Придружено каналу","Συμμετοχή στο κανάλι" ,"Entrou no canal"} }, - { "Clock synced", { 0, "Klok gesynchroniseerd","Uhr synchronisiert","Horloge synchronisée","Reloj sincronizado","Orologio sincronizzato","Часы синхронизированы","Годинник синхронізовано","Часовникът е синхронизиран","Сат синхронизован","Το ρολόι συγχρονίστηκε" ,"Relógio sincronizado"} }, - { "Deleted", { 0, "Verwijderd","Gelöscht","Supprimé","Eliminado","Eliminato","Удалено","Видалено","Изтрито","Обрисано","Διαγράφηκε" ,"Excluído"} }, - { "Sent", { 0, "Verzonden","Gesendet","Envoyé","Enviado","Inviato","Отправлено","Надіслано","Изпратено","Послато","Στάλθηκε" ,"Enviado"} }, - { "Send failed", { 0, "Verzenden mislukt","Senden fehlgeschlagen","Échec de l'envoi","Error al enviar","Invio non riuscito","Не удалось отправить","Не вдалося надіслати","Изпращането неуспешно","Слање није успело","Αποτυχία αποστολής" ,"Falha ao enviar"} }, - { "Login failed", { 0, "Inloggen mislukt","Anmeldung fehlgeschlagen","Échec de connexion","Error de inicio de sesión","Accesso non riuscito","Ошибка входа","Помилка входу","Входът неуспешен","Пријава није успела","Αποτυχία σύνδεσης" ,"Falha no login"} }, - { "Name saved", { 0, "Naam opgeslagen","Name gespeichert","Nom enregistré","Nombre guardado","Nome salvato","Имя сохранено","Ім'я збережено","Името е запазено","Име сачувано","Το όνομα αποθηκεύτηκε" ,"Nome salvo"} }, - { "Position saved", { 0, "Positie opgeslagen","Position gespeichert","Position enregistrée","Posición guardada","Posizione salvata","Позиция сохранена","Позицію збережено","Позицията е запазена","Позиција сачувана","Η θέση αποθηκεύτηκε" ,"Posição salva"} }, - { "Setup complete", { 0, "Installatie voltooid","Einrichtung abgeschlossen","Configuration terminée","Configuración completa","Configurazione completata","Настройка завершена","Налаштування завершено","Настройката е готова","Подешавање завршено","Η ρύθμιση ολοκληρώθηκε" ,"Configuração concluída"} }, - { "Slot empty", { 0, "Slot leeg","Slot leer","Emplacement vide","Ranura vacía","Slot vuoto","Слот пуст","Слот порожній","Слотът е празен","Слот празан","Κενή θέση" ,"Slot vazio"} }, - { "Screen locked", { 0, "Scherm vergrendeld","Bildschirm gesperrt","Écran verrouillé","Pantalla bloqueada","Schermo bloccato","Экран заблокирован","Екран заблоковано","Екранът е заключен","Екран закључан","Η οθόνη κλειδώθηκε" ,"Tela bloqueada"} }, - { "Out of memory", { 0, "Onvoldoende geheugen","Kein Speicher mehr","Mémoire insuffisante","Sin memoria","Memoria esaurita","Недостаточно памяти","Недостатньо пам'яті","Недостатъчна памет","Нема меморије","Ανεπαρκής μνήμη" ,"Sem memória"} }, - { "Already exists", { 0, "Bestaat al","Existiert bereits","Existe déjà","Ya existe","Esiste già","Уже существует","Вже існує","Вече съществува","Већ постоји","Υπάρχει ήδη" ,"Já existe"} }, - { "Marked read", { 0, "Gemarkeerd als gelezen","Als gelesen markiert","Marqué comme lu","Marcado como leído","Segnato come letto","Отмечено прочитанным","Позначено прочитаним","Отбелязано като прочетено","Означено прочитаним","Σημειώθηκε ως αναγνωσμένο" ,"Marcado como lido"} }, - { "All marked read", { 0, "Alles gelezen","Alle als gelesen","Tout marqué comme lu","Todo marcado como leído","Tutto segnato come letto","Всё прочитано","Усе прочитано","Всичко прочетено","Све означено","Όλα ως αναγνωσμένα" ,"Tudo marcado como lido"} }, - { "Enter a name", { 0, "Voer een naam in","Namen eingeben","Saisissez un nom","Introduce un nombre","Inserisci un nome","Введите имя","Введіть ім'я","Въведете име","Унесите име","Εισαγάγετε όνομα" ,"Digite um nome"} }, - { "Invalid lat/lon", { 0, "Ongeldige lat/lon","Ungültige Lat/Lon","Lat/lon invalide","Lat/lon no válida","Lat/lon non valida","Неверные коорд.","Невірні коорд.","Невалидни коорд.","Неисправне коорд.","Μη έγκυρες συντ." ,"Lat/lon inválida"} }, - { "SD card inserted", { 0, "SD-kaart geplaatst","SD-Karte eingelegt","Carte SD insérée","Tarjeta SD insertada","Scheda SD inserita","SD-карта вставлена","SD-картку вставлено","SD картата е поставена","SD картица убачена","Η κάρτα SD εισήχθη" ,"Cartão SD inserido"} }, - { "SD card removed", { 0, "SD-kaart verwijderd","SD-Karte entfernt","Carte SD retirée","Tarjeta SD retirada","Scheda SD rimossa","SD-карта извлечена","SD-картку вилучено","SD картата е извадена","SD картица уклоњена","Η κάρτα SD αφαιρέθηκε" ,"Cartão SD removido"} }, - { "SD card remounted", { 0, "SD-kaart opnieuw gekoppeld","SD-Karte neu eingebunden","Carte SD remontée","Tarjeta SD remontada","Scheda SD rimontata","SD-карта перемонтирована","SD-картку перемонтовано","SD картата е монтирана отново","SD картица поново монтирана","Η κάρτα SD επανασυνδέθηκε" ,"Cartão SD remontado"} }, - { "SD card lost - reinsert to recover", { 0, "SD-kaart verloren - plaats opnieuw","SD-Karte verloren - neu einstecken","Carte SD perdue - réinsérez-la","Tarjeta SD perdida - reinsértala","Scheda SD persa - reinserirla","SD-карта потеряна - переставьте её","SD-картку втрачено - вставте знову","SD картата е изгубена - поставете я отново","SD картица изгубљена - убаците поново","Η κάρτα SD χάθηκε - επανατοποθετήστε" ,"Cartão SD perdido - reinsira-o"} }, - { "Chat history is NOT saving", { 0, "Chatgeschiedenis wordt NIET opgeslagen","Chatverlauf wird NICHT gespeichert","L'historique du chat n'est PAS enregistré","El historial de chat NO se guarda","La cronologia chat NON viene salvata","История чата НЕ сохраняется","Історія чату НЕ зберігається","Историята на чата НЕ се записва","Историја ћаскања се НЕ чува","Το ιστορικό συνομιλίας ΔΕΝ αποθηκεύεται" ,"Histórico do chat NÃO está sendo salvo"} }, - { "Chat history save FAILED", { 0, "Opslaan chatgeschiedenis MISLUKT","Speichern des Chatverlaufs FEHLGESCHLAGEN","Échec de l'enregistrement de l'historique","FALLÓ el guardado del historial de chat","Salvataggio cronologia chat FALLITO","НЕ УДАЛОСЬ сохранить историю чата","НЕ ВДАЛОСЯ зберегти історію чату","Записът на историята на чата СЕ ПРОВАЛИ","Чување историје ћаскања НИЈЕ УСПЕЛО","Η αποθήκευση του ιστορικού ΑΠΕΤΥΧΕ" ,"FALHA ao salvar o histórico do chat"} }, + { "Added to contacts", { 0, "Toegevoegd aan contacten","Zu Kontakten hinzugefügt","Ajouté aux contacts","Añadido a contactos","Aggiunto ai contatti","Добавлено в контакты","Додано до контактів","Добавено в контактите","Додато у контакте","Προστέθηκε στις επαφές" ,"Adicionado aos contatos","Adăugat la contacte" } }, + { "Contact added", { 0, "Contact toegevoegd","Kontakt hinzugefügt","Contact ajouté","Contacto añadido","Contatto aggiunto","Контакт добавлен","Контакт додано","Контактът е добавен","Контакт додат","Η επαφή προστέθηκε" ,"Contato adicionado","Contact adăugat" } }, + { "Contact deleted", { 0, "Contact verwijderd","Kontakt gelöscht","Contact supprimé","Contacto eliminado","Contatto eliminato","Контакт удалён","Контакт видалено","Контактът е изтрит","Контакт обрисан","Η επαφή διαγράφηκε" ,"Contato excluído","Contact şters" } }, + { "Channel created", { 0, "Kanaal aangemaakt","Kanal erstellt","Canal créé","Canal creado","Canale creato","Канал создан","Канал створено","Каналът е създаден","Канал направљен","Το κανάλι δημιουργήθηκε" ,"Canal criado","Canal creat" } }, + { "Channel joined", { 0, "Deelgenomen aan kanaal","Kanal beigetreten","Canal rejoint","Unido al canal","Canale unito","Вы присоединились к каналу","Приєднано до каналу","Присъединено към канала","Придружено каналу","Συμμετοχή στο κανάλι" ,"Entrou no canal","Intrat în canal" } }, + { "Clock synced", { 0, "Klok gesynchroniseerd","Uhr synchronisiert","Horloge synchronisée","Reloj sincronizado","Orologio sincronizzato","Часы синхронизированы","Годинник синхронізовано","Часовникът е синхронизиран","Сат синхронизован","Το ρολόι συγχρονίστηκε" ,"Relógio sincronizado","Ceas sincronizat" } }, + { "Deleted", { 0, "Verwijderd","Gelöscht","Supprimé","Eliminado","Eliminato","Удалено","Видалено","Изтрито","Обрисано","Διαγράφηκε" ,"Excluído","Şters" } }, + { "Sent", { 0, "Verzonden","Gesendet","Envoyé","Enviado","Inviato","Отправлено","Надіслано","Изпратено","Послато","Στάλθηκε" ,"Enviado","Trimis" } }, + { "Send failed", { 0, "Verzenden mislukt","Senden fehlgeschlagen","Échec de l'envoi","Error al enviar","Invio non riuscito","Не удалось отправить","Не вдалося надіслати","Изпращането неуспешно","Слање није успело","Αποτυχία αποστολής" ,"Falha ao enviar","Trimitere eşuată" } }, + { "Login failed", { 0, "Inloggen mislukt","Anmeldung fehlgeschlagen","Échec de connexion","Error de inicio de sesión","Accesso non riuscito","Ошибка входа","Помилка входу","Входът неуспешен","Пријава није успела","Αποτυχία σύνδεσης" ,"Falha no login","Autentificare eşuată" } }, + { "Name saved", { 0, "Naam opgeslagen","Name gespeichert","Nom enregistré","Nombre guardado","Nome salvato","Имя сохранено","Ім'я збережено","Името е запазено","Име сачувано","Το όνομα αποθηκεύτηκε" ,"Nome salvo","Nume salvat" } }, + { "Position saved", { 0, "Positie opgeslagen","Position gespeichert","Position enregistrée","Posición guardada","Posizione salvata","Позиция сохранена","Позицію збережено","Позицията е запазена","Позиција сачувана","Η θέση αποθηκεύτηκε" ,"Posição salva","Poziţie salvată" } }, + { "Setup complete", { 0, "Installatie voltooid","Einrichtung abgeschlossen","Configuration terminée","Configuración completa","Configurazione completata","Настройка завершена","Налаштування завершено","Настройката е готова","Подешавање завршено","Η ρύθμιση ολοκληρώθηκε" ,"Configuração concluída","Configurare finalizată" } }, + { "Slot empty", { 0, "Slot leeg","Slot leer","Emplacement vide","Ranura vacía","Slot vuoto","Слот пуст","Слот порожній","Слотът е празен","Слот празан","Κενή θέση" ,"Slot vazio","Slot gol" } }, + { "Screen locked", { 0, "Scherm vergrendeld","Bildschirm gesperrt","Écran verrouillé","Pantalla bloqueada","Schermo bloccato","Экран заблокирован","Екран заблоковано","Екранът е заключен","Екран закључан","Η οθόνη κλειδώθηκε" ,"Tela bloqueada","Ecran blocat" } }, + { "Out of memory", { 0, "Onvoldoende geheugen","Kein Speicher mehr","Mémoire insuffisante","Sin memoria","Memoria esaurita","Недостаточно памяти","Недостатньо пам'яті","Недостатъчна памет","Нема меморије","Ανεπαρκής μνήμη" ,"Sem memória","Memorie insuficientă" } }, + { "Already exists", { 0, "Bestaat al","Existiert bereits","Existe déjà","Ya existe","Esiste già","Уже существует","Вже існує","Вече съществува","Већ постоји","Υπάρχει ήδη" ,"Já existe","Există deja" } }, + { "Marked read", { 0, "Gemarkeerd als gelezen","Als gelesen markiert","Marqué comme lu","Marcado como leído","Segnato come letto","Отмечено прочитанным","Позначено прочитаним","Отбелязано като прочетено","Означено прочитаним","Σημειώθηκε ως αναγνωσμένο" ,"Marcado como lido","Marcat ca citit" } }, + { "All marked read", { 0, "Alles gelezen","Alle als gelesen","Tout marqué comme lu","Todo marcado como leído","Tutto segnato come letto","Всё прочитано","Усе прочитано","Всичко прочетено","Све означено","Όλα ως αναγνωσμένα" ,"Tudo marcado como lido","Toate marcate ca citite" } }, + { "Enter a name", { 0, "Voer een naam in","Namen eingeben","Saisissez un nom","Introduce un nombre","Inserisci un nome","Введите имя","Введіть ім'я","Въведете име","Унесите име","Εισαγάγετε όνομα" ,"Digite um nome","Introdu un nume" } }, + { "Invalid lat/lon", { 0, "Ongeldige lat/lon","Ungültige Lat/Lon","Lat/lon invalide","Lat/lon no válida","Lat/lon non valida","Неверные коорд.","Невірні коорд.","Невалидни коорд.","Неисправне коорд.","Μη έγκυρες συντ." ,"Lat/lon inválida","Lat/lon nevalide" } }, + { "SD card inserted", { 0, "SD-kaart geplaatst","SD-Karte eingelegt","Carte SD insérée","Tarjeta SD insertada","Scheda SD inserita","SD-карта вставлена","SD-картку вставлено","SD картата е поставена","SD картица убачена","Η κάρτα SD εισήχθη" ,"Cartão SD inserido","Card SD introdus" } }, + { "SD card removed", { 0, "SD-kaart verwijderd","SD-Karte entfernt","Carte SD retirée","Tarjeta SD retirada","Scheda SD rimossa","SD-карта извлечена","SD-картку вилучено","SD картата е извадена","SD картица уклоњена","Η κάρτα SD αφαιρέθηκε" ,"Cartão SD removido","Card SD scos" } }, + { "SD card remounted", { 0, "SD-kaart opnieuw gekoppeld","SD-Karte neu eingebunden","Carte SD remontée","Tarjeta SD remontada","Scheda SD rimontata","SD-карта перемонтирована","SD-картку перемонтовано","SD картата е монтирана отново","SD картица поново монтирана","Η κάρτα SD επανασυνδέθηκε" ,"Cartão SD remontado","Card SD remontat" } }, + { "SD card lost - reinsert to recover", { 0, "SD-kaart verloren - plaats opnieuw","SD-Karte verloren - neu einstecken","Carte SD perdue - réinsérez-la","Tarjeta SD perdida - reinsértala","Scheda SD persa - reinserirla","SD-карта потеряна - переставьте её","SD-картку втрачено - вставте знову","SD картата е изгубена - поставете я отново","SD картица изгубљена - убаците поново","Η κάρτα SD χάθηκε - επανατοποθετήστε" ,"Cartão SD perdido - reinsira-o","Card SD pierdut - reintrodu-l pentru recuperare" } }, + { "Chat history is NOT saving", { 0, "Chatgeschiedenis wordt NIET opgeslagen","Chatverlauf wird NICHT gespeichert","L'historique du chat n'est PAS enregistré","El historial de chat NO se guarda","La cronologia chat NON viene salvata","История чата НЕ сохраняется","Історія чату НЕ зберігається","Историята на чата НЕ се записва","Историја ћаскања се НЕ чува","Το ιστορικό συνομιλίας ΔΕΝ αποθηκεύεται" ,"Histórico do chat NÃO está sendo salvo","Istoricul chatului NU se salvează" } }, + { "Chat history save FAILED", { 0, "Opslaan chatgeschiedenis MISLUKT","Speichern des Chatverlaufs FEHLGESCHLAGEN","Échec de l'enregistrement de l'historique","FALLÓ el guardado del historial de chat","Salvataggio cronologia chat FALLITO","НЕ УДАЛОСЬ сохранить историю чата","НЕ ВДАЛОСЯ зберегти історію чату","Записът на историята на чата СЕ ПРОВАЛИ","Чување историје ћаскања НИЈЕ УСПЕЛО","Η αποθήκευση του ιστορικού ΑΠΕΤΥΧΕ" ,"FALHA ao salvar o histórico do chat","Salvarea istoricului chatului A EŞUAT" } }, // ---- Common field labels / status words ---- - { "Enable Bluetooth", { 0, "Bluetooth inschakelen","Bluetooth aktivieren","Activer le Bluetooth","Activar Bluetooth","Attiva Bluetooth","Включить Bluetooth","Увімкнути Bluetooth","Включи Bluetooth","Укључи Bluetooth","Ενεργοποίηση Bluetooth" ,"Ativar Bluetooth"} }, - { "Network name (SSID)", { 0, "Netwerknaam (SSID)","Netzwerkname (SSID)","Nom du réseau (SSID)","Nombre de red (SSID)","Nome rete (SSID)","Имя сети (SSID)","Назва мережі (SSID)","Име на мрежата (SSID)","Назив мреже (SSID)","Όνομα δικτύου (SSID)" ,"Nome da rede (SSID)"} }, - { "Password (empty = open)", { 0, "Wachtwoord (leeg = open)","Passwort (leer = offen)","Mot de passe (vide = ouvert)","Contraseña (vacío = abierta)","Password (vuoto = aperta)","Пароль (пусто = открытая)","Пароль (порожньо = відкрита)","Парола (празно = отворена)","Лозинка (празно = отворена)","Κωδικός (κενό = ανοιχτό)" ,"Senha (vazio = aberta)"} }, - { "Saved profiles", { 0, "Opgeslagen profielen","Gespeicherte Profile","Profils enregistrés","Perfiles guardados","Profili salvati","Сохранённые профили","Збережені профілі","Запазени профили","Сачувани профили","Αποθηκευμένα προφίλ" ,"Perfis salvos"} }, - { "Channel name", { 0, "Kanaalnaam","Kanalname","Nom du canal","Nombre del canal","Nome canale","Имя канала","Назва каналу","Име на канала","Назив канала","Όνομα καναλιού" ,"Nome do canal"} }, - { "Max hops (0..64)", { 0, "Max hops (0..64)","Max. Hops (0..64)","Sauts max (0..64)","Saltos máx (0..64)","Hop max (0..64)","Макс. хопов (0..64)","Макс. хопів (0..64)","Макс. скокове (0..64)","Макс. скокова (0..64)","Μέγ. άλματα (0..64)" ,"Máx. saltos (0..64)"} }, - { "Offline", { 0, "Offline","Offline","Hors ligne","Sin conexión","Offline","Не в сети","Офлайн","Офлайн","Ван мреже","Εκτός σύνδεσης" ,"Off-line"} }, - { "Name", { 0, "Naam","Name","Nom","Nombre","Nome","Имя","Ім'я","Име","Име","Όνομα" ,"Nome"} }, - { "Status", { 0, "Status","Status","État","Estado","Stato","Статус","Статус","Състояние","Статус","Κατάσταση" ,"Status"} }, - { "Storage", { 0, "Opslag","Speicher","Stockage","Almacenamiento","Archiviazione","Хранилище","Сховище","Хранилище","Складиште","Αποθηκευτικός χώρος" ,"Armazenamento"} }, - { "Diagnostics", { 0, "Diagnostiek","Diagnose","Diagnostics","Diagnóstico","Diagnostica","Диагностика","Діагностика","Диагностика","Дијагностика","Διαγνωστικά" ,"Diagnóstico"} }, - { "Active", { 0, "Actief","Aktiv","Actif","Activo","Attivo","Активно","Активно","Активно","Активно","Ενεργό" ,"Ativo"} }, - { "Inactive", { 0, "Inactief","Inaktiv","Inactif","Inactivo","Inattivo","Неактивно","Неактивно","Неактивно","Неактивно","Ανενεργό" ,"Inativo"} }, - { "New", { 0, "Nieuw","Neu","Nouveau","Nuevo","Nuovo","Новое","Нове","Ново","Ново","Νέο" ,"Novo"} }, - { "About the map", { 0, "Over de kaart","Über die Karte","À propos de la carte","Acerca del mapa","Informazioni sulla mappa","О карте","Про карту","Относно картата","О мапи","Σχετικά με τον χάρτη" ,"Sobre o mapa"} }, - { "Show link lines", { 0, "Verbindingslijnen tonen","Verbindungslinien zeigen","Afficher les liens","Mostrar líneas de enlace","Mostra linee di collegamento","Показывать линии связи","Показувати лінії зв'язку","Показвай линиите на връзката","Прикажи линије веза","Εμφάνιση γραμμών σύνδεσης" ,"Mostrar linhas de enlace"} }, - { "Tiles from SD card", { 0, "Tegels van SD-kaart","Kacheln von SD-Karte","Tuiles depuis la carte SD","Teselas desde tarjeta SD","Riquadri da scheda SD","Тайлы с SD-карты","Плитки з SD-картки","Плочки от SD картата","Плочице са SD картице","Πλακίδια από κάρτα SD" ,"Tiles do cartão SD"} }, - { "Quick reply", { 0, "Snel antwoord","Schnellantwort","Réponse rapide","Respuesta rápida","Risposta rapida","Быстрый ответ","Швидка відповідь","Бърз отговор","Брз одговор","Γρήγορη απάντηση" ,"Resposta rápida"} }, + { "Enable Bluetooth", { 0, "Bluetooth inschakelen","Bluetooth aktivieren","Activer le Bluetooth","Activar Bluetooth","Attiva Bluetooth","Включить Bluetooth","Увімкнути Bluetooth","Включи Bluetooth","Укључи Bluetooth","Ενεργοποίηση Bluetooth" ,"Ativar Bluetooth","Activează Bluetooth" } }, + { "Network name (SSID)", { 0, "Netwerknaam (SSID)","Netzwerkname (SSID)","Nom du réseau (SSID)","Nombre de red (SSID)","Nome rete (SSID)","Имя сети (SSID)","Назва мережі (SSID)","Име на мрежата (SSID)","Назив мреже (SSID)","Όνομα δικτύου (SSID)" ,"Nome da rede (SSID)","Nume reţea (SSID)" } }, + { "Password (empty = open)", { 0, "Wachtwoord (leeg = open)","Passwort (leer = offen)","Mot de passe (vide = ouvert)","Contraseña (vacío = abierta)","Password (vuoto = aperta)","Пароль (пусто = открытая)","Пароль (порожньо = відкрита)","Парола (празно = отворена)","Лозинка (празно = отворена)","Κωδικός (κενό = ανοιχτό)" ,"Senha (vazio = aberta)","Parolă (gol = deschisă)" } }, + { "Saved profiles", { 0, "Opgeslagen profielen","Gespeicherte Profile","Profils enregistrés","Perfiles guardados","Profili salvati","Сохранённые профили","Збережені профілі","Запазени профили","Сачувани профили","Αποθηκευμένα προφίλ" ,"Perfis salvos","Profiluri salvate" } }, + { "Channel name", { 0, "Kanaalnaam","Kanalname","Nom du canal","Nombre del canal","Nome canale","Имя канала","Назва каналу","Име на канала","Назив канала","Όνομα καναλιού" ,"Nome do canal","Nume canal" } }, + { "Max hops (0..64)", { 0, "Max hops (0..64)","Max. Hops (0..64)","Sauts max (0..64)","Saltos máx (0..64)","Hop max (0..64)","Макс. хопов (0..64)","Макс. хопів (0..64)","Макс. скокове (0..64)","Макс. скокова (0..64)","Μέγ. άλματα (0..64)" ,"Máx. saltos (0..64)","Salturi max (0..64)" } }, + { "Offline", { 0, "Offline","Offline","Hors ligne","Sin conexión","Offline","Не в сети","Офлайн","Офлайн","Ван мреже","Εκτός σύνδεσης" ,"Off-line","Offline" } }, + { "Name", { 0, "Naam","Name","Nom","Nombre","Nome","Имя","Ім'я","Име","Име","Όνομα" ,"Nome","Nume" } }, + { "Status", { 0, "Status","Status","État","Estado","Stato","Статус","Статус","Състояние","Статус","Κατάσταση" ,"Status","Stare" } }, + { "Storage", { 0, "Opslag","Speicher","Stockage","Almacenamiento","Archiviazione","Хранилище","Сховище","Хранилище","Складиште","Αποθηκευτικός χώρος" ,"Armazenamento","Stocare" } }, + { "Diagnostics", { 0, "Diagnostiek","Diagnose","Diagnostics","Diagnóstico","Diagnostica","Диагностика","Діагностика","Диагностика","Дијагностика","Διαγνωστικά" ,"Diagnóstico","Diagnosticare" } }, + { "Active", { 0, "Actief","Aktiv","Actif","Activo","Attivo","Активно","Активно","Активно","Активно","Ενεργό" ,"Ativo","Activ" } }, + { "Inactive", { 0, "Inactief","Inaktiv","Inactif","Inactivo","Inattivo","Неактивно","Неактивно","Неактивно","Неактивно","Ανενεργό" ,"Inativo","Inactiv" } }, + { "New", { 0, "Nieuw","Neu","Nouveau","Nuevo","Nuovo","Новое","Нове","Ново","Ново","Νέο" ,"Novo","Nou" } }, + { "About the map", { 0, "Over de kaart","Über die Karte","À propos de la carte","Acerca del mapa","Informazioni sulla mappa","О карте","Про карту","Относно картата","О мапи","Σχετικά με τον χάρτη" ,"Sobre o mapa","Despre hartă" } }, + { "Show link lines", { 0, "Verbindingslijnen tonen","Verbindungslinien zeigen","Afficher les liens","Mostrar líneas de enlace","Mostra linee di collegamento","Показывать линии связи","Показувати лінії зв'язку","Показвай линиите на връзката","Прикажи линије веза","Εμφάνιση γραμμών σύνδεσης" ,"Mostrar linhas de enlace","Arată liniile de legătură" } }, + { "Tiles from SD card", { 0, "Tegels van SD-kaart","Kacheln von SD-Karte","Tuiles depuis la carte SD","Teselas desde tarjeta SD","Riquadri da scheda SD","Тайлы с SD-карты","Плитки з SD-картки","Плочки от SD картата","Плочице са SD картице","Πλακίδια από κάρτα SD" ,"Tiles do cartão SD","Dale de pe cardul SD" } }, + { "Quick reply", { 0, "Snel antwoord","Schnellantwort","Réponse rapide","Respuesta rápida","Risposta rapida","Быстрый ответ","Швидка відповідь","Бърз отговор","Брз одговор","Γρήγορη απάντηση" ,"Resposta rápida","Răspuns rapid" } }, // ---- Sweep pass 3: chats / contacts / wifi / map / lock — alerts + labels ---- - { "Contacts", { 0, "Contacten","Kontakte","Contacts","Contactos","Contatti","Контакты","Контакти","Контакти","Контакти","Επαφές" ,"Contatos"} }, - { "Commands", { 0, "Opdrachten","Befehle","Commandes","Comandos","Comandi","Команды","Команди","Команди","Команде","Εντολές" ,"Comandos"} }, - { "Editing", { 0, "Bewerken","Bearbeiten","Modification","Editando","Modifica","Редактирование","Редагування","Редактиране","Уређивање","Επεξεργασία" ,"Editando"} }, - { "Full", { 0, "Vol","Voll","Plein","Lleno","Pieno","Заполнено","Заповнено","Пълно","Пуно","Πλήρες" ,"Cheio"} }, - { "Delivered", { 0, "Afgeleverd","Zugestellt","Remis","Entregado","Consegnato","Доставлено","Доставлено","Доставено","Испоручено","Παραδόθηκε" ,"Entregue"} }, - { "New contact", { 0, "Nieuw contact","Neuer Kontakt","Nouveau contact","Nuevo contacto","Nuovo contatto","Новый контакт","Новий контакт","Нов контакт","Нови контакт","Νέα επαφή" ,"Novo contato"} }, - { "New DM", { 0, "Nieuw DM","Neue DN","Nouveau MP","Nuevo MD","Nuovo MD","Новое ЛС","Нове ПП","Ново ЛС","Нова ДП","Νέο ΠΜ" ,"Nova DM"} }, - { "New channel msg", { 0, "Nieuw kanaalbericht","Neue Kanalnachricht","Nouveau msg de canal","Nuevo msj de canal","Nuovo msg canale","Новое сообщ. канала","Нове повід. каналу","Ново съобщ. в канал","Нова порука канала","Νέο μήνυμα καναλιού" ,"Nova msg de canal"} }, - { "New room msg", { 0, "Nieuw roombericht","Neue Raumnachricht","Nouveau msg de salon","Nuevo msj de sala","Nuovo msg stanza","Новое сообщ. комнаты","Нове повід. кімнати","Ново съобщ. в стая","Нова порука собе","Νέο μήνυμα δωματίου" ,"Nova msg de sala"} }, - { "Nearby on map", { 0, "Dichtbij op kaart","In der Nähe auf Karte","À proximité sur la carte","Cerca en el mapa","Vicino sulla mappa","Рядом на карте","Поблизу на карті","Наблизо на картата","У близини на мапи","Κοντά στον χάρτη" ,"Por perto no mapa"} }, - { "Advert policy saved", { 0, "Advertentiebeleid opgeslagen","Advert-Richtlinie gespeichert","Règle d'annonce enregistrée","Política de anuncio guardada","Criterio advert salvato","Политика advert сохранена","Політику advert збережено","Правилото за advert е запазено","Правило за advert сачувано","Η πολιτική advert αποθηκεύτηκε" ,"Política de advert salva"} }, - { "Auto-add saved", { 0, "Auto-toevoegen opgeslagen","Auto-Hinzufügen gespeichert","Ajout auto enregistré","Auto-añadir guardado","Auto-aggiunta salvata","Автодобавление сохранено","Автододавання збережено","Авто-добавянето е запазено","Аутододавање сачувано","Η αυτόματη προσθήκη αποθηκεύτηκε" ,"Auto-adição salva"} }, - { "Experimental saved", { 0, "Experimenteel opgeslagen","Experimentell gespeichert","Expérimental enregistré","Experimental guardado","Sperimentale salvato","Эксперим. сохранено","Експерим. збережено","Експерим. запазено","Експериментално сачувано","Πειραματικά αποθηκεύτηκαν" ,"Experimental salvo"} }, - { "Channel scope saved", { 0, "Kanaalbereik opgeslagen","Kanalbereich gespeichert","Portée du canal enregistrée","Alcance del canal guardado","Ambito canale salvato","Область канала сохранена","Область каналу збережено","Обхватът на канала е запазен","Опсег канала сачуван","Το εύρος καναλιού αποθηκεύτηκε" ,"Escopo do canal salvo"} }, - { "Couldn't delete", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Suppression impossible","No se pudo eliminar","Impossibile eliminare","Не удалось удалить","Не вдалося видалити","Изтриването неуспешно","Брисање није успело","Αδυναμία διαγραφής" ,"Não foi possível excluir"} }, - { "Create failed", { 0, "Aanmaken mislukt","Erstellen fehlgeschlagen","Échec de création","Error al crear","Creazione non riuscita","Не удалось создать","Не вдалося створити","Създаването неуспешно","Креирање није успело","Αποτυχία δημιουργίας" ,"Falha ao criar"} }, - { "Delete failed", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Échec de suppression","Error al eliminar","Eliminazione non riuscita","Не удалось удалить","Не вдалося видалити","Изтриването неуспешно","Брисање није успело","Αποτυχία διαγραφής" ,"Falha ao excluir"} }, - { "Save failed", { 0, "Opslaan mislukt","Speichern fehlgeschlagen","Échec d'enregistrement","Error al guardar","Salvataggio non riuscito","Не удалось сохранить","Не вдалося зберегти","Запазването неуспешно","Чување није успело","Αποτυχία αποθήκευσης" ,"Falha ao salvar"} }, - { "Channel not found", { 0, "Kanaal niet gevonden","Kanal nicht gefunden","Canal introuvable","Canal no encontrado","Canale non trovato","Канал не найден","Канал не знайдено","Каналът не е намерен","Канал није пронађен","Το κανάλι δεν βρέθηκε" ,"Canal não encontrado"} }, - { "Channel table is full", { 0, "Kanaaltabel is vol","Kanaltabelle ist voll","Table des canaux pleine","Tabla de canales llena","Tabella canali piena","Таблица каналов заполнена","Таблиця каналів заповнена","Таблицата с канали е пълна","Табела канала је пуна","Ο πίνακας καναλιών είναι πλήρης" ,"Tabela de canais cheia"} }, - { "Contact gone", { 0, "Contact weg","Kontakt weg","Contact perdu","Contacto perdido","Contatto perso","Контакт пропал","Контакт зник","Контактът изчезна","Контакт нестао","Η επαφή χάθηκε" ,"Contato sumiu"} }, - { "Contact missing", { 0, "Contact ontbreekt","Kontakt fehlt","Contact manquant","Falta el contacto","Contatto mancante","Контакт отсутствует","Контакт відсутній","Контактът липсва","Контакт недостаје","Η επαφή λείπει" ,"Contato ausente"} }, - { "Contacts full", { 0, "Contacten vol","Kontakte voll","Contacts pleins","Contactos llenos","Contatti pieni","Контакты заполнены","Контакти заповнено","Контактите са пълни","Контакти пуни","Οι επαφές είναι πλήρεις" ,"Contatos cheios"} }, - { "Cannot open file", { 0, "Kan bestand niet openen","Datei kann nicht geöffnet werden","Impossible d'ouvrir le fichier","No se puede abrir el archivo","Impossibile aprire il file","Не удаётся открыть файл","Не вдалося відкрити файл","Файлът не може да се отвори","Не могу да отворим датотеку","Αδυναμία ανοίγματος αρχείου" ,"Não foi possível abrir o arquivo"} }, - { "Connecting...", { 0, "Verbinden...","Verbinde...","Connexion...","Conectando...","Connessione...","Подключение...","Підключення...","Свързване...","Повезивање...","Σύνδεση..." ,"Conectando..."} }, - { "Loading...", { 0, "Laden...","Lädt...","Chargement...","Cargando...","Caricamento...","Загрузка...","Завантаження...","Зареждане...","Учитавање...","Φόρτωση..." ,"Carregando..."} }, - { "Loading map…", { 0, "Kaart laden…","Karte wird geladen…","Chargement de la carte…","Cargando mapa…","Caricamento mappa…","Загрузка карты…","Завантаження карти…","Зареждане на картата…","Учитавање мапе…","Φόρτωση χάρτη…" ,"Carregando mapa…"} }, - { "Scanning…", { 0, "Scannen…","Suche…","Analyse…","Buscando…","Scansione…","Сканирование…","Сканування…","Сканиране…","Скенирање…","Σάρωση…" ,"Buscando…"} }, - { "Logging in…", { 0, "Inloggen…","Anmeldung…","Connexion…","Iniciando sesión…","Accesso…","Вход…","Вхід…","Вписване…","Пријава…","Σύνδεση…" ,"Entrando…"} }, - { "Rebooting...", { 0, "Opnieuw opstarten...","Neustart...","Redémarrage...","Reiniciando...","Riavvio...","Перезагрузка...","Перезавантаження...","Рестартиране...","Рестартовање...","Επανεκκίνηση..." ,"Reiniciando..."} }, - { "Rebooting…", { 0, "Opnieuw opstarten…","Neustart…","Redémarrage…","Reiniciando…","Riavvio…","Перезагрузка…","Перезавантаження…","Рестартиране…","Рестартовање…","Επανεκκίνηση…" ,"Reiniciando…"} }, - { "Locking…", { 0, "Vergrendelen…","Sperren…","Verrouillage…","Bloqueando…","Blocco…","Блокировка…","Блокування…","Заключване…","Закључавање…","Κλείδωμα…" ,"Bloqueando…"} }, - { "Unlocking…", { 0, "Ontgrendelen…","Entsperren…","Déverrouillage…","Desbloqueando…","Sblocco…","Разблокировка…","Розблокування…","Отключване…","Откључавање…","Ξεκλείδωμα…" ,"Desbloqueando…"} }, - { "Hashtag name", { 0, "Hashtagnaam","Hashtag-Name","Nom du hashtag","Nombre de hashtag","Nome hashtag","Имя хэштега","Назва хештегу","Име на хаштаг","Назив хаштага","Όνομα hashtag" ,"Nome da hashtag"} }, - { "Public key (64 hex chars)", { 0, "Publieke sleutel (64 hex)","Öffentl. Schlüssel (64 Hex)","Clé publique (64 hex)","Clave pública (64 hex)","Chiave pubblica (64 hex)","Открытый ключ (64 hex)","Відкритий ключ (64 hex)","Публичен ключ (64 hex)","Јавни кључ (64 hex)","Δημόσιο κλειδί (64 hex)" ,"Chave pública (64 hex)"} }, - { "Secret (32 hex chars)", { 0, "Geheim (32 hex)","Geheimnis (32 Hex)","Secret (32 hex)","Secreto (32 hex)","Segreto (32 hex)","Секрет (32 hex)","Секрет (32 hex)","Тайна (32 hex)","Тајна (32 hex)","Μυστικό (32 hex)" ,"Segredo (32 hex)"} }, - { "Secret (32 hex, optional)", { 0, "Geheim (32 hex, optioneel)","Geheimnis (32 Hex, optional)","Secret (32 hex, facultatif)","Secreto (32 hex, opcional)","Segreto (32 hex, opzionale)","Секрет (32 hex, необяз.)","Секрет (32 hex, необов.)","Тайна (32 hex, по избор)","Тајна (32 hex, опционо)","Μυστικό (32 hex, προαιρ.)" ,"Segredo (32 hex, opcional)"} }, - { "Lock wallpaper", { 0, "Vergrendelachtergrond","Sperr-Hintergrund","Fond de verrouillage","Fondo de bloqueo","Sfondo blocco","Обои блокировки","Шпалери блокування","Тапет за заключване","Позадина закључавања","Ταπετσαρία κλειδώματος" ,"Papel de parede do bloqueio"} }, - { "Lock wallpaper set", { 0, "Vergrendelachtergrond ingesteld","Sperr-Hintergrund gesetzt","Fond de verrouillage défini","Fondo de bloqueo establecido","Sfondo blocco impostato","Обои блокировки заданы","Шпалери блокування встановлено","Тапетът за заключване е зададен","Позадина закључавања постављена","Ορίστηκε ταπετσαρία κλειδώματος" ,"Papel de parede definido"} }, - { "Lock text colour set", { 0, "Tekstkleur vergrendeling ingesteld","Sperrtext-Farbe gesetzt","Couleur du texte de verrouillage définie","Color de texto de bloqueo establecido","Colore testo blocco impostato","Цвет текста блокировки задан","Колір тексту блокування встановлено","Цветът на текста при заключване е зададен","Боја текста закључавања постављена","Ορίστηκε χρώμα κειμένου κλειδώματος" ,"Cor do texto de bloqueio definida"} }, - { "Radio off", { 0, "Radio uit","Funk aus","Radio désactivée","Radio apagada","Radio spenta","Радио выкл","Радіо вимк","Радиото е изключено","Радио искључен","Ράδιο ανενεργό" ,"Rádio desligado"} }, - { "Radio: off", { 0, "Radio: uit","Funk: aus","Radio : désactivée","Radio: apagada","Radio: spenta","Радио: выкл","Радіо: вимк","Радио: изключено","Радио: искључен","Ράδιο: ανενεργό" ,"Rádio: desligado"} }, - { "Path reset", { 0, "Pad gereset","Pfad zurückgesetzt","Chemin réinitialisé","Ruta restablecida","Percorso reimpostato","Путь сброшен","Шлях скинуто","Пътят е нулиран","Путања ресетована","Η διαδρομή επαναφέρθηκε" ,"Rota redefinida"} }, - { "Path reset failed", { 0, "Pad resetten mislukt","Pfad-Reset fehlgeschlagen","Échec de réinit. du chemin","Error al restablecer ruta","Reset percorso non riuscito","Не удалось сбросить путь","Не вдалося скинути шлях","Нулирането на пътя неуспешно","Ресет путање није успео","Αποτυχία επαναφοράς διαδρομής" ,"Falha ao redefinir rota"} }, - { "Ping failed", { 0, "Ping mislukt","Ping fehlgeschlagen","Échec du ping","Error de ping","Ping non riuscito","Сбой ping","Помилка ping","Ping неуспешен","Ping није успео","Αποτυχία ping" ,"Falha no ping"} }, - { "Public channel ready", { 0, "Openbaar kanaal klaar","Öffentl. Kanal bereit","Canal public prêt","Canal público listo","Canale pubblico pronto","Публичный канал готов","Публічний канал готовий","Публичният канал е готов","Јавни канал спреман","Το δημόσιο κανάλι είναι έτοιμο" ,"Canal público pronto"} }, - { "Password too long", { 0, "Wachtwoord te lang","Passwort zu lang","Mot de passe trop long","Contraseña demasiado larga","Password troppo lunga","Пароль слишком длинный","Пароль задовгий","Паролата е твърде дълга","Лозинка предуга","Ο κωδικός είναι πολύ μακρύς" ,"Senha muito longa"} }, - { "SSID too long", { 0, "SSID te lang","SSID zu lang","SSID trop long","SSID demasiado largo","SSID troppo lungo","SSID слишком длинный","SSID задовгий","SSID е твърде дълъг","SSID предуг","Το SSID είναι πολύ μακρύ" ,"SSID muito longo"} }, - { "Enter SSID first", { 0, "Voer eerst SSID in","Zuerst SSID eingeben","Saisissez d'abord le SSID","Introduce primero el SSID","Inserisci prima l'SSID","Сначала введите SSID","Спочатку введіть SSID","Първо въведете SSID","Прво унесите SSID","Εισαγάγετε πρώτα το SSID" ,"Digite o SSID primeiro"} }, - { "Set an SSID first", { 0, "Stel eerst een SSID in","Zuerst SSID festlegen","Définissez d'abord un SSID","Establece primero un SSID","Imposta prima un SSID","Сначала задайте SSID","Спочатку задайте SSID","Първо задайте SSID","Прво поставите SSID","Ορίστε πρώτα ένα SSID" ,"Defina um SSID primeiro"} }, - { "Set your location first", { 0, "Stel eerst je locatie in","Zuerst Standort festlegen","Définissez d'abord votre position","Establece primero tu ubicación","Imposta prima la posizione","Сначала задайте местоположение","Спочатку задайте місцезнаходження","Първо задайте местоположението","Прво поставите локацију","Ορίστε πρώτα την τοποθεσία σας" ,"Defina sua localização primeiro"} }, - { "Wi-Fi saved (radio off)", { 0, "Wi-Fi opgeslagen (radio uit)","WLAN gespeichert (Funk aus)","Wi-Fi enregistré (radio off)","Wi-Fi guardado (radio off)","Wi-Fi salvato (radio off)","Wi-Fi сохранён (радио выкл)","Wi-Fi збережено (радіо вимк)","Wi-Fi запазен (радио изкл)","Wi-Fi сачуван (радио искљ)","Το Wi-Fi αποθηκεύτηκε (ράδιο off)" ,"Wi-Fi salvo (rádio desligado)"} }, - { "Wi-Fi saved, reconnecting", { 0, "Wi-Fi opgeslagen, opnieuw verbinden","WLAN gespeichert, verbinde neu","Wi-Fi enregistré, reconnexion","Wi-Fi guardado, reconectando","Wi-Fi salvato, riconnessione","Wi-Fi сохранён, переподключение","Wi-Fi збережено, перепідключення","Wi-Fi запазен, повторно свързване","Wi-Fi сачуван, поновно повезивање","Το Wi-Fi αποθηκεύτηκε, επανασύνδεση" ,"Wi-Fi salvo, reconectando"} }, - { "Select a DM thread", { 0, "Kies een DM-gesprek","DN-Unterhaltung wählen","Choisir une conversation MP","Selecciona un hilo de MD","Seleziona una conversazione MD","Выберите тему ЛС","Виберіть гілку ПП","Изберете ЛС разговор","Изаберите ДП разговор","Επιλέξτε νήμα ΠΜ" ,"Selecione uma conversa de DM"} }, - { "No Bluetooth on this device", { 0, "Geen Bluetooth op dit apparaat","Kein Bluetooth auf diesem Gerät","Pas de Bluetooth sur cet appareil","Sin Bluetooth en este dispositivo","Nessun Bluetooth su questo dispositivo","Нет Bluetooth на этом устройстве","Немає Bluetooth на цьому пристрої","Няма Bluetooth на това устройство","Нема Bluetooth на овом уређају","Δεν υπάρχει Bluetooth σε αυτή τη συσκευή" ,"Sem Bluetooth neste aparelho"} }, - { "No networks found (2.4 GHz only)", { 0, "Geen netwerken gevonden (alleen 2.4 GHz)","Keine Netzwerke gefunden (nur 2,4 GHz)","Aucun réseau trouvé (2,4 GHz uniquement)","No se encontraron redes (solo 2.4 GHz)","Nessuna rete trovata (solo 2.4 GHz)","Сети не найдены (только 2.4 ГГц)","Мережі не знайдено (лише 2.4 ГГц)","Няма намерени мрежи (само 2.4 GHz)","Нема мрежа (само 2.4 GHz)","Δεν βρέθηκαν δίκτυα (μόνο 2.4 GHz)" ,"Nenhuma rede encontrada (só 2.4 GHz)"} }, - { "Insert emoji / symbol", { 0, "Emoji / symbool invoegen","Emoji / Symbol einfügen","Insérer emoji / symbole","Insertar emoji / símbolo","Inserisci emoji / simbolo","Вставить эмодзи / символ","Вставити емодзі / символ","Вмъкни емоджи / символ","Уметни емоџи / симбол","Εισαγωγή emoji / συμβόλου" ,"Inserir emoji / símbolo"} }, - { "tap to cancel", { 0, "tik om te annuleren","zum Abbrechen tippen","toucher pour annuler","toca para cancelar","tocca per annullare","нажмите для отмены","торкніться, щоб скасувати","докоснете за отказ","додирните за отказ","πατήστε για ακύρωση" ,"toque para cancelar"} }, - { "keep holding", { 0, "blijf vasthouden","weiter halten","maintenez","mantén pulsado","tieni premuto","удерживайте","утримуйте","задръжте","држите","κρατήστε πατημένο" ,"continue segurando"} }, - { "hold the trackball to unlock", { 0, "houd de trackball vast om te ontgrendelen","Trackball halten zum Entsperren","maintenez la boule pour déverrouiller","mantén el trackball para desbloquear","tieni premuta la trackball per sbloccare","удерживайте трекбол для разблокировки","утримуйте трекбол для розблокування","задръжте топчето за отключване","држите куглицу да откључате","κρατήστε το trackball για ξεκλείδωμα" ,"segure o trackball para desbloquear"} }, + { "Contacts", { 0, "Contacten","Kontakte","Contacts","Contactos","Contatti","Контакты","Контакти","Контакти","Контакти","Επαφές" ,"Contatos","Contacte" } }, + { "Commands", { 0, "Opdrachten","Befehle","Commandes","Comandos","Comandi","Команды","Команди","Команди","Команде","Εντολές" ,"Comandos","Comenzi" } }, + { "Editing", { 0, "Bewerken","Bearbeiten","Modification","Editando","Modifica","Редактирование","Редагування","Редактиране","Уређивање","Επεξεργασία" ,"Editando","Editare" } }, + { "Full", { 0, "Vol","Voll","Plein","Lleno","Pieno","Заполнено","Заповнено","Пълно","Пуно","Πλήρες" ,"Cheio","Plin" } }, + { "Delivered", { 0, "Afgeleverd","Zugestellt","Remis","Entregado","Consegnato","Доставлено","Доставлено","Доставено","Испоручено","Παραδόθηκε" ,"Entregue","Livrat" } }, + { "New contact", { 0, "Nieuw contact","Neuer Kontakt","Nouveau contact","Nuevo contacto","Nuovo contatto","Новый контакт","Новий контакт","Нов контакт","Нови контакт","Νέα επαφή" ,"Novo contato","Contact nou" } }, + { "New DM", { 0, "Nieuw DM","Neue DN","Nouveau MP","Nuevo MD","Nuovo MD","Новое ЛС","Нове ПП","Ново ЛС","Нова ДП","Νέο ΠΜ" ,"Nova DM","DM nou" } }, + { "New channel msg", { 0, "Nieuw kanaalbericht","Neue Kanalnachricht","Nouveau msg de canal","Nuevo msj de canal","Nuovo msg canale","Новое сообщ. канала","Нове повід. каналу","Ново съобщ. в канал","Нова порука канала","Νέο μήνυμα καναλιού" ,"Nova msg de canal","Mesaj nou pe canal" } }, + { "New room msg", { 0, "Nieuw roombericht","Neue Raumnachricht","Nouveau msg de salon","Nuevo msj de sala","Nuovo msg stanza","Новое сообщ. комнаты","Нове повід. кімнати","Ново съобщ. в стая","Нова порука собе","Νέο μήνυμα δωματίου" ,"Nova msg de sala","Mesaj nou în cameră" } }, + { "Nearby on map", { 0, "Dichtbij op kaart","In der Nähe auf Karte","À proximité sur la carte","Cerca en el mapa","Vicino sulla mappa","Рядом на карте","Поблизу на карті","Наблизо на картата","У близини на мапи","Κοντά στον χάρτη" ,"Por perto no mapa","În apropiere pe hartă" } }, + { "Advert policy saved", { 0, "Advertentiebeleid opgeslagen","Advert-Richtlinie gespeichert","Règle d'annonce enregistrée","Política de anuncio guardada","Criterio advert salvato","Политика advert сохранена","Політику advert збережено","Правилото за advert е запазено","Правило за advert сачувано","Η πολιτική advert αποθηκεύτηκε" ,"Política de advert salva","Politică advert salvată" } }, + { "Auto-add saved", { 0, "Auto-toevoegen opgeslagen","Auto-Hinzufügen gespeichert","Ajout auto enregistré","Auto-añadir guardado","Auto-aggiunta salvata","Автодобавление сохранено","Автододавання збережено","Авто-добавянето е запазено","Аутододавање сачувано","Η αυτόματη προσθήκη αποθηκεύτηκε" ,"Auto-adição salva","Auto-adăugare salvată" } }, + { "Experimental saved", { 0, "Experimenteel opgeslagen","Experimentell gespeichert","Expérimental enregistré","Experimental guardado","Sperimentale salvato","Эксперим. сохранено","Експерим. збережено","Експерим. запазено","Експериментално сачувано","Πειραματικά αποθηκεύτηκαν" ,"Experimental salvo","Experimentale salvate" } }, + { "Channel scope saved", { 0, "Kanaalbereik opgeslagen","Kanalbereich gespeichert","Portée du canal enregistrée","Alcance del canal guardado","Ambito canale salvato","Область канала сохранена","Область каналу збережено","Обхватът на канала е запазен","Опсег канала сачуван","Το εύρος καναλιού αποθηκεύτηκε" ,"Escopo do canal salvo","Domeniu canal salvat" } }, + { "Couldn't delete", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Suppression impossible","No se pudo eliminar","Impossibile eliminare","Не удалось удалить","Не вдалося видалити","Изтриването неуспешно","Брисање није успело","Αδυναμία διαγραφής" ,"Não foi possível excluir","Nu s-a putut şterge" } }, + { "Create failed", { 0, "Aanmaken mislukt","Erstellen fehlgeschlagen","Échec de création","Error al crear","Creazione non riuscita","Не удалось создать","Не вдалося створити","Създаването неуспешно","Креирање није успело","Αποτυχία δημιουργίας" ,"Falha ao criar","Creare eşuată" } }, + { "Delete failed", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Échec de suppression","Error al eliminar","Eliminazione non riuscita","Не удалось удалить","Не вдалося видалити","Изтриването неуспешно","Брисање није успело","Αποτυχία διαγραφής" ,"Falha ao excluir","Ştergere eşuată" } }, + { "Save failed", { 0, "Opslaan mislukt","Speichern fehlgeschlagen","Échec d'enregistrement","Error al guardar","Salvataggio non riuscito","Не удалось сохранить","Не вдалося зберегти","Запазването неуспешно","Чување није успело","Αποτυχία αποθήκευσης" ,"Falha ao salvar","Salvare eşuată" } }, + { "Channel not found", { 0, "Kanaal niet gevonden","Kanal nicht gefunden","Canal introuvable","Canal no encontrado","Canale non trovato","Канал не найден","Канал не знайдено","Каналът не е намерен","Канал није пронађен","Το κανάλι δεν βρέθηκε" ,"Canal não encontrado","Canal negăsit" } }, + { "Channel table is full", { 0, "Kanaaltabel is vol","Kanaltabelle ist voll","Table des canaux pleine","Tabla de canales llena","Tabella canali piena","Таблица каналов заполнена","Таблиця каналів заповнена","Таблицата с канали е пълна","Табела канала је пуна","Ο πίνακας καναλιών είναι πλήρης" ,"Tabela de canais cheia","Tabela de canale este plină" } }, + { "Contact gone", { 0, "Contact weg","Kontakt weg","Contact perdu","Contacto perdido","Contatto perso","Контакт пропал","Контакт зник","Контактът изчезна","Контакт нестао","Η επαφή χάθηκε" ,"Contato sumiu","Contact dispărut" } }, + { "Contact missing", { 0, "Contact ontbreekt","Kontakt fehlt","Contact manquant","Falta el contacto","Contatto mancante","Контакт отсутствует","Контакт відсутній","Контактът липсва","Контакт недостаје","Η επαφή λείπει" ,"Contato ausente","Contact lipsă" } }, + { "Contacts full", { 0, "Contacten vol","Kontakte voll","Contacts pleins","Contactos llenos","Contatti pieni","Контакты заполнены","Контакти заповнено","Контактите са пълни","Контакти пуни","Οι επαφές είναι πλήρεις" ,"Contatos cheios","Contacte pline" } }, + { "Cannot open file", { 0, "Kan bestand niet openen","Datei kann nicht geöffnet werden","Impossible d'ouvrir le fichier","No se puede abrir el archivo","Impossibile aprire il file","Не удаётся открыть файл","Не вдалося відкрити файл","Файлът не може да се отвори","Не могу да отворим датотеку","Αδυναμία ανοίγματος αρχείου" ,"Não foi possível abrir o arquivo","Fişierul nu poate fi deschis" } }, + { "Connecting...", { 0, "Verbinden...","Verbinde...","Connexion...","Conectando...","Connessione...","Подключение...","Підключення...","Свързване...","Повезивање...","Σύνδεση..." ,"Conectando...","Se conectează..." } }, + { "Loading...", { 0, "Laden...","Lädt...","Chargement...","Cargando...","Caricamento...","Загрузка...","Завантаження...","Зареждане...","Учитавање...","Φόρτωση..." ,"Carregando...","Se încarcă..." } }, + { "Loading map…", { 0, "Kaart laden…","Karte wird geladen…","Chargement de la carte…","Cargando mapa…","Caricamento mappa…","Загрузка карты…","Завантаження карти…","Зареждане на картата…","Учитавање мапе…","Φόρτωση χάρτη…" ,"Carregando mapa…","Se încarcă harta…" } }, + { "Scanning…", { 0, "Scannen…","Suche…","Analyse…","Buscando…","Scansione…","Сканирование…","Сканування…","Сканиране…","Скенирање…","Σάρωση…" ,"Buscando…","Se scanează…" } }, + { "Logging in…", { 0, "Inloggen…","Anmeldung…","Connexion…","Iniciando sesión…","Accesso…","Вход…","Вхід…","Вписване…","Пријава…","Σύνδεση…" ,"Entrando…","Se autentifică…" } }, + { "Rebooting...", { 0, "Opnieuw opstarten...","Neustart...","Redémarrage...","Reiniciando...","Riavvio...","Перезагрузка...","Перезавантаження...","Рестартиране...","Рестартовање...","Επανεκκίνηση..." ,"Reiniciando...","Se reporneşte..." } }, + { "Rebooting…", { 0, "Opnieuw opstarten…","Neustart…","Redémarrage…","Reiniciando…","Riavvio…","Перезагрузка…","Перезавантаження…","Рестартиране…","Рестартовање…","Επανεκκίνηση…" ,"Reiniciando…","Se reporneşte…" } }, + { "Locking…", { 0, "Vergrendelen…","Sperren…","Verrouillage…","Bloqueando…","Blocco…","Блокировка…","Блокування…","Заключване…","Закључавање…","Κλείδωμα…" ,"Bloqueando…","Se blochează…" } }, + { "Unlocking…", { 0, "Ontgrendelen…","Entsperren…","Déverrouillage…","Desbloqueando…","Sblocco…","Разблокировка…","Розблокування…","Отключване…","Откључавање…","Ξεκλείδωμα…" ,"Desbloqueando…","Se deblochează…" } }, + { "Hashtag name", { 0, "Hashtagnaam","Hashtag-Name","Nom du hashtag","Nombre de hashtag","Nome hashtag","Имя хэштега","Назва хештегу","Име на хаштаг","Назив хаштага","Όνομα hashtag" ,"Nome da hashtag","Nume hashtag" } }, + { "Public key (64 hex chars)", { 0, "Publieke sleutel (64 hex)","Öffentl. Schlüssel (64 Hex)","Clé publique (64 hex)","Clave pública (64 hex)","Chiave pubblica (64 hex)","Открытый ключ (64 hex)","Відкритий ключ (64 hex)","Публичен ключ (64 hex)","Јавни кључ (64 hex)","Δημόσιο κλειδί (64 hex)" ,"Chave pública (64 hex)","Cheie publică (64 caractere hex)" } }, + { "Secret (32 hex chars)", { 0, "Geheim (32 hex)","Geheimnis (32 Hex)","Secret (32 hex)","Secreto (32 hex)","Segreto (32 hex)","Секрет (32 hex)","Секрет (32 hex)","Тайна (32 hex)","Тајна (32 hex)","Μυστικό (32 hex)" ,"Segredo (32 hex)","Secret (32 caractere hex)" } }, + { "Secret (32 hex, optional)", { 0, "Geheim (32 hex, optioneel)","Geheimnis (32 Hex, optional)","Secret (32 hex, facultatif)","Secreto (32 hex, opcional)","Segreto (32 hex, opzionale)","Секрет (32 hex, необяз.)","Секрет (32 hex, необов.)","Тайна (32 hex, по избор)","Тајна (32 hex, опционо)","Μυστικό (32 hex, προαιρ.)" ,"Segredo (32 hex, opcional)","Secret (32 hex, opţional)" } }, + { "Lock wallpaper", { 0, "Vergrendelachtergrond","Sperr-Hintergrund","Fond de verrouillage","Fondo de bloqueo","Sfondo blocco","Обои блокировки","Шпалери блокування","Тапет за заключване","Позадина закључавања","Ταπετσαρία κλειδώματος" ,"Papel de parede do bloqueio","Fundal blocare" } }, + { "Lock wallpaper set", { 0, "Vergrendelachtergrond ingesteld","Sperr-Hintergrund gesetzt","Fond de verrouillage défini","Fondo de bloqueo establecido","Sfondo blocco impostato","Обои блокировки заданы","Шпалери блокування встановлено","Тапетът за заключване е зададен","Позадина закључавања постављена","Ορίστηκε ταπετσαρία κλειδώματος" ,"Papel de parede definido","Fundal blocare setat" } }, + { "Lock text colour set", { 0, "Tekstkleur vergrendeling ingesteld","Sperrtext-Farbe gesetzt","Couleur du texte de verrouillage définie","Color de texto de bloqueo establecido","Colore testo blocco impostato","Цвет текста блокировки задан","Колір тексту блокування встановлено","Цветът на текста при заключване е зададен","Боја текста закључавања постављена","Ορίστηκε χρώμα κειμένου κλειδώματος" ,"Cor do texto de bloqueio definida","Culoare text blocare setată" } }, + { "Radio off", { 0, "Radio uit","Funk aus","Radio désactivée","Radio apagada","Radio spenta","Радио выкл","Радіо вимк","Радиото е изключено","Радио искључен","Ράδιο ανενεργό" ,"Rádio desligado","Radio oprit" } }, + { "Radio: off", { 0, "Radio: uit","Funk: aus","Radio : désactivée","Radio: apagada","Radio: spenta","Радио: выкл","Радіо: вимк","Радио: изключено","Радио: искључен","Ράδιο: ανενεργό" ,"Rádio: desligado","Radio: oprit" } }, + { "Path reset", { 0, "Pad gereset","Pfad zurückgesetzt","Chemin réinitialisé","Ruta restablecida","Percorso reimpostato","Путь сброшен","Шлях скинуто","Пътят е нулиран","Путања ресетована","Η διαδρομή επαναφέρθηκε" ,"Rota redefinida","Cale resetată" } }, + { "Path reset failed", { 0, "Pad resetten mislukt","Pfad-Reset fehlgeschlagen","Échec de réinit. du chemin","Error al restablecer ruta","Reset percorso non riuscito","Не удалось сбросить путь","Не вдалося скинути шлях","Нулирането на пътя неуспешно","Ресет путање није успео","Αποτυχία επαναφοράς διαδρομής" ,"Falha ao redefinir rota","Resetarea căii a eşuat" } }, + { "Ping failed", { 0, "Ping mislukt","Ping fehlgeschlagen","Échec du ping","Error de ping","Ping non riuscito","Сбой ping","Помилка ping","Ping неуспешен","Ping није успео","Αποτυχία ping" ,"Falha no ping","Ping eşuat" } }, + { "Public channel ready", { 0, "Openbaar kanaal klaar","Öffentl. Kanal bereit","Canal public prêt","Canal público listo","Canale pubblico pronto","Публичный канал готов","Публічний канал готовий","Публичният канал е готов","Јавни канал спреман","Το δημόσιο κανάλι είναι έτοιμο" ,"Canal público pronto","Canal public pregătit" } }, + { "Password too long", { 0, "Wachtwoord te lang","Passwort zu lang","Mot de passe trop long","Contraseña demasiado larga","Password troppo lunga","Пароль слишком длинный","Пароль задовгий","Паролата е твърде дълга","Лозинка предуга","Ο κωδικός είναι πολύ μακρύς" ,"Senha muito longa","Parolă prea lungă" } }, + { "SSID too long", { 0, "SSID te lang","SSID zu lang","SSID trop long","SSID demasiado largo","SSID troppo lungo","SSID слишком длинный","SSID задовгий","SSID е твърде дълъг","SSID предуг","Το SSID είναι πολύ μακρύ" ,"SSID muito longo","SSID prea lung" } }, + { "Enter SSID first", { 0, "Voer eerst SSID in","Zuerst SSID eingeben","Saisissez d'abord le SSID","Introduce primero el SSID","Inserisci prima l'SSID","Сначала введите SSID","Спочатку введіть SSID","Първо въведете SSID","Прво унесите SSID","Εισαγάγετε πρώτα το SSID" ,"Digite o SSID primeiro","Introdu mai întâi SSID-ul" } }, + { "Set an SSID first", { 0, "Stel eerst een SSID in","Zuerst SSID festlegen","Définissez d'abord un SSID","Establece primero un SSID","Imposta prima un SSID","Сначала задайте SSID","Спочатку задайте SSID","Първо задайте SSID","Прво поставите SSID","Ορίστε πρώτα ένα SSID" ,"Defina um SSID primeiro","Setează mai întâi un SSID" } }, + { "Set your location first", { 0, "Stel eerst je locatie in","Zuerst Standort festlegen","Définissez d'abord votre position","Establece primero tu ubicación","Imposta prima la posizione","Сначала задайте местоположение","Спочатку задайте місцезнаходження","Първо задайте местоположението","Прво поставите локацију","Ορίστε πρώτα την τοποθεσία σας" ,"Defina sua localização primeiro","Setează-ţi mai întâi locaţia" } }, + { "Wi-Fi saved (radio off)", { 0, "Wi-Fi opgeslagen (radio uit)","WLAN gespeichert (Funk aus)","Wi-Fi enregistré (radio off)","Wi-Fi guardado (radio off)","Wi-Fi salvato (radio off)","Wi-Fi сохранён (радио выкл)","Wi-Fi збережено (радіо вимк)","Wi-Fi запазен (радио изкл)","Wi-Fi сачуван (радио искљ)","Το Wi-Fi αποθηκεύτηκε (ράδιο off)" ,"Wi-Fi salvo (rádio desligado)","Wi-Fi salvat (radio oprit)" } }, + { "Wi-Fi saved, reconnecting", { 0, "Wi-Fi opgeslagen, opnieuw verbinden","WLAN gespeichert, verbinde neu","Wi-Fi enregistré, reconnexion","Wi-Fi guardado, reconectando","Wi-Fi salvato, riconnessione","Wi-Fi сохранён, переподключение","Wi-Fi збережено, перепідключення","Wi-Fi запазен, повторно свързване","Wi-Fi сачуван, поновно повезивање","Το Wi-Fi αποθηκεύτηκε, επανασύνδεση" ,"Wi-Fi salvo, reconectando","Wi-Fi salvat, se reconectează" } }, + { "Select a DM thread", { 0, "Kies een DM-gesprek","DN-Unterhaltung wählen","Choisir une conversation MP","Selecciona un hilo de MD","Seleziona una conversazione MD","Выберите тему ЛС","Виберіть гілку ПП","Изберете ЛС разговор","Изаберите ДП разговор","Επιλέξτε νήμα ΠΜ" ,"Selecione uma conversa de DM","Selectează o conversaţie DM" } }, + { "No Bluetooth on this device", { 0, "Geen Bluetooth op dit apparaat","Kein Bluetooth auf diesem Gerät","Pas de Bluetooth sur cet appareil","Sin Bluetooth en este dispositivo","Nessun Bluetooth su questo dispositivo","Нет Bluetooth на этом устройстве","Немає Bluetooth на цьому пристрої","Няма Bluetooth на това устройство","Нема Bluetooth на овом уређају","Δεν υπάρχει Bluetooth σε αυτή τη συσκευή" ,"Sem Bluetooth neste aparelho","Fără Bluetooth pe acest dispozitiv" } }, + { "No networks found (2.4 GHz only)", { 0, "Geen netwerken gevonden (alleen 2.4 GHz)","Keine Netzwerke gefunden (nur 2,4 GHz)","Aucun réseau trouvé (2,4 GHz uniquement)","No se encontraron redes (solo 2.4 GHz)","Nessuna rete trovata (solo 2.4 GHz)","Сети не найдены (только 2.4 ГГц)","Мережі не знайдено (лише 2.4 ГГц)","Няма намерени мрежи (само 2.4 GHz)","Нема мрежа (само 2.4 GHz)","Δεν βρέθηκαν δίκτυα (μόνο 2.4 GHz)" ,"Nenhuma rede encontrada (só 2.4 GHz)","Nicio reţea găsită (doar 2,4 GHz)" } }, + { "Insert emoji / symbol", { 0, "Emoji / symbool invoegen","Emoji / Symbol einfügen","Insérer emoji / symbole","Insertar emoji / símbolo","Inserisci emoji / simbolo","Вставить эмодзи / символ","Вставити емодзі / символ","Вмъкни емоджи / символ","Уметни емоџи / симбол","Εισαγωγή emoji / συμβόλου" ,"Inserir emoji / símbolo","Inserează emoji / simbol" } }, + { "tap to cancel", { 0, "tik om te annuleren","zum Abbrechen tippen","toucher pour annuler","toca para cancelar","tocca per annullare","нажмите для отмены","торкніться, щоб скасувати","докоснете за отказ","додирните за отказ","πατήστε για ακύρωση" ,"toque para cancelar","atinge pentru anulare" } }, + { "keep holding", { 0, "blijf vasthouden","weiter halten","maintenez","mantén pulsado","tieni premuto","удерживайте","утримуйте","задръжте","држите","κρατήστε πατημένο" ,"continue segurando","ţine apăsat" } }, + { "hold the trackball to unlock", { 0, "houd de trackball vast om te ontgrendelen","Trackball halten zum Entsperren","maintenez la boule pour déverrouiller","mantén el trackball para desbloquear","tieni premuta la trackball per sbloccare","удерживайте трекбол для разблокировки","утримуйте трекбол для розблокування","задръжте топчето за отключване","држите куглицу да откључате","κρατήστε το trackball για ξεκλείδωμα" ,"segure o trackball para desbloquear","ţine trackball-ul apăsat pentru deblocare" } }, // ---- Sweep pass 4 (final): remaining alerts / hints ---- - { "Already exists or table full.", { 0, "Bestaat al of tabel vol.","Existiert bereits oder Tabelle voll.","Existe déjà ou table pleine.","Ya existe o tabla llena.","Esiste già o tabella piena.","Уже существует или таблица заполнена.","Вже існує або таблиця заповнена.","Вече съществува или таблицата е пълна.","Већ постоји или је табела пуна.","Υπάρχει ήδη ή ο πίνακας είναι πλήρης." ,"Já existe ou tabela cheia."} }, - { "Battery calibration reset to default", { 0, "Batterijkalibratie teruggezet","Batteriekalibrierung zurückgesetzt","Étalonnage batterie réinitialisé","Calibración de batería restablecida","Calibrazione batteria ripristinata","Калибровка батареи сброшена","Калібрування батареї скинуто","Калибрирането на батерията е нулирано","Калибрација батерије ресетована","Επαναφορά βαθμονόμησης μπαταρίας" ,"Calibração da bateria redefinida"} }, - { "Battery read too low — charge fully first", { 0, "Batterijwaarde te laag — laad eerst volledig op","Batteriewert zu niedrig — zuerst voll laden","Lecture batterie trop basse — chargez d'abord","Lectura de batería muy baja — carga primero","Lettura batteria troppo bassa — carica prima","Заряд слишком низкий — сначала зарядите","Заряд надто низький — спершу зарядіть","Отчитането е твърде ниско — заредете първо","Очитавање прениско — прво напуните","Πολύ χαμηλή μέτρηση — φορτίστε πρώτα" ,"Leitura da bateria muito baixa — carregue até o fim primeiro"} }, - { "Connect Wi-Fi to reload tiles", { 0, "Verbind wifi om tegels te herladen","WLAN verbinden, um Kacheln neu zu laden","Connectez le Wi-Fi pour recharger les tuiles","Conecta Wi-Fi para recargar teselas","Connetti Wi-Fi per ricaricare i riquadri","Подключите Wi-Fi для перезагрузки тайлов","Підключіть Wi-Fi, щоб перезавантажити плитки","Свържете Wi-Fi за презареждане на плочките","Повежите Wi-Fi да поново учитате плочице","Συνδέστε Wi-Fi για επαναφόρτωση πλακιδίων" ,"Conecte o Wi-Fi para recarregar os tiles"} }, - { "Diagnostics are on the About page.", { 0, "Diagnostiek staat op de pagina Over.","Diagnose ist auf der Seite Über.","Les diagnostics sont sur la page À propos.","El diagnóstico está en la página Acerca de.","La diagnostica è nella pagina Informazioni.","Диагностика на странице «О программе».","Діагностика на сторінці «Про програму».","Диагностиката е на страницата Относно.","Дијагностика је на страници О програму.","Τα διαγνωστικά είναι στη σελίδα Σχετικά." ,"O diagnóstico está na página Sobre."} }, - { "Edit in Settings → Quick replies", { 0, "Bewerk in Instellingen → Snelle antwoorden","Bearbeiten in Einstellungen → Schnellantworten","Modifier dans Paramètres → Réponses rapides","Editar en Ajustes → Respuestas rápidas","Modifica in Impostazioni → Risposte rapide","Изменить в Настройки → Быстрые ответы","Редагувати в Налаштування → Швидкі відповіді","Редактирай в Настройки → Бързи отговори","Уреди у Подешавања → Брзи одговори","Επεξεργασία στις Ρυθμίσεις → Γρήγορες απαντήσεις" ,"Edite em Ajustes → Respostas rápidas"} }, - { "Export failed (can't open file)", { 0, "Export mislukt (kan bestand niet openen)","Export fehlgeschlagen (Datei nicht zu öffnen)","Échec de l'export (fichier illisible)","Error de exportación (no se abre el archivo)","Esportazione non riuscita (file non apribile)","Сбой экспорта (не удаётся открыть файл)","Помилка експорту (не вдалося відкрити файл)","Експортът неуспешен (файлът не се отваря)","Извоз није успео (датотека се не отвара)","Αποτυχία εξαγωγής (αδυναμία ανοίγματος)" ,"Falha ao exportar (não abre o arquivo)"} }, - { "Favorites unsupported", { 0, "Favorieten niet ondersteund","Favoriten nicht unterstützt","Favoris non pris en charge","Favoritos no compatibles","Preferiti non supportati","Избранное не поддерживается","Обране не підтримується","Любими не се поддържат","Омиљени нису подржани","Τα αγαπημένα δεν υποστηρίζονται" ,"Favoritos sem suporte"} }, - { "Format failed (no card / SD error)", { 0, "Formatteren mislukt (geen kaart / SD-fout)","Formatieren fehlgeschlagen (keine Karte / SD-Fehler)","Échec du formatage (pas de carte / erreur SD)","Error de formato (sin tarjeta / error SD)","Formattazione non riuscita (nessuna scheda / errore SD)","Сбой форматирования (нет карты / ошибка SD)","Помилка форматування (немає картки / помилка SD)","Форматирането неуспешно (няма карта / SD грешка)","Форматирање није успело (нема картице / SD грешка)","Αποτυχία διαμόρφωσης (χωρίς κάρτα / σφάλμα SD)" ,"Falha ao formatar (sem cartão / erro SD)"} }, - { "Import failed (bad/unreadable JSON)", { 0, "Import mislukt (ongeldige JSON)","Import fehlgeschlagen (ungültiges JSON)","Échec de l'import (JSON illisible)","Error de importación (JSON no válido)","Importazione non riuscita (JSON non valido)","Сбой импорта (неверный JSON)","Помилка імпорту (некоректний JSON)","Импортът неуспешен (невалиден JSON)","Увоз није успео (неисправан JSON)","Αποτυχία εισαγωγής (μη έγκυρο JSON)" ,"Falha ao importar (JSON inválido/ilegível)"} }, - { "Import: storage unavailable", { 0, "Import: opslag niet beschikbaar","Import: Speicher nicht verfügbar","Import : stockage indisponible","Importar: almacenamiento no disponible","Importa: archiviazione non disponibile","Импорт: хранилище недоступно","Імпорт: сховище недоступне","Импорт: хранилището е недостъпно","Увоз: складиште недоступно","Εισαγωγή: μη διαθέσιμος χώρος" ,"Importar: armazenamento indisponível"} }, - { "Internal has no folders", { 0, "Intern heeft geen mappen","Intern hat keine Ordner","Aucun dossier en interne","El interno no tiene carpetas","L'interno non ha cartelle","Во внутренней памяти нет папок","У внутрішній пам'яті немає тек","Вътрешната памет няма папки","Интерна нема фасцикле","Η εσωτερική δεν έχει φακέλους" ,"O interno não tem pastas"} }, - { "Invalid max hops", { 0, "Ongeldige max hops","Ungültige max. Hops","Sauts max invalides","Saltos máx no válidos","Hop max non validi","Неверное макс. число хопов","Невірне макс. число хопів","Невалидни макс. скокове","Неисправни макс. скокови","Μη έγκυρα μέγ. άλματα" ,"Máx. saltos inválido"} }, - { "Invalid radio values", { 0, "Ongeldige radiowaarden","Ungültige Funkwerte","Valeurs radio invalides","Valores de radio no válidos","Valori radio non validi","Неверные значения радио","Невірні значення радіо","Невалидни радио стойности","Неисправне радио вредности","Μη έγκυρες τιμές ράδιο" ,"Valores de rádio inválidos"} }, - { "Max zoom for this pack", { 0, "Max zoom voor dit pakket","Max. Zoom für dieses Paket","Zoom max pour ce pack","Zoom máx para este paquete","Zoom max per questo pacchetto","Макс. зум для этого пакета","Макс. зум для цього пакета","Макс. мащаб за този пакет","Макс. зум за овај пакет","Μέγ. ζουμ για αυτό το πακέτο" ,"Zoom máx. para este pacote"} }, - { "Min zoom for this pack", { 0, "Min zoom voor dit pakket","Min. Zoom für dieses Paket","Zoom min pour ce pack","Zoom mín para este paquete","Zoom min per questo pacchetto","Мин. зум для этого пакета","Мін. зум для цього пакета","Мин. мащаб за този пакет","Мин. зум за овај пакет","Ελάχ. ζουμ για αυτό το πακέτο" ,"Zoom mín. para este pacote"} }, - { "Mode: BLE starting / low memory", { 0, "Modus: BLE start / weinig geheugen","Modus: BLE startet / wenig Speicher","Mode : BLE démarre / mémoire faible","Modo: BLE iniciando / poca memoria","Modalità: BLE avvio / memoria scarsa","Режим: BLE запускается / мало памяти","Режим: BLE запускається / мало пам'яті","Режим: BLE стартира / малко памет","Режим: BLE стартује / мало меморије","Λειτουργία: BLE εκκινεί / λίγη μνήμη" ,"Modo: BLE iniciando / pouca memória"} }, - { "Name can't be empty.", { 0, "Naam mag niet leeg zijn.","Name darf nicht leer sein.","Le nom ne peut pas être vide.","El nombre no puede estar vacío.","Il nome non può essere vuoto.","Имя не может быть пустым.","Ім'я не може бути порожнім.","Името не може да е празно.","Име не може бити празно.","Το όνομα δεν μπορεί να είναι κενό." ,"O nome não pode ficar vazio."} }, - { "No DM key", { 0, "Geen DM-sleutel","Kein DN-Schlüssel","Pas de clé MP","Sin clave de MD","Nessuna chiave MD","Нет ключа ЛС","Немає ключа ПП","Няма ключ за ЛС","Нема ДП кључа","Χωρίς κλειδί ΠΜ" ,"Sem chave de DM"} }, - { "No contact for thread", { 0, "Geen contact voor gesprek","Kein Kontakt für Unterhaltung","Aucun contact pour ce fil","Sin contacto para el hilo","Nessun contatto per la conversazione","Нет контакта для темы","Немає контакту для гілки","Няма контакт за разговора","Нема контакта за разговор","Καμία επαφή για το νήμα" ,"Sem contato para a conversa"} }, - { "No contact to trace", { 0, "Geen contact om te traceren","Kein Kontakt zum Verfolgen","Aucun contact à tracer","Sin contacto para rastrear","Nessun contatto da tracciare","Нет контакта для трассировки","Немає контакту для трасування","Няма контакт за проследяване","Нема контакта за праћење","Καμία επαφή για ιχνηλάτηση" ,"Sem contato para rastrear"} }, - { "No tiles (non-ESP32)", { 0, "Geen tegels (niet-ESP32)","Keine Kacheln (kein ESP32)","Pas de tuiles (non-ESP32)","Sin teselas (no ESP32)","Nessun riquadro (non-ESP32)","Нет тайлов (не ESP32)","Немає плиток (не ESP32)","Няма плочки (не ESP32)","Нема плочица (није ESP32)","Χωρίς πλακίδια (μη ESP32)" ,"Sem tiles (não ESP32)"} }, - { "Pubkey must be 64 hex chars.", { 0, "Pubkey moet 64 hex-tekens zijn.","Pubkey muss 64 Hex-Zeichen haben.","La clé publique doit faire 64 hex.","La clave pública debe tener 64 hex.","La chiave pubblica deve avere 64 hex.","Откр. ключ — 64 hex-символа.","Відкр. ключ — 64 hex-символи.","Публичният ключ трябва да е 64 hex.","Јавни кључ мора бити 64 hex.","Το δημόσιο κλειδί πρέπει 64 hex." ,"A chave pública deve ter 64 hex."} }, - { "RX log", { 0, "RX-logboek","RX-Protokoll","Journal RX","Registro RX","Log RX","Журнал RX","Журнал RX","RX дневник","RX дневник","Αρχείο RX" ,"Log RX"} }, - { "Raw log", { 0, "Ruwe log","Roh-Protokoll","Journal brut","Registro sin procesar","Log grezzo","Сырой журнал","Сирий журнал","Суров дневник","Сирови дневник","Ακατέργαστο αρχείο" ,"Log bruto"} }, - { "RangeTest failed", { 0, "RangeTest mislukt","RangeTest fehlgeschlagen","Échec du RangeTest","Error de RangeTest","RangeTest non riuscito","Сбой RangeTest","Помилка RangeTest","RangeTest неуспешен","RangeTest није успео","Αποτυχία RangeTest" ,"Falha no RangeTest"} }, - { "Telemetry req failed", { 0, "Telemetrie-verzoek mislukt","Telemetrie-Anfrage fehlgeschlagen","Échec de la requête télémétrie","Error de solicitud de telemetría","Richiesta telemetria non riuscita","Сбой запроса телеметрии","Помилка запиту телеметрії","Заявката за телеметрия неуспешна","Захтев за телеметрију није успео","Αποτυχία αιτήματος τηλεμετρίας" ,"Falha na req. de telemetria"} }, - { "Trace failed", { 0, "Tracering mislukt","Trace fehlgeschlagen","Échec du traçage","Error de rastreo","Tracciamento non riuscito","Сбой трассировки","Помилка трасування","Проследяването неуспешно","Праћење није успело","Αποτυχία ιχνηλάτησης" ,"Falha no rastreamento"} }, - { "Tile reload needs Wi-Fi build", { 0, "Tegels herladen vereist wifi-build","Kachel-Neuladen braucht WLAN-Build","Le rechargement des tuiles requiert le build Wi-Fi","Recargar teselas requiere build Wi-Fi","La ricarica riquadri richiede build Wi-Fi","Перезагрузка тайлов требует Wi-Fi сборки","Перезавантаження плиток потребує Wi-Fi збірки","Презареждането изисква Wi-Fi компилация","Поновно учитавање захтева Wi-Fi билд","Η επαναφόρτωση χρειάζεται έκδοση Wi-Fi" ,"Recarregar tiles exige build com Wi-Fi"} }, - { "Too large to edit (>8 KB)", { 0, "Te groot om te bewerken (>8 KB)","Zu groß zum Bearbeiten (>8 KB)","Trop grand à modifier (>8 Ko)","Demasiado grande para editar (>8 KB)","Troppo grande da modificare (>8 KB)","Слишком велик для редактирования (>8 КБ)","Завеликий для редагування (>8 КБ)","Твърде голям за редактиране (>8 KB)","Превелико за уређивање (>8 KB)","Πολύ μεγάλο για επεξεργασία (>8 KB)" ,"Grande demais para editar (>8 KB)"} }, - { "Wi-Fi is not enabled in this build.", { 0, "Wi-Fi is niet ingeschakeld in deze build.","WLAN ist in diesem Build nicht aktiviert.","Le Wi-Fi n'est pas activé dans ce build.","Wi-Fi no está habilitado en este build.","Il Wi-Fi non è abilitato in questo build.","Wi-Fi не включён в этой сборке.","Wi-Fi не увімкнено в цій збірці.","Wi-Fi не е разрешен в тази компилация.","Wi-Fi није омогућен у овом билду.","Το Wi-Fi δεν είναι ενεργό σε αυτή την έκδοση." ,"O Wi-Fi não está ativado neste build."} }, - { "Wi-Fi isn't available on this build.", { 0, "Wi-Fi is niet beschikbaar in deze build.","WLAN ist in diesem Build nicht verfügbar.","Le Wi-Fi n'est pas disponible sur ce build.","Wi-Fi no está disponible en este build.","Il Wi-Fi non è disponibile su questo build.","Wi-Fi недоступен в этой сборке.","Wi-Fi недоступний у цій збірці.","Wi-Fi не е наличен в тази компилация.","Wi-Fi није доступан на овом билду.","Το Wi-Fi δεν είναι διαθέσιμο σε αυτή την έκδοση." ,"O Wi-Fi não está disponível neste build."} }, - { "Analyzing terrain…", { 0, "Terrein analyseren…","Gelände wird analysiert…","Analyse du terrain…","Analizando terreno…","Analisi del terreno…","Анализ рельефа…","Аналіз рельєфу…","Анализиране на терена…","Анализа терена…","Ανάλυση εδάφους…" ,"Analisando o terreno…"} }, - { "Exporting settings…", { 0, "Instellingen exporteren…","Einstellungen werden exportiert…","Exportation des réglages…","Exportando ajustes…","Esportazione impostazioni…","Экспорт настроек…","Експорт налаштувань…","Експортиране на настройки…","Извоз подешавања…","Εξαγωγή ρυθμίσεων…" ,"Exportando ajustes…"} }, - { "Importing settings…", { 0, "Instellingen importeren…","Einstellungen werden importiert…","Importation des réglages…","Importando ajustes…","Importazione impostazioni…","Импорт настроек…","Імпорт налаштувань…","Импортиране на настройки…","Увоз подешавања…","Εισαγωγή ρυθμίσεων…" ,"Importando ajustes…"} }, - { "Trace sent…", { 0, "Tracering verzonden…","Trace gesendet…","Traçage envoyé…","Rastreo enviado…","Traccia inviata…","Трассировка отправлена…","Трасування надіслано…","Проследяването е изпратено…","Праћење послато…","Στάλθηκε ιχνηλάτηση…" ,"Rastreamento enviado…"} }, - { "Tracing route…", { 0, "Route traceren…","Route wird verfolgt…","Traçage de l'itinéraire…","Rastreando ruta…","Tracciamento percorso…","Трассировка маршрута…","Трасування маршруту…","Проследяване на маршрута…","Праћење руте…","Ιχνηλάτηση διαδρομής…" ,"Rastreando rota…"} }, - { "Rotating… rebooting", { 0, "Draaien… opnieuw opstarten","Drehen… Neustart","Rotation… redémarrage","Girando… reiniciando","Rotazione… riavvio","Поворот… перезагрузка","Обертання… перезавантаження","Завъртане… рестарт","Ротација… рестарт","Περιστροφή… επανεκκίνηση" ,"Girando… reiniciando"} }, - { "Saved — rebooting", { 0, "Opgeslagen — opnieuw opstarten","Gespeichert — Neustart","Enregistré — redémarrage","Guardado — reiniciando","Salvato — riavvio","Сохранено — перезагрузка","Збережено — перезавантаження","Запазено — рестарт","Сачувано — рестарт","Αποθηκεύτηκε — επανεκκίνηση" ,"Salvo — reiniciando"} }, - { "Saved — reconnecting…", { 0, "Opgeslagen — opnieuw verbinden…","Gespeichert — verbinde neu…","Enregistré — reconnexion…","Guardado — reconectando…","Salvato — riconnessione…","Сохранено — переподключение…","Збережено — перепідключення…","Запазено — повторно свързване…","Сачувано — поновно повезивање…","Αποθηκεύτηκε — επανασύνδεση…" ,"Salvo — reconectando…"} }, - { "SSID set — enter password, then Save", { 0, "SSID ingesteld — voer wachtwoord in, dan Opslaan","SSID gesetzt — Passwort eingeben, dann Speichern","SSID défini — saisissez le mot de passe, puis Enregistrer","SSID establecido — escribe la contraseña y Guardar","SSID impostato — inserisci la password, poi Salva","SSID задан — введите пароль, затем Сохранить","SSID задано — введіть пароль, потім Зберегти","SSID зададен — въведете парола, после Запази","SSID постављен — унесите лозинку, па Сачувај","Ορίστηκε SSID — εισαγάγετε κωδικό, μετά Αποθήκευση" ,"SSID definido — digite a senha e Salvar"} }, - { "Powering off… click trackball to wake", { 0, "Uitschakelen… klik trackball om te wekken","Schaltet aus… Trackball klicken zum Aufwecken","Extinction… cliquez la boule pour réveiller","Apagando… pulsa el trackball para despertar","Spegnimento… clicca la trackball per riattivare","Выключение… нажмите трекбол для пробуждения","Вимкнення… натисніть трекбол для пробудження","Изключване… щракнете топчето за събуждане","Гашење… кликните куглицу за буђење","Απενεργοποίηση… κλικ στο trackball για αφύπνιση" ,"Desligando… clique o trackball para acordar"} }, - { "Roll to highlight • click to insert", { 0, "Rol om te markeren • klik om in te voegen","Rollen zum Hervorheben • Klick zum Einfügen","Faites rouler pour surligner • cliquez pour insérer","Desplaza para resaltar • pulsa para insertar","Scorri per evidenziare • clicca per inserire","Прокрутите для выбора • нажмите для вставки","Прокрутіть для вибору • натисніть для вставки","Превъртете за маркиране • щракнете за вмъкване","Котрљајте за истицање • кликните за уметање","Κυλήστε για επισήμανση • κλικ για εισαγωγή" ,"Role para destacar • clique para inserir"} }, - { "mesh link armed", { 0, "mesh link actief","Mesh-Link scharf","liaison mesh armée","enlace mesh armado","link mesh armato","меш-канал готов","меш-зв'язок готовий","меш връзка готова","меш веза спремна","σύνδεσμος mesh ενεργός" ,"enlace mesh pronto"} }, - { "n/a in this build", { 0, "n.v.t. in deze build","n. v. in diesem Build","n/d dans ce build","n/d en este build","n/d in questo build","н/д в этой сборке","н/д у цій збірці","н/п в тази компилация","н/д у овом билду","μ/δ σε αυτή την έκδοση" ,"n/d neste build"} }, - { "tap to exit full screen", { 0, "tik om volledig scherm te verlaten","tippen zum Verlassen des Vollbilds","toucher pour quitter le plein écran","toca para salir de pantalla completa","tocca per uscire da schermo intero","нажмите для выхода из полноэкранного режима","торкніться, щоб вийти з повноекранного режиму","докоснете за изход от цял екран","додирните за излаз из целог екрана","πατήστε για έξοδο από πλήρη οθόνη" ,"toque para sair da tela cheia"} }, - { "double-tap SPACE cycles through the ones you enable", { 0, "dubbeltik op SPATIE wisselt tussen de ingeschakelde","Doppeltipp auf LEER wechselt die aktivierten","double-tapez ESPACE pour parcourir les activés","doble toque en ESPACIO recorre los activados","doppio tocco su SPAZIO scorre quelli attivi","двойное нажатие ПРОБЕЛ переключает включённые","подвійне натискання ПРОБІЛ перемикає увімкнені","двойно докосване на ИНТЕРВАЛ превърта включените","двоструки додир РАЗМАК пролази кроз омогућене","διπλό πάτημα ΔΙΑΣΤΗΜΑ εναλλάσσει τα ενεργά" ,"toque duplo em ESPAÇO percorre os que você ativar"} }, - { "pick accented letters as you type; off = plain typing", { 0, "kies letters met accenten tijdens het typen; uit = gewoon typen","Akzentbuchstaben beim Tippen wählen; aus = einfaches Tippen","choisir les lettres accentuées en tapant ; désactivé = saisie simple","elige letras acentuadas al escribir; desactivado = escritura simple","scegli lettere accentate mentre scrivi; off = scrittura semplice","выбор букв с акцентами при наборе; выкл = обычный набор","вибір літер з акцентами під час набору; вимк = звичайний набір","избор на букви с ударения при писане; изкл = обикновено писане","бирање слова са акцентима док куцате; искљ = обично куцање","επιλογή τονισμένων γραμμάτων κατά την πληκτρολόγηση· ανενεργό = απλή" ,"escolha letras acentuadas ao digitar; desligado = digitação simples"} }, - { "tap the language key (e.g. EN) on the keyboard to cycle the ones you enable", { 0, "tik op de taaltoets (bijv. EN) op het toetsenbord om de ingeschakelde te doorlopen","Sprachtaste (z. B. EN) auf der Tastatur antippen, um die aktivierten zu wechseln","touchez la touche de langue (ex. EN) du clavier pour parcourir les activées","toca la tecla de idioma (p. ej. EN) del teclado para recorrer las activadas","tocca il tasto lingua (es. EN) sulla tastiera per scorrere quelle attive","нажмите клавишу языка (напр. EN) на клавиатуре для переключения включённых","натисніть клавішу мови (напр. EN) на клавіатурі для перемикання увімкнених","докоснете клавиша за език (напр. EN) на клавиатурата, за да превъртате включените","додирните тастер језика (нпр. EN) на тастатури да пролазите кроз омогућене","πατήστε το πλήκτρο γλώσσας (π.χ. EN) στο πληκτρολόγιο για εναλλαγή" ,"toque na tecla de idioma (ex. EN) no teclado para percorrer os que você ativar"} }, - { "Sample text", { 0, "Voorbeeldtekst","Beispieltext","Exemple de texte","Texto de ejemplo","Testo di esempio","Пример текста","Зразок тексту","Примерен текст","Пример текста","Δείγμα κειμένου" ,"Texto de exemplo"} }, - { "Save & restart", { 0, "Opslaan & herstarten","Speichern & neu starten","Enregistrer & redémarrer","Guardar y reiniciar","Salva e riavvia","Сохранить и перезагрузить","Зберегти й перезавантажити","Запази и рестартирай","Сачувај и рестартуј","Αποθήκευση & επανεκκίνηση" ,"Salvar e reiniciar"} }, - { "Save / turn on", { 0, "Opslaan / inschakelen","Speichern / einschalten","Enregistrer / activer","Guardar / activar","Salva / attiva","Сохранить / включить","Зберегти / увімкнути","Запази / включи","Сачувај / укључи","Αποθήκευση / ενεργοποίηση" ,"Salvar / ligar"} }, - { "Save auto-add", { 0, "Auto-toevoegen opslaan","Auto-Hinzufügen speichern","Enregistrer l'ajout auto","Guardar auto-añadir","Salva auto-aggiunta","Сохранить автодобавление","Зберегти автододавання","Запази авто-добавянето","Сачувај аутододавање","Αποθήκευση αυτόματης προσθήκης" ,"Salvar auto-adição"} }, - { "Save experimental", { 0, "Experimenteel opslaan","Experimentell speichern","Enregistrer l'expérimental","Guardar experimental","Salva sperimentale","Сохранить экспериментальное","Зберегти експериментальне","Запази експерименталното","Сачувај експериментално","Αποθήκευση πειραματικών" ,"Salvar experimental"} }, - { "Save quick replies", { 0, "Snelle antwoorden opslaan","Schnellantworten speichern","Enregistrer les réponses rapides","Guardar respuestas rápidas","Salva risposte rapide","Сохранить быстрые ответы","Зберегти швидкі відповіді","Запази бързите отговори","Сачувај брзе одговоре","Αποθήκευση γρήγορων απαντήσεων" ,"Salvar respostas rápidas"} }, + { "Already exists or table full.", { 0, "Bestaat al of tabel vol.","Existiert bereits oder Tabelle voll.","Existe déjà ou table pleine.","Ya existe o tabla llena.","Esiste già o tabella piena.","Уже существует или таблица заполнена.","Вже існує або таблиця заповнена.","Вече съществува или таблицата е пълна.","Већ постоји или је табела пуна.","Υπάρχει ήδη ή ο πίνακας είναι πλήρης." ,"Já existe ou tabela cheia.","Există deja sau tabela e plină." } }, + { "Battery calibration reset to default", { 0, "Batterijkalibratie teruggezet","Batteriekalibrierung zurückgesetzt","Étalonnage batterie réinitialisé","Calibración de batería restablecida","Calibrazione batteria ripristinata","Калибровка батареи сброшена","Калібрування батареї скинуто","Калибрирането на батерията е нулирано","Калибрација батерије ресетована","Επαναφορά βαθμονόμησης μπαταρίας" ,"Calibração da bateria redefinida","Calibrare baterie resetată la implicit" } }, + { "Battery read too low — charge fully first", { 0, "Batterijwaarde te laag — laad eerst volledig op","Batteriewert zu niedrig — zuerst voll laden","Lecture batterie trop basse — chargez d'abord","Lectura de batería muy baja — carga primero","Lettura batteria troppo bassa — carica prima","Заряд слишком низкий — сначала зарядите","Заряд надто низький — спершу зарядіть","Отчитането е твърде ниско — заредете първо","Очитавање прениско — прво напуните","Πολύ χαμηλή μέτρηση — φορτίστε πρώτα" ,"Leitura da bateria muito baixa — carregue até o fim primeiro","Citire baterie prea mică — încarcă complet mai întâi" } }, + { "Connect Wi-Fi to reload tiles", { 0, "Verbind wifi om tegels te herladen","WLAN verbinden, um Kacheln neu zu laden","Connectez le Wi-Fi pour recharger les tuiles","Conecta Wi-Fi para recargar teselas","Connetti Wi-Fi per ricaricare i riquadri","Подключите Wi-Fi для перезагрузки тайлов","Підключіть Wi-Fi, щоб перезавантажити плитки","Свържете Wi-Fi за презареждане на плочките","Повежите Wi-Fi да поново учитате плочице","Συνδέστε Wi-Fi για επαναφόρτωση πλακιδίων" ,"Conecte o Wi-Fi para recarregar os tiles","Conectează Wi-Fi pentru a reîncărca dalele" } }, + { "Diagnostics are on the About page.", { 0, "Diagnostiek staat op de pagina Over.","Diagnose ist auf der Seite Über.","Les diagnostics sont sur la page À propos.","El diagnóstico está en la página Acerca de.","La diagnostica è nella pagina Informazioni.","Диагностика на странице «О программе».","Діагностика на сторінці «Про програму».","Диагностиката е на страницата Относно.","Дијагностика је на страници О програму.","Τα διαγνωστικά είναι στη σελίδα Σχετικά." ,"O diagnóstico está na página Sobre.","Diagnosticarea este pe pagina Despre." } }, + { "Edit in Settings → Quick replies", { 0, "Bewerk in Instellingen → Snelle antwoorden","Bearbeiten in Einstellungen → Schnellantworten","Modifier dans Paramètres → Réponses rapides","Editar en Ajustes → Respuestas rápidas","Modifica in Impostazioni → Risposte rapide","Изменить в Настройки → Быстрые ответы","Редагувати в Налаштування → Швидкі відповіді","Редактирай в Настройки → Бързи отговори","Уреди у Подешавања → Брзи одговори","Επεξεργασία στις Ρυθμίσεις → Γρήγορες απαντήσεις" ,"Edite em Ajustes → Respostas rápidas","Editează în Setări → Răspunsuri rapide" } }, + { "Export failed (can't open file)", { 0, "Export mislukt (kan bestand niet openen)","Export fehlgeschlagen (Datei nicht zu öffnen)","Échec de l'export (fichier illisible)","Error de exportación (no se abre el archivo)","Esportazione non riuscita (file non apribile)","Сбой экспорта (не удаётся открыть файл)","Помилка експорту (не вдалося відкрити файл)","Експортът неуспешен (файлът не се отваря)","Извоз није успео (датотека се не отвара)","Αποτυχία εξαγωγής (αδυναμία ανοίγματος)" ,"Falha ao exportar (não abre o arquivo)","Export eşuat (fişierul nu se poate deschide)" } }, + { "Favorites unsupported", { 0, "Favorieten niet ondersteund","Favoriten nicht unterstützt","Favoris non pris en charge","Favoritos no compatibles","Preferiti non supportati","Избранное не поддерживается","Обране не підтримується","Любими не се поддържат","Омиљени нису подржани","Τα αγαπημένα δεν υποστηρίζονται" ,"Favoritos sem suporte","Favoritele nu sunt acceptate" } }, + { "Format failed (no card / SD error)", { 0, "Formatteren mislukt (geen kaart / SD-fout)","Formatieren fehlgeschlagen (keine Karte / SD-Fehler)","Échec du formatage (pas de carte / erreur SD)","Error de formato (sin tarjeta / error SD)","Formattazione non riuscita (nessuna scheda / errore SD)","Сбой форматирования (нет карты / ошибка SD)","Помилка форматування (немає картки / помилка SD)","Форматирането неуспешно (няма карта / SD грешка)","Форматирање није успело (нема картице / SD грешка)","Αποτυχία διαμόρφωσης (χωρίς κάρτα / σφάλμα SD)" ,"Falha ao formatar (sem cartão / erro SD)","Formatare eşuată (fără card / eroare SD)" } }, + { "Import failed (bad/unreadable JSON)", { 0, "Import mislukt (ongeldige JSON)","Import fehlgeschlagen (ungültiges JSON)","Échec de l'import (JSON illisible)","Error de importación (JSON no válido)","Importazione non riuscita (JSON non valido)","Сбой импорта (неверный JSON)","Помилка імпорту (некоректний JSON)","Импортът неуспешен (невалиден JSON)","Увоз није успео (неисправан JSON)","Αποτυχία εισαγωγής (μη έγκυρο JSON)" ,"Falha ao importar (JSON inválido/ilegível)","Import eşuat (JSON invalid/ilizibil)" } }, + { "Import: storage unavailable", { 0, "Import: opslag niet beschikbaar","Import: Speicher nicht verfügbar","Import : stockage indisponible","Importar: almacenamiento no disponible","Importa: archiviazione non disponibile","Импорт: хранилище недоступно","Імпорт: сховище недоступне","Импорт: хранилището е недостъпно","Увоз: складиште недоступно","Εισαγωγή: μη διαθέσιμος χώρος" ,"Importar: armazenamento indisponível","Import: stocare indisponibilă" } }, + { "Internal has no folders", { 0, "Intern heeft geen mappen","Intern hat keine Ordner","Aucun dossier en interne","El interno no tiene carpetas","L'interno non ha cartelle","Во внутренней памяти нет папок","У внутрішній пам'яті немає тек","Вътрешната памет няма папки","Интерна нема фасцикле","Η εσωτερική δεν έχει φακέλους" ,"O interno não tem pastas","Stocarea internă nu are foldere" } }, + { "Invalid max hops", { 0, "Ongeldige max hops","Ungültige max. Hops","Sauts max invalides","Saltos máx no válidos","Hop max non validi","Неверное макс. число хопов","Невірне макс. число хопів","Невалидни макс. скокове","Неисправни макс. скокови","Μη έγκυρα μέγ. άλματα" ,"Máx. saltos inválido","Salturi max nevalide" } }, + { "Invalid radio values", { 0, "Ongeldige radiowaarden","Ungültige Funkwerte","Valeurs radio invalides","Valores de radio no válidos","Valori radio non validi","Неверные значения радио","Невірні значення радіо","Невалидни радио стойности","Неисправне радио вредности","Μη έγκυρες τιμές ράδιο" ,"Valores de rádio inválidos","Valori radio nevalide" } }, + { "Max zoom for this pack", { 0, "Max zoom voor dit pakket","Max. Zoom für dieses Paket","Zoom max pour ce pack","Zoom máx para este paquete","Zoom max per questo pacchetto","Макс. зум для этого пакета","Макс. зум для цього пакета","Макс. мащаб за този пакет","Макс. зум за овај пакет","Μέγ. ζουμ για αυτό το πακέτο" ,"Zoom máx. para este pacote","Zoom maxim pentru acest pachet" } }, + { "Min zoom for this pack", { 0, "Min zoom voor dit pakket","Min. Zoom für dieses Paket","Zoom min pour ce pack","Zoom mín para este paquete","Zoom min per questo pacchetto","Мин. зум для этого пакета","Мін. зум для цього пакета","Мин. мащаб за този пакет","Мин. зум за овај пакет","Ελάχ. ζουμ για αυτό το πακέτο" ,"Zoom mín. para este pacote","Zoom minim pentru acest pachet" } }, + { "Mode: BLE starting / low memory", { 0, "Modus: BLE start / weinig geheugen","Modus: BLE startet / wenig Speicher","Mode : BLE démarre / mémoire faible","Modo: BLE iniciando / poca memoria","Modalità: BLE avvio / memoria scarsa","Режим: BLE запускается / мало памяти","Режим: BLE запускається / мало пам'яті","Режим: BLE стартира / малко памет","Режим: BLE стартује / мало меморије","Λειτουργία: BLE εκκινεί / λίγη μνήμη" ,"Modo: BLE iniciando / pouca memória","Mod: BLE porneşte / memorie redusă" } }, + { "Name can't be empty.", { 0, "Naam mag niet leeg zijn.","Name darf nicht leer sein.","Le nom ne peut pas être vide.","El nombre no puede estar vacío.","Il nome non può essere vuoto.","Имя не может быть пустым.","Ім'я не може бути порожнім.","Името не може да е празно.","Име не може бити празно.","Το όνομα δεν μπορεί να είναι κενό." ,"O nome não pode ficar vazio.","Numele nu poate fi gol." } }, + { "No DM key", { 0, "Geen DM-sleutel","Kein DN-Schlüssel","Pas de clé MP","Sin clave de MD","Nessuna chiave MD","Нет ключа ЛС","Немає ключа ПП","Няма ключ за ЛС","Нема ДП кључа","Χωρίς κλειδί ΠΜ" ,"Sem chave de DM","Fără cheie DM" } }, + { "No contact for thread", { 0, "Geen contact voor gesprek","Kein Kontakt für Unterhaltung","Aucun contact pour ce fil","Sin contacto para el hilo","Nessun contatto per la conversazione","Нет контакта для темы","Немає контакту для гілки","Няма контакт за разговора","Нема контакта за разговор","Καμία επαφή για το νήμα" ,"Sem contato para a conversa","Niciun contact pentru conversaţie" } }, + { "No contact to trace", { 0, "Geen contact om te traceren","Kein Kontakt zum Verfolgen","Aucun contact à tracer","Sin contacto para rastrear","Nessun contatto da tracciare","Нет контакта для трассировки","Немає контакту для трасування","Няма контакт за проследяване","Нема контакта за праћење","Καμία επαφή για ιχνηλάτηση" ,"Sem contato para rastrear","Niciun contact de trasat" } }, + { "No tiles (non-ESP32)", { 0, "Geen tegels (niet-ESP32)","Keine Kacheln (kein ESP32)","Pas de tuiles (non-ESP32)","Sin teselas (no ESP32)","Nessun riquadro (non-ESP32)","Нет тайлов (не ESP32)","Немає плиток (не ESP32)","Няма плочки (не ESP32)","Нема плочица (није ESP32)","Χωρίς πλακίδια (μη ESP32)" ,"Sem tiles (não ESP32)","Fără dale (non-ESP32)" } }, + { "Pubkey must be 64 hex chars.", { 0, "Pubkey moet 64 hex-tekens zijn.","Pubkey muss 64 Hex-Zeichen haben.","La clé publique doit faire 64 hex.","La clave pública debe tener 64 hex.","La chiave pubblica deve avere 64 hex.","Откр. ключ — 64 hex-символа.","Відкр. ключ — 64 hex-символи.","Публичният ключ трябва да е 64 hex.","Јавни кључ мора бити 64 hex.","Το δημόσιο κλειδί πρέπει 64 hex." ,"A chave pública deve ter 64 hex.","Cheia publică trebuie să aibă 64 caractere hex." } }, + { "RX log", { 0, "RX-logboek","RX-Protokoll","Journal RX","Registro RX","Log RX","Журнал RX","Журнал RX","RX дневник","RX дневник","Αρχείο RX" ,"Log RX","Jurnal RX" } }, + { "Raw log", { 0, "Ruwe log","Roh-Protokoll","Journal brut","Registro sin procesar","Log grezzo","Сырой журнал","Сирий журнал","Суров дневник","Сирови дневник","Ακατέργαστο αρχείο" ,"Log bruto","Jurnal brut" } }, + { "RangeTest failed", { 0, "RangeTest mislukt","RangeTest fehlgeschlagen","Échec du RangeTest","Error de RangeTest","RangeTest non riuscito","Сбой RangeTest","Помилка RangeTest","RangeTest неуспешен","RangeTest није успео","Αποτυχία RangeTest" ,"Falha no RangeTest","RangeTest eşuat" } }, + { "Telemetry req failed", { 0, "Telemetrie-verzoek mislukt","Telemetrie-Anfrage fehlgeschlagen","Échec de la requête télémétrie","Error de solicitud de telemetría","Richiesta telemetria non riuscita","Сбой запроса телеметрии","Помилка запиту телеметрії","Заявката за телеметрия неуспешна","Захтев за телеметрију није успео","Αποτυχία αιτήματος τηλεμετρίας" ,"Falha na req. de telemetria","Cerere telemetrie eşuată" } }, + { "Trace failed", { 0, "Tracering mislukt","Trace fehlgeschlagen","Échec du traçage","Error de rastreo","Tracciamento non riuscito","Сбой трассировки","Помилка трасування","Проследяването неуспешно","Праћење није успело","Αποτυχία ιχνηλάτησης" ,"Falha no rastreamento","Trasare eşuată" } }, + { "Tile reload needs Wi-Fi build", { 0, "Tegels herladen vereist wifi-build","Kachel-Neuladen braucht WLAN-Build","Le rechargement des tuiles requiert le build Wi-Fi","Recargar teselas requiere build Wi-Fi","La ricarica riquadri richiede build Wi-Fi","Перезагрузка тайлов требует Wi-Fi сборки","Перезавантаження плиток потребує Wi-Fi збірки","Презареждането изисква Wi-Fi компилация","Поновно учитавање захтева Wi-Fi билд","Η επαναφόρτωση χρειάζεται έκδοση Wi-Fi" ,"Recarregar tiles exige build com Wi-Fi","Reîncărcarea dalelor necesită build cu Wi-Fi" } }, + { "Too large to edit (>8 KB)", { 0, "Te groot om te bewerken (>8 KB)","Zu groß zum Bearbeiten (>8 KB)","Trop grand à modifier (>8 Ko)","Demasiado grande para editar (>8 KB)","Troppo grande da modificare (>8 KB)","Слишком велик для редактирования (>8 КБ)","Завеликий для редагування (>8 КБ)","Твърде голям за редактиране (>8 KB)","Превелико за уређивање (>8 KB)","Πολύ μεγάλο για επεξεργασία (>8 KB)" ,"Grande demais para editar (>8 KB)","Prea mare pentru editare (>8 KB)" } }, + { "Wi-Fi is not enabled in this build.", { 0, "Wi-Fi is niet ingeschakeld in deze build.","WLAN ist in diesem Build nicht aktiviert.","Le Wi-Fi n'est pas activé dans ce build.","Wi-Fi no está habilitado en este build.","Il Wi-Fi non è abilitato in questo build.","Wi-Fi не включён в этой сборке.","Wi-Fi не увімкнено в цій збірці.","Wi-Fi не е разрешен в тази компилация.","Wi-Fi није омогућен у овом билду.","Το Wi-Fi δεν είναι ενεργό σε αυτή την έκδοση." ,"O Wi-Fi não está ativado neste build.","Wi-Fi nu este activat în acest build." } }, + { "Wi-Fi isn't available on this build.", { 0, "Wi-Fi is niet beschikbaar in deze build.","WLAN ist in diesem Build nicht verfügbar.","Le Wi-Fi n'est pas disponible sur ce build.","Wi-Fi no está disponible en este build.","Il Wi-Fi non è disponibile su questo build.","Wi-Fi недоступен в этой сборке.","Wi-Fi недоступний у цій збірці.","Wi-Fi не е наличен в тази компилация.","Wi-Fi није доступан на овом билду.","Το Wi-Fi δεν είναι διαθέσιμο σε αυτή την έκδοση." ,"O Wi-Fi não está disponível neste build.","Wi-Fi nu este disponibil în acest build." } }, + { "Analyzing terrain…", { 0, "Terrein analyseren…","Gelände wird analysiert…","Analyse du terrain…","Analizando terreno…","Analisi del terreno…","Анализ рельефа…","Аналіз рельєфу…","Анализиране на терена…","Анализа терена…","Ανάλυση εδάφους…" ,"Analisando o terreno…","Se analizează terenul…" } }, + { "Exporting settings…", { 0, "Instellingen exporteren…","Einstellungen werden exportiert…","Exportation des réglages…","Exportando ajustes…","Esportazione impostazioni…","Экспорт настроек…","Експорт налаштувань…","Експортиране на настройки…","Извоз подешавања…","Εξαγωγή ρυθμίσεων…" ,"Exportando ajustes…","Se exportă setările…" } }, + { "Importing settings…", { 0, "Instellingen importeren…","Einstellungen werden importiert…","Importation des réglages…","Importando ajustes…","Importazione impostazioni…","Импорт настроек…","Імпорт налаштувань…","Импортиране на настройки…","Увоз подешавања…","Εισαγωγή ρυθμίσεων…" ,"Importando ajustes…","Se importă setările…" } }, + { "Trace sent…", { 0, "Tracering verzonden…","Trace gesendet…","Traçage envoyé…","Rastreo enviado…","Traccia inviata…","Трассировка отправлена…","Трасування надіслано…","Проследяването е изпратено…","Праћење послато…","Στάλθηκε ιχνηλάτηση…" ,"Rastreamento enviado…","Trasare trimisă…" } }, + { "Tracing route…", { 0, "Route traceren…","Route wird verfolgt…","Traçage de l'itinéraire…","Rastreando ruta…","Tracciamento percorso…","Трассировка маршрута…","Трасування маршруту…","Проследяване на маршрута…","Праћење руте…","Ιχνηλάτηση διαδρομής…" ,"Rastreando rota…","Se trasează ruta…" } }, + { "Rotating… rebooting", { 0, "Draaien… opnieuw opstarten","Drehen… Neustart","Rotation… redémarrage","Girando… reiniciando","Rotazione… riavvio","Поворот… перезагрузка","Обертання… перезавантаження","Завъртане… рестарт","Ротација… рестарт","Περιστροφή… επανεκκίνηση" ,"Girando… reiniciando","Se roteşte… repornire" } }, + { "Saved — rebooting", { 0, "Opgeslagen — opnieuw opstarten","Gespeichert — Neustart","Enregistré — redémarrage","Guardado — reiniciando","Salvato — riavvio","Сохранено — перезагрузка","Збережено — перезавантаження","Запазено — рестарт","Сачувано — рестарт","Αποθηκεύτηκε — επανεκκίνηση" ,"Salvo — reiniciando","Salvat — se reporneşte" } }, + { "Saved — reconnecting…", { 0, "Opgeslagen — opnieuw verbinden…","Gespeichert — verbinde neu…","Enregistré — reconnexion…","Guardado — reconectando…","Salvato — riconnessione…","Сохранено — переподключение…","Збережено — перепідключення…","Запазено — повторно свързване…","Сачувано — поновно повезивање…","Αποθηκεύτηκε — επανασύνδεση…" ,"Salvo — reconectando…","Salvat — se reconectează…" } }, + { "SSID set — enter password, then Save", { 0, "SSID ingesteld — voer wachtwoord in, dan Opslaan","SSID gesetzt — Passwort eingeben, dann Speichern","SSID défini — saisissez le mot de passe, puis Enregistrer","SSID establecido — escribe la contraseña y Guardar","SSID impostato — inserisci la password, poi Salva","SSID задан — введите пароль, затем Сохранить","SSID задано — введіть пароль, потім Зберегти","SSID зададен — въведете парола, после Запази","SSID постављен — унесите лозинку, па Сачувај","Ορίστηκε SSID — εισαγάγετε κωδικό, μετά Αποθήκευση" ,"SSID definido — digite a senha e Salvar","SSID setat — introdu parola, apoi Salvează" } }, + { "Powering off… click trackball to wake", { 0, "Uitschakelen… klik trackball om te wekken","Schaltet aus… Trackball klicken zum Aufwecken","Extinction… cliquez la boule pour réveiller","Apagando… pulsa el trackball para despertar","Spegnimento… clicca la trackball per riattivare","Выключение… нажмите трекбол для пробуждения","Вимкнення… натисніть трекбол для пробудження","Изключване… щракнете топчето за събуждане","Гашење… кликните куглицу за буђење","Απενεργοποίηση… κλικ στο trackball για αφύπνιση" ,"Desligando… clique o trackball para acordar","Se opreşte… apasă trackball-ul pentru trezire" } }, + { "Roll to highlight • click to insert", { 0, "Rol om te markeren • klik om in te voegen","Rollen zum Hervorheben • Klick zum Einfügen","Faites rouler pour surligner • cliquez pour insérer","Desplaza para resaltar • pulsa para insertar","Scorri per evidenziare • clicca per inserire","Прокрутите для выбора • нажмите для вставки","Прокрутіть для вибору • натисніть для вставки","Превъртете за маркиране • щракнете за вмъкване","Котрљајте за истицање • кликните за уметање","Κυλήστε για επισήμανση • κλικ για εισαγωγή" ,"Role para destacar • clique para inserir","Rulează pentru evidenţiere • apasă pentru inserare" } }, + { "mesh link armed", { 0, "mesh link actief","Mesh-Link scharf","liaison mesh armée","enlace mesh armado","link mesh armato","меш-канал готов","меш-зв'язок готовий","меш връзка готова","меш веза спремна","σύνδεσμος mesh ενεργός" ,"enlace mesh pronto","mesh link armat" } }, + { "n/a in this build", { 0, "n.v.t. in deze build","n. v. in diesem Build","n/d dans ce build","n/d en este build","n/d in questo build","н/д в этой сборке","н/д у цій збірці","н/п в тази компилация","н/д у овом билду","μ/δ σε αυτή την έκδοση" ,"n/d neste build","indisponibil în acest build" } }, + { "tap to exit full screen", { 0, "tik om volledig scherm te verlaten","tippen zum Verlassen des Vollbilds","toucher pour quitter le plein écran","toca para salir de pantalla completa","tocca per uscire da schermo intero","нажмите для выхода из полноэкранного режима","торкніться, щоб вийти з повноекранного режиму","докоснете за изход от цял екран","додирните за излаз из целог екрана","πατήστε για έξοδο από πλήρη οθόνη" ,"toque para sair da tela cheia","atinge pentru a ieşi din ecran complet" } }, + { "double-tap SPACE cycles through the ones you enable", { 0, "dubbeltik op SPATIE wisselt tussen de ingeschakelde","Doppeltipp auf LEER wechselt die aktivierten","double-tapez ESPACE pour parcourir les activés","doble toque en ESPACIO recorre los activados","doppio tocco su SPAZIO scorre quelli attivi","двойное нажатие ПРОБЕЛ переключает включённые","подвійне натискання ПРОБІЛ перемикає увімкнені","двойно докосване на ИНТЕРВАЛ превърта включените","двоструки додир РАЗМАК пролази кроз омогућене","διπλό πάτημα ΔΙΑΣΤΗΜΑ εναλλάσσει τα ενεργά" ,"toque duplo em ESPAÇO percorre os que você ativar","dublă atingere pe SPAŢIU comută între cele activate" } }, + { "pick accented letters as you type; off = plain typing", { 0, "kies letters met accenten tijdens het typen; uit = gewoon typen","Akzentbuchstaben beim Tippen wählen; aus = einfaches Tippen","choisir les lettres accentuées en tapant ; désactivé = saisie simple","elige letras acentuadas al escribir; desactivado = escritura simple","scegli lettere accentate mentre scrivi; off = scrittura semplice","выбор букв с акцентами при наборе; выкл = обычный набор","вибір літер з акцентами під час набору; вимк = звичайний набір","избор на букви с ударения при писане; изкл = обикновено писане","бирање слова са акцентима док куцате; искљ = обично куцање","επιλογή τονισμένων γραμμάτων κατά την πληκτρολόγηση· ανενεργό = απλή" ,"escolha letras acentuadas ao digitar; desligado = digitação simples","alege litere cu accente în timp ce tastezi; oprit = tastare simplă" } }, + { "tap the language key (e.g. EN) on the keyboard to cycle the ones you enable", { 0, "tik op de taaltoets (bijv. EN) op het toetsenbord om de ingeschakelde te doorlopen","Sprachtaste (z. B. EN) auf der Tastatur antippen, um die aktivierten zu wechseln","touchez la touche de langue (ex. EN) du clavier pour parcourir les activées","toca la tecla de idioma (p. ej. EN) del teclado para recorrer las activadas","tocca il tasto lingua (es. EN) sulla tastiera per scorrere quelle attive","нажмите клавишу языка (напр. EN) на клавиатуре для переключения включённых","натисніть клавішу мови (напр. EN) на клавіатурі для перемикання увімкнених","докоснете клавиша за език (напр. EN) на клавиатурата, за да превъртате включените","додирните тастер језика (нпр. EN) на тастатури да пролазите кроз омогућене","πατήστε το πλήκτρο γλώσσας (π.χ. EN) στο πληκτρολόγιο για εναλλαγή" ,"toque na tecla de idioma (ex. EN) no teclado para percorrer os que você ativar","atinge tasta de limbă (ex. EN) de pe tastatură pentru a comuta între cele activate" } }, + { "Sample text", { 0, "Voorbeeldtekst","Beispieltext","Exemple de texte","Texto de ejemplo","Testo di esempio","Пример текста","Зразок тексту","Примерен текст","Пример текста","Δείγμα κειμένου" ,"Texto de exemplo","Text de probă" } }, + { "Save & restart", { 0, "Opslaan & herstarten","Speichern & neu starten","Enregistrer & redémarrer","Guardar y reiniciar","Salva e riavvia","Сохранить и перезагрузить","Зберегти й перезавантажити","Запази и рестартирай","Сачувај и рестартуј","Αποθήκευση & επανεκκίνηση" ,"Salvar e reiniciar","Salvează şi reporneşte" } }, + { "Save / turn on", { 0, "Opslaan / inschakelen","Speichern / einschalten","Enregistrer / activer","Guardar / activar","Salva / attiva","Сохранить / включить","Зберегти / увімкнути","Запази / включи","Сачувај / укључи","Αποθήκευση / ενεργοποίηση" ,"Salvar / ligar","Salvează / porneşte" } }, + { "Save auto-add", { 0, "Auto-toevoegen opslaan","Auto-Hinzufügen speichern","Enregistrer l'ajout auto","Guardar auto-añadir","Salva auto-aggiunta","Сохранить автодобавление","Зберегти автододавання","Запази авто-добавянето","Сачувај аутододавање","Αποθήκευση αυτόματης προσθήκης" ,"Salvar auto-adição","Salvează auto-adăugarea" } }, + { "Save experimental", { 0, "Experimenteel opslaan","Experimentell speichern","Enregistrer l'expérimental","Guardar experimental","Salva sperimentale","Сохранить экспериментальное","Зберегти експериментальне","Запази експерименталното","Сачувај експериментално","Αποθήκευση πειραματικών" ,"Salvar experimental","Salvează experimentalele" } }, + { "Save quick replies", { 0, "Snelle antwoorden opslaan","Schnellantworten speichern","Enregistrer les réponses rapides","Guardar respuestas rápidas","Salva risposte rapide","Сохранить быстрые ответы","Зберегти швидкі відповіді","Запази бързите отговори","Сачувај брзе одговоре","Αποθήκευση γρήγορων απαντήσεων" ,"Salvar respostas rápidas","Salvează răspunsurile rapide" } }, // ---- Sweep pass 5 (final): long help / error dialogs ---- - { "32-hex secret. Anyone with this can read the channel.", { 0, "32-hex geheim. Iedereen hiermee kan het kanaal lezen.","32-Hex-Geheimnis. Wer es hat, kann den Kanal lesen.","Secret 32 hex. Quiconque l'a peut lire le canal.","Secreto de 32 hex. Cualquiera con esto puede leer el canal.","Segreto 32 hex. Chiunque lo abbia può leggere il canale.","Секрет 32 hex. Любой с ним может читать канал.","Секрет 32 hex. Будь-хто з ним може читати канал.","32-hex тайна. Всеки с нея може да чете канала.","32-hex тајна. Свако с њом може читати канал.","Μυστικό 32 hex. Όποιος το έχει διαβάζει το κανάλι." ,"Segredo de 32 hex. Qualquer um com isto lê o canal."} }, - { "Anyone can join. Key is derived from the hashtag (lowercase).", { 0, "Iedereen kan deelnemen. Sleutel komt van de hashtag (kleine letters).","Jeder kann beitreten. Schlüssel aus dem Hashtag (Kleinbuchstaben).","Tout le monde peut rejoindre. Clé dérivée du hashtag (minuscules).","Cualquiera puede unirse. La clave deriva del hashtag (minúsculas).","Chiunque può unirsi. La chiave deriva dall'hashtag (minuscolo).","Любой может присоединиться. Ключ из хэштега (нижний регистр).","Будь-хто може приєднатися. Ключ із хештегу (нижній регістр).","Всеки може да се присъедини. Ключът е от хаштага (малки букви).","Свако може да се придружи. Кључ из хаштага (мала слова).","Όλοι μπορούν να συμμετάσχουν. Κλειδί από το hashtag (πεζά)." ,"Qualquer um pode entrar. A chave vem da hashtag (minúsculas)."} }, - { "Enter the 32-hex secret shared by the channel creator.", { 0, "Voer het 32-hex geheim in dat de kanaalmaker deelt.","Geben Sie das 32-Hex-Geheimnis des Erstellers ein.","Saisissez le secret 32 hex partagé par le créateur du canal.","Introduce el secreto de 32 hex que comparte el creador del canal.","Inserisci il segreto 32 hex condiviso dal creatore del canale.","Введите секрет 32 hex, переданный создателем канала.","Введіть секрет 32 hex від творця каналу.","Въведете 32-hex тайната, споделена от създателя на канала.","Унесите 32-hex тајну коју је поделио творац канала.","Εισαγάγετε το μυστικό 32 hex του δημιουργού του καναλιού." ,"Digite o segredo de 32 hex compartilhado pelo criador do canal."} }, - { "Paste the 64-hex public key and a name. Useful when you want to DM someone before their advert has arrived.", { 0, "Plak de 64-hex publieke sleutel en een naam. Handig om iemand te DM'en voordat hun advert is aangekomen.","Fügen Sie den 64-Hex-Schlüssel und einen Namen ein. Nützlich, um jemandem zu schreiben, bevor sein Advert eintraf.","Collez la clé publique 64 hex et un nom. Utile pour envoyer un MP avant l'arrivée de l'annonce.","Pega la clave pública de 64 hex y un nombre. Útil para enviar un MD antes de que llegue su anuncio.","Incolla la chiave pubblica 64 hex e un nome. Utile per inviare un MD prima dell'advert.","Вставьте открытый ключ 64 hex и имя. Удобно написать до прихода advert.","Вставте відкритий ключ 64 hex та ім'я. Зручно написати до приходу advert.","Поставете 64-hex публичния ключ и име. Полезно за ЛС преди advert-а.","Налепите 64-hex јавни кључ и име. Корисно за ДП пре advert-а.","Επικολλήστε το δημόσιο κλειδί 64 hex και ένα όνομα. Χρήσιμο για ΠΜ πριν το advert." ,"Cole a chave pública de 64 hex e um nome. Útil para mandar DM a alguém antes do advert chegar."} }, - { "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", { 0, "Deel het 32-teken geheim zodat anderen kunnen deelnemen. Leeg laten voor een willekeurig geheim.","Teilen Sie das 32-Zeichen-Geheimnis, damit andere beitreten. Leer lassen für ein zufälliges.","Partagez le secret de 32 caractères pour que d'autres rejoignent. Laissez vide pour un aléatoire.","Comparte el secreto de 32 caracteres para que otros se unan. Déjalo vacío para uno aleatorio.","Condividi il segreto di 32 caratteri per far unire altri. Lascia vuoto per uno casuale.","Поделитесь секретом из 32 символов, чтобы другие присоединились. Пусто = случайный.","Поділіться секретом із 32 символів, щоб інші приєдналися. Порожньо = випадковий.","Споделете 32-символната тайна, за да се присъединят други. Празно = случайна.","Поделите тајну од 32 знака да се други придруже. Празно = насумична.","Μοιραστείτε το μυστικό 32 χαρακτήρων για να συμμετάσχουν άλλοι. Κενό = τυχαίο." ,"Compartilhe o segredo de 32 caracteres para outros entrarem. Deixe vazio para gerar um aleatório."} }, - { "Region scope (#tag) for this channel.\nBlank = use the default scope.", { 0, "Regiobereik (#tag) voor dit kanaal.\nLeeg = standaardbereik gebruiken.","Regionsbereich (#tag) für diesen Kanal.\nLeer = Standardbereich verwenden.","Portée régionale (#tag) pour ce canal.\nVide = portée par défaut.","Alcance de región (#tag) para este canal.\nVacío = alcance predeterminado.","Ambito regione (#tag) per questo canale.\nVuoto = ambito predefinito.","Регион (#tag) для этого канала.\nПусто = область по умолчанию.","Регіон (#tag) для цього каналу.\nПорожньо = область за умовчанням.","Регионален обхват (#tag) за този канал.\nПразно = обхват по подразбиране.","Опсег региона (#tag) за овај канал.\nПразно = подразумевани опсег.","Εύρος περιοχής (#tag) για αυτό το κανάλι.\nΚενό = προεπιλεγμένο εύρος." ,"Escopo de região (#tag) para este canal.\nVazio = usar o escopo padrão."} }, - { "Map storage error.\nReflash the tiles partition.", { 0, "Kaartopslagfout.\nFlash de tiles-partitie opnieuw.","Kartenspeicherfehler.\nTiles-Partition neu flashen.","Erreur de stockage carte.\nReflashez la partition des tuiles.","Error de almacenamiento del mapa.\nReinstala la partición de teselas.","Errore archiviazione mappa.\nRiflasha la partizione tiles.","Ошибка хранилища карты.\nПерепрошейте раздел tiles.","Помилка сховища карти.\nПерепрошийте розділ tiles.","Грешка в хранилището на картата.\nПрефлашнете дяла tiles.","Грешка складишта мапе.\nПоново флешујте tiles партицију.","Σφάλμα αποθήκευσης χάρτη.\nΞαναφλασάρετε το tiles partition." ,"Erro de armazenamento do mapa.\nRegrave a partição de tiles."} }, - { "Map storage error.\n\nSD card detected but the\ntile cache didn't mount.\nReboot to retry.", { 0, "Kaartopslagfout.\n\nSD-kaart gedetecteerd maar de\ntegelcache kon niet koppelen.\nHerstart om opnieuw te proberen.","Kartenspeicherfehler.\n\nSD-Karte erkannt, aber der\nKachel-Cache wurde nicht eingebunden.\nNeustart zum Wiederholen.","Erreur de stockage carte.\n\nCarte SD détectée mais le\ncache de tuiles n'a pas monté.\nRedémarrez pour réessayer.","Error de almacenamiento del mapa.\n\nTarjeta SD detectada pero la\ncaché de teselas no se montó.\nReinicia para reintentar.","Errore archiviazione mappa.\n\nScheda SD rilevata ma la\ncache riquadri non si è montata.\nRiavvia per riprovare.","Ошибка хранилища карты.\n\nSD-карта найдена, но кэш\nтайлов не смонтирован.\nПерезагрузите для повтора.","Помилка сховища карти.\n\nSD-картку знайдено, але кеш\nплиток не змонтовано.\nПерезавантажте для повтору.","Грешка в хранилището.\n\nSD картата е открита, но кешът\nна плочките не се монтира.\nРестартирайте за нов опит.","Грешка складишта мапе.\n\nSD картица откривена, али кеш\nплочица није монтиран.\nРестартујте за поновни покушај.","Σφάλμα αποθήκευσης χάρτη.\n\nΕντοπίστηκε κάρτα SD αλλά η\nμνήμη πλακιδίων δεν προσαρτήθηκε.\nΕπανεκκίνηση για νέα προσπάθεια." ,"Erro de armazenamento do mapa.\n\nCartão SD detectado mas o\ncache de tiles não montou.\nReinicie para tentar de novo."} }, - { "Map — set your location\nin Settings → Profile to\nshow the map here.", { 0, "Kaart — stel je locatie in\nbij Instellingen → Profiel om\nde kaart hier te tonen.","Karte — Standort in\nEinstellungen → Profil festlegen,\num die Karte hier zu zeigen.","Carte — définissez votre position\ndans Paramètres → Profil pour\nafficher la carte ici.","Mapa — establece tu ubicación\nen Ajustes → Perfil para\nmostrar el mapa aquí.","Mappa — imposta la posizione\nin Impostazioni → Profilo per\nmostrare la mappa qui.","Карта — задайте местоположение\nв Настройки → Профиль, чтобы\nпоказать карту здесь.","Карта — задайте місцезнаходження\nу Налаштування → Профіль, щоб\nпоказати карту тут.","Карта — задайте местоположението\nв Настройки → Профил, за да\nсе покаже картата тук.","Мапа — поставите локацију\nу Подешавања → Профил да\nприкажете мапу овде.","Χάρτης — ορίστε την τοποθεσία\nστις Ρυθμίσεις → Προφίλ για\nνα εμφανιστεί ο χάρτης εδώ." ,"Mapa — defina sua localização\nem Ajustes → Perfil para\nmostrar o mapa aqui."} }, - { "No .json backups found.\nExport one first, or copy a\nmeshcore-backup.json to the\nSD card or internal flash.", { 0, "Geen .json back-ups gevonden.\nExporteer er eerst een, of kopieer\neen meshcore-backup.json naar de\nSD-kaart of interne flash.","Keine .json-Backups gefunden.\nZuerst eins exportieren oder\nmeshcore-backup.json auf die\nSD-Karte oder den Flash kopieren.","Aucune sauvegarde .json trouvée.\nExportez-en une, ou copiez un\nmeshcore-backup.json sur la\ncarte SD ou le flash interne.","No se hallaron copias .json.\nExporta una, o copia un\nmeshcore-backup.json a la\ntarjeta SD o flash interna.","Nessun backup .json trovato.\nEsportane uno, o copia un\nmeshcore-backup.json sulla\nscheda SD o flash interna.","Резервные копии .json не найдены.\nЭкспортируйте одну или скопируйте\nmeshcore-backup.json на\nSD-карту или внутр. флеш.","Резервних копій .json не знайдено.\nЕкспортуйте одну або скопіюйте\nmeshcore-backup.json на\nSD-картку чи внутр. флеш.","Няма намерени .json архиви.\nЕкспортирайте един или копирайте\nmeshcore-backup.json на\nSD картата или вътр. флаш.","Нема .json резервних копија.\nИзвезите једну или копирајте\nmeshcore-backup.json на\nSD картицу или интерни флеш.","Δεν βρέθηκαν αντίγραφα .json.\nΕξαγάγετε ένα ή αντιγράψτε\nmeshcore-backup.json στην\nκάρτα SD ή στο εσωτ. flash." ,"Nenhum backup .json encontrado.\nExporte um primeiro, ou copie um\nmeshcore-backup.json para o\ncartão SD ou a flash interna."} }, - { "No JPEGs found in /lock/.\nAdd images via the file\nmanager or an SD card.", { 0, "Geen JPEG's gevonden in /lock/.\nVoeg afbeeldingen toe via de\nbestandsbeheerder of een SD-kaart.","Keine JPEGs in /lock/ gefunden.\nBilder über den Dateimanager\noder eine SD-Karte hinzufügen.","Aucun JPEG dans /lock/.\nAjoutez des images via le\ngestionnaire ou une carte SD.","No hay JPEG en /lock/.\nAñade imágenes con el gestor\nde archivos o una tarjeta SD.","Nessun JPEG in /lock/.\nAggiungi immagini dal gestore\nfile o da una scheda SD.","В /lock/ нет JPEG.\nДобавьте изображения через\nфайловый менеджер или SD-карту.","У /lock/ немає JPEG.\nДодайте зображення через\nфайловий менеджер або SD-картку.","Няма JPEG в /lock/.\nДобавете изображения чрез\nфайловия мениджър или SD карта.","Нема JPEG у /lock/.\nДодајте слике преко менаџера\nдатотека или SD картице.","Δεν βρέθηκαν JPEG στο /lock/.\nΠροσθέστε εικόνες μέσω του\nδιαχειριστή αρχείων ή κάρτας SD." ,"Nenhum JPEG em /lock/.\nAdicione imagens pelo gerenciador\nde arquivos ou um cartão SD."} }, - { "No blocked users.\n\nLong-press a message and tap\nBlock to add one.", { 0, "Geen geblokkeerde gebruikers.\n\nHoud een bericht ingedrukt en tik\nop Blokkeren om er een toe te voegen.","Keine blockierten Benutzer.\n\nNachricht lange drücken und auf\nBlockieren tippen, um einen hinzuzufügen.","Aucun utilisateur bloqué.\n\nAppui long sur un message puis\ntouchez Bloquer pour en ajouter un.","Sin usuarios bloqueados.\n\nMantén pulsado un mensaje y toca\nBloquear para añadir uno.","Nessun utente bloccato.\n\nTieni premuto un messaggio e tocca\nBlocca per aggiungerne uno.","Нет заблокированных.\n\nДолго нажмите сообщение и нажмите\nБлокировать, чтобы добавить.","Немає заблокованих.\n\nЗатисніть повідомлення й натисніть\nБлокувати, щоб додати.","Няма блокирани потребители.\n\nЗадръжте съобщение и докоснете\nБлокирай, за да добавите.","Нема блокираних корисника.\n\nДуго притисните поруку и додирните\nБлокирај да додате једног.","Κανένας αποκλεισμένος χρήστης.\n\nΠατήστε παρατεταμένα ένα μήνυμα και\nΑποκλεισμός για προσθήκη." ,"Nenhum usuário bloqueado.\n\nSegure uma mensagem e toque\nBloquear para adicionar."} }, - { "No contacts have shared\na GPS location yet.", { 0, "Nog geen contacten hebben\neen GPS-locatie gedeeld.","Noch keine Kontakte haben\neinen GPS-Standort geteilt.","Aucun contact n'a encore\npartagé de position GPS.","Ningún contacto ha compartido\nuna ubicación GPS aún.","Nessun contatto ha ancora\ncondiviso una posizione GPS.","Контакты ещё не делились\nGPS-местоположением.","Контакти ще не ділилися\nGPS-місцезнаходженням.","Все още няма контакти,\nсподелили GPS местоположение.","Још ниједан контакт није\nподелио GPS локацију.","Καμία επαφή δεν έχει\nμοιραστεί θέση GPS ακόμη." ,"Nenhum contato compartilhou\numa localização GPS ainda."} }, - { "No map storage.\n\nInsert an SD card to cache\nWi-Fi tiles (or reflash to\nrestore the tiles partition).", { 0, "Geen kaartopslag.\n\nPlaats een SD-kaart om wifi-tegels\nte cachen (of flash opnieuw om\nde tiles-partitie te herstellen).","Kein Kartenspeicher.\n\nSD-Karte einlegen, um WLAN-Kacheln\nzu cachen (oder neu flashen, um die\nTiles-Partition wiederherzustellen).","Pas de stockage carte.\n\nInsérez une carte SD pour les\ntuiles Wi-Fi (ou reflashez pour\nrestaurer la partition des tuiles).","Sin almacenamiento de mapa.\n\nInserta una tarjeta SD para teselas\nWi-Fi (o reinstala para restaurar\nla partición de teselas).","Nessuna archiviazione mappa.\n\nInserisci una scheda SD per i\nriquadri Wi-Fi (o riflasha per\nripristinare la partizione tiles).","Нет хранилища карты.\n\nВставьте SD-карту для кэша\nWi-Fi тайлов (или перепрошейте\nдля раздела tiles).","Немає сховища карти.\n\nВставте SD-картку для кешу\nWi-Fi плиток (або перепрошийте\nдля розділу tiles).","Няма хранилище за карта.\n\nПоставете SD карта за кеш на\nWi-Fi плочки (или префлашнете за\nдяла tiles).","Нема складишта мапе.\n\nУбаците SD картицу за кеш\nWi-Fi плочица (или флешујте за\ntiles партицију).","Χωρίς αποθήκευση χάρτη.\n\nΕισαγάγετε κάρτα SD για πλακίδια\nWi-Fi (ή ξαναφλασάρετε για\nτο tiles partition)." ,"Sem armazenamento de mapa.\n\nInsira um cartão SD para\nguardar tiles do Wi-Fi (ou regrave\npara restaurar a partição de tiles)."} }, - { "PNG isn't supported here\nUse a JPEG", { 0, "PNG wordt hier niet ondersteund\nGebruik een JPEG","PNG wird hier nicht unterstützt\nVerwenden Sie ein JPEG","PNG non pris en charge ici\nUtilisez un JPEG","PNG no se admite aquí\nUsa un JPEG","PNG non supportato qui\nUsa un JPEG","PNG здесь не поддерживается\nИспользуйте JPEG","PNG тут не підтримується\nВикористайте JPEG","PNG не се поддържа тук\nИзползвайте JPEG","PNG овде није подржан\nКористите JPEG","Το PNG δεν υποστηρίζεται εδώ\nΧρησιμοποιήστε JPEG" ,"PNG não é suportado aqui\nUse um JPEG"} }, - { "Still analyzing the previous\npath… try again in a moment.", { 0, "Vorige pad wordt nog\ngeanalyseerd… probeer zo opnieuw.","Vorheriger Pfad wird noch\nanalysiert… gleich erneut versuchen.","Analyse du chemin précédent\nen cours… réessayez bientôt.","Aún analizando la ruta\nanterior… reinténtalo en un momento.","Analisi del percorso precedente\nin corso… riprova tra poco.","Ещё анализируется предыдущий\nпуть… повторите чуть позже.","Ще аналізується попередній\nшлях… спробуйте за мить.","Все още се анализира предишният\nпът… опитайте пак след малко.","Још се анализира претходна\nпутања… покушајте ускоро.","Ακόμη αναλύεται η προηγούμενη\nδιαδρομή… δοκιμάστε σε λίγο." ,"Ainda analisando a rota\nanterior… tente de novo em instantes."} }, - { "This contact hasn't shared\na GPS position.", { 0, "Dit contact heeft geen\nGPS-positie gedeeld.","Dieser Kontakt hat keine\nGPS-Position geteilt.","Ce contact n'a pas partagé\nde position GPS.","Este contacto no ha compartido\nuna posición GPS.","Questo contatto non ha condiviso\nuna posizione GPS.","Этот контакт не делился\nGPS-позицией.","Цей контакт не ділився\nGPS-позицією.","Този контакт не е споделил\nGPS позиция.","Овај контакт није поделио\nGPS позицију.","Αυτή η επαφή δεν μοιράστηκε\nθέση GPS." ,"Este contato não compartilhou\numa posição GPS."} }, - { "Tip: you can scan for networks in Settings → Network after setup.", { 0, "Tip: je kunt netwerken scannen in Instellingen → Netwerk na de installatie.","Tipp: Nach der Einrichtung in Einstellungen → Netzwerk scannen.","Astuce : recherchez des réseaux dans Paramètres → Réseau après la configuration.","Consejo: puedes buscar redes en Ajustes → Red tras la configuración.","Suggerimento: cerca reti in Impostazioni → Rete dopo la configurazione.","Совет: сканировать сети можно в Настройки → Сеть после настройки.","Порада: шукати мережі можна в Налаштування → Мережа після налаштування.","Съвет: можете да сканирате мрежи в Настройки → Мрежа след настройката.","Савет: мреже можете скенирати у Подешавања → Мрежа након подешавања.","Συμβουλή: σαρώστε δίκτυα στις Ρυθμίσεις → Δίκτυο μετά τη ρύθμιση." ,"Dica: você pode buscar redes em Ajustes → Rede após a configuração."} }, - { "Type your network name — or switch to Wi-Fi in Settings to scan", { 0, "Typ je netwerknaam — of schakel naar wifi in Instellingen om te scannen","Netzwerknamen eingeben — oder in Einstellungen auf WLAN wechseln zum Scannen","Saisissez le nom du réseau — ou passez au Wi-Fi dans Paramètres pour scanner","Escribe el nombre de la red — o cambia a Wi-Fi en Ajustes para buscar","Digita il nome della rete — o passa al Wi-Fi in Impostazioni per scansionare","Введите имя сети — или включите Wi-Fi в Настройках для поиска","Введіть назву мережі — або увімкніть Wi-Fi у Налаштуваннях для пошуку","Въведете името на мрежата — или включете Wi-Fi в Настройки за сканиране","Унесите назив мреже — или пређите на Wi-Fi у Подешавањима за скенирање","Πληκτρολογήστε το όνομα δικτύου — ή στρέψτε σε Wi-Fi στις Ρυθμίσεις για σάρωση" ,"Digite o nome da sua rede — ou mude para Wi-Fi em Ajustes para buscar"} }, - { "Update manually at flasher.wadamesh.com\n(Wi-Fi update is paused for now)", { 0, "Werk handmatig bij op flasher.wadamesh.com\n(wifi-update is voorlopig gepauzeerd)","Manuell aktualisieren auf flasher.wadamesh.com\n(WLAN-Update ist vorerst pausiert)","Mettez à jour sur flasher.wadamesh.com\n(la MAJ Wi-Fi est en pause pour l'instant)","Actualiza en flasher.wadamesh.com\n(la actualización Wi-Fi está en pausa)","Aggiorna su flasher.wadamesh.com\n(l'aggiornamento Wi-Fi è in pausa)","Обновите вручную на flasher.wadamesh.com\n(обновление по Wi-Fi пока приостановлено)","Оновіть вручну на flasher.wadamesh.com\n(оновлення через Wi-Fi поки призупинено)","Обновете ръчно на flasher.wadamesh.com\n(Wi-Fi обновяването е спряно засега)","Ажурирајте ручно на flasher.wadamesh.com\n(Wi-Fi ажурирање је за сада паузирано)","Ενημερώστε χειροκίνητα στο flasher.wadamesh.com\n(η ενημέρωση Wi-Fi είναι σε παύση)" ,"Atualize manualmente em flasher.wadamesh.com\n(a atualização por Wi-Fi está pausada por ora)"} }, - { "Wi-Fi needed to fetch the\nterrain profile for this path.\nConnect in Settings → Wi-Fi.", { 0, "Wifi nodig om het terreinprofiel\nvoor dit pad op te halen.\nVerbind in Instellingen → Wi-Fi.","WLAN nötig, um das Geländeprofil\nfür diesen Pfad zu laden.\nVerbinden in Einstellungen → Wi-Fi.","Wi-Fi requis pour le profil\nde terrain de ce chemin.\nConnectez dans Paramètres → Wi-Fi.","Wi-Fi necesario para el perfil\ndel terreno de esta ruta.\nConéctate en Ajustes → Wi-Fi.","Wi-Fi necessario per il profilo\ndel terreno di questo percorso.\nConnetti in Impostazioni → Wi-Fi.","Нужен Wi-Fi для профиля\nрельефа этого пути.\nПодключите в Настройки → Wi-Fi.","Потрібен Wi-Fi для профілю\nрельєфу цього шляху.\nПідключіть у Налаштування → Wi-Fi.","Нужен е Wi-Fi за профила на\nтерена за този път.\nСвържете в Настройки → Wi-Fi.","Потребан Wi-Fi за профил\nтерена ове путање.\nПовежите у Подешавања → Wi-Fi.","Απαιτείται Wi-Fi για το προφίλ\nεδάφους αυτής της διαδρομής.\nΣυνδεθείτε στις Ρυθμίσεις → Wi-Fi." ,"É preciso Wi-Fi para obter o perfil\ndo terreno desta rota.\nConecte em Ajustes → Wi-Fi."} }, - { "You can run setup later in Settings → Device", { 0, "Je kunt de installatie later uitvoeren in Instellingen → Apparaat","Sie können die Einrichtung später in Einstellungen → Gerät ausführen","Vous pouvez lancer la configuration plus tard dans Paramètres → Appareil","Puedes ejecutar la configuración luego en Ajustes → Dispositivo","Puoi eseguire la configurazione dopo in Impostazioni → Dispositivo","Настройку можно запустить позже в Настройки → Устройство","Налаштування можна запустити пізніше в Налаштування → Пристрій","Можете да стартирате настройката по-късно в Настройки → Устройство","Подешавање можете покренути касније у Подешавања → Уређај","Μπορείτε να εκτελέσετε τη ρύθμιση αργότερα στις Ρυθμίσεις → Συσκευή" ,"Você pode rodar a configuração depois em Ajustes → Aparelho"} }, - { "Your location is unknown.\nSet GPS / position in\nSettings → Profile first.", { 0, "Je locatie is onbekend.\nStel GPS / positie eerst in bij\nInstellingen → Profiel.","Ihr Standort ist unbekannt.\nGPS / Position zuerst in\nEinstellungen → Profil festlegen.","Votre position est inconnue.\nDéfinissez GPS / position dans\nParamètres → Profil d'abord.","Tu ubicación es desconocida.\nEstablece GPS / posición en\nAjustes → Perfil primero.","Posizione sconosciuta.\nImposta prima GPS / posizione in\nImpostazioni → Profilo.","Местоположение неизвестно.\nСначала задайте GPS / позицию в\nНастройки → Профиль.","Місцезнаходження невідоме.\nСпочатку задайте GPS / позицію в\nНалаштування → Профіль.","Местоположението е неизвестно.\nПърво задайте GPS / позиция в\nНастройки → Профил.","Локација је непозната.\nПрво поставите GPS / позицију у\nПодешавања → Профил.","Η τοποθεσία σας είναι άγνωστη.\nΟρίστε GPS / θέση στις\nΡυθμίσεις → Προφίλ πρώτα." ,"Sua localização é desconhecida.\nDefina GPS / posição em\nAjustes → Perfil primeiro."} }, - { "Can't display image\n(JPEG only, <= 1024 px)", { 0, "Kan afbeelding niet tonen\n(alleen JPEG, <= 1024 px)","Bild kann nicht angezeigt werden\n(nur JPEG, <= 1024 px)","Impossible d'afficher l'image\n(JPEG uniquement, <= 1024 px)","No se puede mostrar la imagen\n(solo JPEG, <= 1024 px)","Impossibile mostrare l'immagine\n(solo JPEG, <= 1024 px)","Не удаётся показать изображение\n(только JPEG, <= 1024 пикс)","Не вдалося показати зображення\n(лише JPEG, <= 1024 пікс)","Изображението не може да се покаже\n(само JPEG, <= 1024 px)","Не могу приказати слику\n(само JPEG, <= 1024 px)","Αδυναμία εμφάνισης εικόνας\n(μόνο JPEG, <= 1024 px)" ,"Não dá para exibir a imagem\n(só JPEG, <= 1024 px)"} }, - { "Couldn't start the analyzer.\nTry again.", { 0, "Kon de analyser niet starten.\nProbeer opnieuw.","Analyzer konnte nicht starten.\nErneut versuchen.","Impossible de démarrer l'analyseur.\nRéessayez.","No se pudo iniciar el analizador.\nInténtalo de nuevo.","Impossibile avviare l'analizzatore.\nRiprova.","Не удалось запустить анализатор.\nПопробуйте снова.","Не вдалося запустити аналізатор.\nСпробуйте знову.","Анализаторът не можа да стартира.\nОпитайте пак.","Анализатор није могао да се покрене.\nПокушајте поново.","Αδυναμία εκκίνησης αναλυτή.\nΔοκιμάστε ξανά." ,"Não foi possível iniciar o analisador.\nTente de novo."} }, + { "32-hex secret. Anyone with this can read the channel.", { 0, "32-hex geheim. Iedereen hiermee kan het kanaal lezen.","32-Hex-Geheimnis. Wer es hat, kann den Kanal lesen.","Secret 32 hex. Quiconque l'a peut lire le canal.","Secreto de 32 hex. Cualquiera con esto puede leer el canal.","Segreto 32 hex. Chiunque lo abbia può leggere il canale.","Секрет 32 hex. Любой с ним может читать канал.","Секрет 32 hex. Будь-хто з ним може читати канал.","32-hex тайна. Всеки с нея може да чете канала.","32-hex тајна. Свако с њом може читати канал.","Μυστικό 32 hex. Όποιος το έχει διαβάζει το κανάλι." ,"Segredo de 32 hex. Qualquer um com isto lê o canal.","Secret de 32 hex. Oricine îl are poate citi canalul." } }, + { "Anyone can join. Key is derived from the hashtag (lowercase).", { 0, "Iedereen kan deelnemen. Sleutel komt van de hashtag (kleine letters).","Jeder kann beitreten. Schlüssel aus dem Hashtag (Kleinbuchstaben).","Tout le monde peut rejoindre. Clé dérivée du hashtag (minuscules).","Cualquiera puede unirse. La clave deriva del hashtag (minúsculas).","Chiunque può unirsi. La chiave deriva dall'hashtag (minuscolo).","Любой может присоединиться. Ключ из хэштега (нижний регистр).","Будь-хто може приєднатися. Ключ із хештегу (нижній регістр).","Всеки може да се присъедини. Ключът е от хаштага (малки букви).","Свако може да се придружи. Кључ из хаштага (мала слова).","Όλοι μπορούν να συμμετάσχουν. Κλειδί από το hashtag (πεζά)." ,"Qualquer um pode entrar. A chave vem da hashtag (minúsculas).","Oricine poate intra. Cheia derivă din hashtag (litere mici)." } }, + { "Enter the 32-hex secret shared by the channel creator.", { 0, "Voer het 32-hex geheim in dat de kanaalmaker deelt.","Geben Sie das 32-Hex-Geheimnis des Erstellers ein.","Saisissez le secret 32 hex partagé par le créateur du canal.","Introduce el secreto de 32 hex que comparte el creador del canal.","Inserisci il segreto 32 hex condiviso dal creatore del canale.","Введите секрет 32 hex, переданный создателем канала.","Введіть секрет 32 hex від творця каналу.","Въведете 32-hex тайната, споделена от създателя на канала.","Унесите 32-hex тајну коју је поделио творац канала.","Εισαγάγετε το μυστικό 32 hex του δημιουργού του καναλιού." ,"Digite o segredo de 32 hex compartilhado pelo criador do canal.","Introdu secretul de 32 hex partajat de creatorul canalului." } }, + { "Paste the 64-hex public key and a name. Useful when you want to DM someone before their advert has arrived.", { 0, "Plak de 64-hex publieke sleutel en een naam. Handig om iemand te DM'en voordat hun advert is aangekomen.","Fügen Sie den 64-Hex-Schlüssel und einen Namen ein. Nützlich, um jemandem zu schreiben, bevor sein Advert eintraf.","Collez la clé publique 64 hex et un nom. Utile pour envoyer un MP avant l'arrivée de l'annonce.","Pega la clave pública de 64 hex y un nombre. Útil para enviar un MD antes de que llegue su anuncio.","Incolla la chiave pubblica 64 hex e un nome. Utile per inviare un MD prima dell'advert.","Вставьте открытый ключ 64 hex и имя. Удобно написать до прихода advert.","Вставте відкритий ключ 64 hex та ім'я. Зручно написати до приходу advert.","Поставете 64-hex публичния ключ и име. Полезно за ЛС преди advert-а.","Налепите 64-hex јавни кључ и име. Корисно за ДП пре advert-а.","Επικολλήστε το δημόσιο κλειδί 64 hex και ένα όνομα. Χρήσιμο για ΠΜ πριν το advert." ,"Cole a chave pública de 64 hex e um nome. Útil para mandar DM a alguém antes do advert chegar.","Lipeşte cheia publică de 64 hex şi un nume. Util când vrei să trimiţi un DM cuiva înainte să-i sosească advertul." } }, + { "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", { 0, "Deel het 32-teken geheim zodat anderen kunnen deelnemen. Leeg laten voor een willekeurig geheim.","Teilen Sie das 32-Zeichen-Geheimnis, damit andere beitreten. Leer lassen für ein zufälliges.","Partagez le secret de 32 caractères pour que d'autres rejoignent. Laissez vide pour un aléatoire.","Comparte el secreto de 32 caracteres para que otros se unan. Déjalo vacío para uno aleatorio.","Condividi il segreto di 32 caratteri per far unire altri. Lascia vuoto per uno casuale.","Поделитесь секретом из 32 символов, чтобы другие присоединились. Пусто = случайный.","Поділіться секретом із 32 символів, щоб інші приєдналися. Порожньо = випадковий.","Споделете 32-символната тайна, за да се присъединят други. Празно = случайна.","Поделите тајну од 32 знака да се други придруже. Празно = насумична.","Μοιραστείτε το μυστικό 32 χαρακτήρων για να συμμετάσχουν άλλοι. Κενό = τυχαίο." ,"Compartilhe o segredo de 32 caracteres para outros entrarem. Deixe vazio para gerar um aleatório.","Partajează secretul de 32 caractere ca să poată intra şi alţii. Lasă secretul gol pentru a genera unul aleatoriu." } }, + { "Region scope (#tag) for this channel.\nBlank = use the default scope.", { 0, "Regiobereik (#tag) voor dit kanaal.\nLeeg = standaardbereik gebruiken.","Regionsbereich (#tag) für diesen Kanal.\nLeer = Standardbereich verwenden.","Portée régionale (#tag) pour ce canal.\nVide = portée par défaut.","Alcance de región (#tag) para este canal.\nVacío = alcance predeterminado.","Ambito regione (#tag) per questo canale.\nVuoto = ambito predefinito.","Регион (#tag) для этого канала.\nПусто = область по умолчанию.","Регіон (#tag) для цього каналу.\nПорожньо = область за умовчанням.","Регионален обхват (#tag) за този канал.\nПразно = обхват по подразбиране.","Опсег региона (#tag) за овај канал.\nПразно = подразумевани опсег.","Εύρος περιοχής (#tag) για αυτό το κανάλι.\nΚενό = προεπιλεγμένο εύρος." ,"Escopo de região (#tag) para este canal.\nVazio = usar o escopo padrão.","Domeniu regional (#tag) pentru acest canal.\nGol = domeniul implicit." } }, + { "Map storage error.\nReflash the tiles partition.", { 0, "Kaartopslagfout.\nFlash de tiles-partitie opnieuw.","Kartenspeicherfehler.\nTiles-Partition neu flashen.","Erreur de stockage carte.\nReflashez la partition des tuiles.","Error de almacenamiento del mapa.\nReinstala la partición de teselas.","Errore archiviazione mappa.\nRiflasha la partizione tiles.","Ошибка хранилища карты.\nПерепрошейте раздел tiles.","Помилка сховища карти.\nПерепрошийте розділ tiles.","Грешка в хранилището на картата.\nПрефлашнете дяла tiles.","Грешка складишта мапе.\nПоново флешујте tiles партицију.","Σφάλμα αποθήκευσης χάρτη.\nΞαναφλασάρετε το tiles partition." ,"Erro de armazenamento do mapa.\nRegrave a partição de tiles.","Eroare stocare hartă.\nRescrie partiţia de dale." } }, + { "Map storage error.\n\nSD card detected but the\ntile cache didn't mount.\nReboot to retry.", { 0, "Kaartopslagfout.\n\nSD-kaart gedetecteerd maar de\ntegelcache kon niet koppelen.\nHerstart om opnieuw te proberen.","Kartenspeicherfehler.\n\nSD-Karte erkannt, aber der\nKachel-Cache wurde nicht eingebunden.\nNeustart zum Wiederholen.","Erreur de stockage carte.\n\nCarte SD détectée mais le\ncache de tuiles n'a pas monté.\nRedémarrez pour réessayer.","Error de almacenamiento del mapa.\n\nTarjeta SD detectada pero la\ncaché de teselas no se montó.\nReinicia para reintentar.","Errore archiviazione mappa.\n\nScheda SD rilevata ma la\ncache riquadri non si è montata.\nRiavvia per riprovare.","Ошибка хранилища карты.\n\nSD-карта найдена, но кэш\nтайлов не смонтирован.\nПерезагрузите для повтора.","Помилка сховища карти.\n\nSD-картку знайдено, але кеш\nплиток не змонтовано.\nПерезавантажте для повтору.","Грешка в хранилището.\n\nSD картата е открита, но кешът\nна плочките не се монтира.\nРестартирайте за нов опит.","Грешка складишта мапе.\n\nSD картица откривена, али кеш\nплочица није монтиран.\nРестартујте за поновни покушај.","Σφάλμα αποθήκευσης χάρτη.\n\nΕντοπίστηκε κάρτα SD αλλά η\nμνήμη πλακιδίων δεν προσαρτήθηκε.\nΕπανεκκίνηση για νέα προσπάθεια." ,"Erro de armazenamento do mapa.\n\nCartão SD detectado mas o\ncache de tiles não montou.\nReinicie para tentar de novo.","Eroare stocare hartă.\n\nCard SD detectat, dar\ncache-ul de dale nu s-a montat.\nReporneşte pentru reîncercare." } }, + { "Map — set your location\nin Settings → Profile to\nshow the map here.", { 0, "Kaart — stel je locatie in\nbij Instellingen → Profiel om\nde kaart hier te tonen.","Karte — Standort in\nEinstellungen → Profil festlegen,\num die Karte hier zu zeigen.","Carte — définissez votre position\ndans Paramètres → Profil pour\nafficher la carte ici.","Mapa — establece tu ubicación\nen Ajustes → Perfil para\nmostrar el mapa aquí.","Mappa — imposta la posizione\nin Impostazioni → Profilo per\nmostrare la mappa qui.","Карта — задайте местоположение\nв Настройки → Профиль, чтобы\nпоказать карту здесь.","Карта — задайте місцезнаходження\nу Налаштування → Профіль, щоб\nпоказати карту тут.","Карта — задайте местоположението\nв Настройки → Профил, за да\nсе покаже картата тук.","Мапа — поставите локацију\nу Подешавања → Профил да\nприкажете мапу овде.","Χάρτης — ορίστε την τοποθεσία\nστις Ρυθμίσεις → Προφίλ για\nνα εμφανιστεί ο χάρτης εδώ." ,"Mapa — defina sua localização\nem Ajustes → Perfil para\nmostrar o mapa aqui.","Hartă — setează-ţi locaţia\nîn Setări → Profil pentru\na afişa harta aici." } }, + { "No .json backups found.\nExport one first, or copy a\nmeshcore-backup.json to the\nSD card or internal flash.", { 0, "Geen .json back-ups gevonden.\nExporteer er eerst een, of kopieer\neen meshcore-backup.json naar de\nSD-kaart of interne flash.","Keine .json-Backups gefunden.\nZuerst eins exportieren oder\nmeshcore-backup.json auf die\nSD-Karte oder den Flash kopieren.","Aucune sauvegarde .json trouvée.\nExportez-en une, ou copiez un\nmeshcore-backup.json sur la\ncarte SD ou le flash interne.","No se hallaron copias .json.\nExporta una, o copia un\nmeshcore-backup.json a la\ntarjeta SD o flash interna.","Nessun backup .json trovato.\nEsportane uno, o copia un\nmeshcore-backup.json sulla\nscheda SD o flash interna.","Резервные копии .json не найдены.\nЭкспортируйте одну или скопируйте\nmeshcore-backup.json на\nSD-карту или внутр. флеш.","Резервних копій .json не знайдено.\nЕкспортуйте одну або скопіюйте\nmeshcore-backup.json на\nSD-картку чи внутр. флеш.","Няма намерени .json архиви.\nЕкспортирайте един или копирайте\nmeshcore-backup.json на\nSD картата или вътр. флаш.","Нема .json резервних копија.\nИзвезите једну или копирајте\nmeshcore-backup.json на\nSD картицу или интерни флеш.","Δεν βρέθηκαν αντίγραφα .json.\nΕξαγάγετε ένα ή αντιγράψτε\nmeshcore-backup.json στην\nκάρτα SD ή στο εσωτ. flash." ,"Nenhum backup .json encontrado.\nExporte um primeiro, ou copie um\nmeshcore-backup.json para o\ncartão SD ou a flash interna.","Niciun backup .json găsit.\nExportă unul mai întâi sau copiază\nun meshcore-backup.json pe cardul\nSD ori în flash-ul intern." } }, + { "No JPEGs found in /lock/.\nAdd images via the file\nmanager or an SD card.", { 0, "Geen JPEG's gevonden in /lock/.\nVoeg afbeeldingen toe via de\nbestandsbeheerder of een SD-kaart.","Keine JPEGs in /lock/ gefunden.\nBilder über den Dateimanager\noder eine SD-Karte hinzufügen.","Aucun JPEG dans /lock/.\nAjoutez des images via le\ngestionnaire ou une carte SD.","No hay JPEG en /lock/.\nAñade imágenes con el gestor\nde archivos o una tarjeta SD.","Nessun JPEG in /lock/.\nAggiungi immagini dal gestore\nfile o da una scheda SD.","В /lock/ нет JPEG.\nДобавьте изображения через\nфайловый менеджер или SD-карту.","У /lock/ немає JPEG.\nДодайте зображення через\nфайловий менеджер або SD-картку.","Няма JPEG в /lock/.\nДобавете изображения чрез\nфайловия мениджър или SD карта.","Нема JPEG у /lock/.\nДодајте слике преко менаџера\nдатотека или SD картице.","Δεν βρέθηκαν JPEG στο /lock/.\nΠροσθέστε εικόνες μέσω του\nδιαχειριστή αρχείων ή κάρτας SD." ,"Nenhum JPEG em /lock/.\nAdicione imagens pelo gerenciador\nde arquivos ou um cartão SD.","Niciun JPEG găsit în /lock/.\nAdaugă imagini prin managerul\nde fişiere sau un card SD." } }, + { "No blocked users.\n\nLong-press a message and tap\nBlock to add one.", { 0, "Geen geblokkeerde gebruikers.\n\nHoud een bericht ingedrukt en tik\nop Blokkeren om er een toe te voegen.","Keine blockierten Benutzer.\n\nNachricht lange drücken und auf\nBlockieren tippen, um einen hinzuzufügen.","Aucun utilisateur bloqué.\n\nAppui long sur un message puis\ntouchez Bloquer pour en ajouter un.","Sin usuarios bloqueados.\n\nMantén pulsado un mensaje y toca\nBloquear para añadir uno.","Nessun utente bloccato.\n\nTieni premuto un messaggio e tocca\nBlocca per aggiungerne uno.","Нет заблокированных.\n\nДолго нажмите сообщение и нажмите\nБлокировать, чтобы добавить.","Немає заблокованих.\n\nЗатисніть повідомлення й натисніть\nБлокувати, щоб додати.","Няма блокирани потребители.\n\nЗадръжте съобщение и докоснете\nБлокирай, за да добавите.","Нема блокираних корисника.\n\nДуго притисните поруку и додирните\nБлокирај да додате једног.","Κανένας αποκλεισμένος χρήστης.\n\nΠατήστε παρατεταμένα ένα μήνυμα και\nΑποκλεισμός για προσθήκη." ,"Nenhum usuário bloqueado.\n\nSegure uma mensagem e toque\nBloquear para adicionar.","Niciun utilizator blocat.\n\nApasă lung pe un mesaj şi atinge\nBlochează pentru a adăuga unul." } }, + { "No contacts have shared\na GPS location yet.", { 0, "Nog geen contacten hebben\neen GPS-locatie gedeeld.","Noch keine Kontakte haben\neinen GPS-Standort geteilt.","Aucun contact n'a encore\npartagé de position GPS.","Ningún contacto ha compartido\nuna ubicación GPS aún.","Nessun contatto ha ancora\ncondiviso una posizione GPS.","Контакты ещё не делились\nGPS-местоположением.","Контакти ще не ділилися\nGPS-місцезнаходженням.","Все още няма контакти,\nсподелили GPS местоположение.","Још ниједан контакт није\nподелио GPS локацију.","Καμία επαφή δεν έχει\nμοιραστεί θέση GPS ακόμη." ,"Nenhum contato compartilhou\numa localização GPS ainda.","Niciun contact nu a partajat\nîncă o locaţie GPS." } }, + { "No map storage.\n\nInsert an SD card to cache\nWi-Fi tiles (or reflash to\nrestore the tiles partition).", { 0, "Geen kaartopslag.\n\nPlaats een SD-kaart om wifi-tegels\nte cachen (of flash opnieuw om\nde tiles-partitie te herstellen).","Kein Kartenspeicher.\n\nSD-Karte einlegen, um WLAN-Kacheln\nzu cachen (oder neu flashen, um die\nTiles-Partition wiederherzustellen).","Pas de stockage carte.\n\nInsérez une carte SD pour les\ntuiles Wi-Fi (ou reflashez pour\nrestaurer la partition des tuiles).","Sin almacenamiento de mapa.\n\nInserta una tarjeta SD para teselas\nWi-Fi (o reinstala para restaurar\nla partición de teselas).","Nessuna archiviazione mappa.\n\nInserisci una scheda SD per i\nriquadri Wi-Fi (o riflasha per\nripristinare la partizione tiles).","Нет хранилища карты.\n\nВставьте SD-карту для кэша\nWi-Fi тайлов (или перепрошейте\nдля раздела tiles).","Немає сховища карти.\n\nВставте SD-картку для кешу\nWi-Fi плиток (або перепрошийте\nдля розділу tiles).","Няма хранилище за карта.\n\nПоставете SD карта за кеш на\nWi-Fi плочки (или префлашнете за\nдяла tiles).","Нема складишта мапе.\n\nУбаците SD картицу за кеш\nWi-Fi плочица (или флешујте за\ntiles партицију).","Χωρίς αποθήκευση χάρτη.\n\nΕισαγάγετε κάρτα SD για πλακίδια\nWi-Fi (ή ξαναφλασάρετε για\nτο tiles partition)." ,"Sem armazenamento de mapa.\n\nInsira um cartão SD para\nguardar tiles do Wi-Fi (ou regrave\npara restaurar a partição de tiles).","Fără stocare pentru hartă.\n\nIntrodu un card SD pentru dalele\nWi-Fi (sau rescrie partiţia\nde dale ca s-o refaci)." } }, + { "PNG isn't supported here\nUse a JPEG", { 0, "PNG wordt hier niet ondersteund\nGebruik een JPEG","PNG wird hier nicht unterstützt\nVerwenden Sie ein JPEG","PNG non pris en charge ici\nUtilisez un JPEG","PNG no se admite aquí\nUsa un JPEG","PNG non supportato qui\nUsa un JPEG","PNG здесь не поддерживается\nИспользуйте JPEG","PNG тут не підтримується\nВикористайте JPEG","PNG не се поддържа тук\nИзползвайте JPEG","PNG овде није подржан\nКористите JPEG","Το PNG δεν υποστηρίζεται εδώ\nΧρησιμοποιήστε JPEG" ,"PNG não é suportado aqui\nUse um JPEG","PNG nu este acceptat aici\nFoloseşte un JPEG" } }, + { "Still analyzing the previous\npath… try again in a moment.", { 0, "Vorige pad wordt nog\ngeanalyseerd… probeer zo opnieuw.","Vorheriger Pfad wird noch\nanalysiert… gleich erneut versuchen.","Analyse du chemin précédent\nen cours… réessayez bientôt.","Aún analizando la ruta\nanterior… reinténtalo en un momento.","Analisi del percorso precedente\nin corso… riprova tra poco.","Ещё анализируется предыдущий\nпуть… повторите чуть позже.","Ще аналізується попередній\nшлях… спробуйте за мить.","Все още се анализира предишният\nпът… опитайте пак след малко.","Још се анализира претходна\nпутања… покушајте ускоро.","Ακόμη αναλύεται η προηγούμενη\nδιαδρομή… δοκιμάστε σε λίγο." ,"Ainda analisando a rota\nanterior… tente de novo em instantes.","Încă se analizează calea\nanterioară… reîncearcă imediat." } }, + { "This contact hasn't shared\na GPS position.", { 0, "Dit contact heeft geen\nGPS-positie gedeeld.","Dieser Kontakt hat keine\nGPS-Position geteilt.","Ce contact n'a pas partagé\nde position GPS.","Este contacto no ha compartido\nuna posición GPS.","Questo contatto non ha condiviso\nuna posizione GPS.","Этот контакт не делился\nGPS-позицией.","Цей контакт не ділився\nGPS-позицією.","Този контакт не е споделил\nGPS позиция.","Овај контакт није поделио\nGPS позицију.","Αυτή η επαφή δεν μοιράστηκε\nθέση GPS." ,"Este contato não compartilhou\numa posição GPS.","Acest contact nu a partajat\no poziţie GPS." } }, + { "Tip: you can scan for networks in Settings → Network after setup.", { 0, "Tip: je kunt netwerken scannen in Instellingen → Netwerk na de installatie.","Tipp: Nach der Einrichtung in Einstellungen → Netzwerk scannen.","Astuce : recherchez des réseaux dans Paramètres → Réseau après la configuration.","Consejo: puedes buscar redes en Ajustes → Red tras la configuración.","Suggerimento: cerca reti in Impostazioni → Rete dopo la configurazione.","Совет: сканировать сети можно в Настройки → Сеть после настройки.","Порада: шукати мережі можна в Налаштування → Мережа після налаштування.","Съвет: можете да сканирате мрежи в Настройки → Мрежа след настройката.","Савет: мреже можете скенирати у Подешавања → Мрежа након подешавања.","Συμβουλή: σαρώστε δίκτυα στις Ρυθμίσεις → Δίκτυο μετά τη ρύθμιση." ,"Dica: você pode buscar redes em Ajustes → Rede após a configuração.","Sfat: poţi scana reţele în Setări → Reţea după configurare." } }, + { "Type your network name — or switch to Wi-Fi in Settings to scan", { 0, "Typ je netwerknaam — of schakel naar wifi in Instellingen om te scannen","Netzwerknamen eingeben — oder in Einstellungen auf WLAN wechseln zum Scannen","Saisissez le nom du réseau — ou passez au Wi-Fi dans Paramètres pour scanner","Escribe el nombre de la red — o cambia a Wi-Fi en Ajustes para buscar","Digita il nome della rete — o passa al Wi-Fi in Impostazioni per scansionare","Введите имя сети — или включите Wi-Fi в Настройках для поиска","Введіть назву мережі — або увімкніть Wi-Fi у Налаштуваннях для пошуку","Въведете името на мрежата — или включете Wi-Fi в Настройки за сканиране","Унесите назив мреже — или пређите на Wi-Fi у Подешавањима за скенирање","Πληκτρολογήστε το όνομα δικτύου — ή στρέψτε σε Wi-Fi στις Ρυθμίσεις για σάρωση" ,"Digite o nome da sua rede — ou mude para Wi-Fi em Ajustes para buscar","Tastează numele reţelei — sau treci la Wi-Fi în Setări pentru scanare" } }, + { "Update manually at flasher.wadamesh.com\n(Wi-Fi update is paused for now)", { 0, "Werk handmatig bij op flasher.wadamesh.com\n(wifi-update is voorlopig gepauzeerd)","Manuell aktualisieren auf flasher.wadamesh.com\n(WLAN-Update ist vorerst pausiert)","Mettez à jour sur flasher.wadamesh.com\n(la MAJ Wi-Fi est en pause pour l'instant)","Actualiza en flasher.wadamesh.com\n(la actualización Wi-Fi está en pausa)","Aggiorna su flasher.wadamesh.com\n(l'aggiornamento Wi-Fi è in pausa)","Обновите вручную на flasher.wadamesh.com\n(обновление по Wi-Fi пока приостановлено)","Оновіть вручну на flasher.wadamesh.com\n(оновлення через Wi-Fi поки призупинено)","Обновете ръчно на flasher.wadamesh.com\n(Wi-Fi обновяването е спряно засега)","Ажурирајте ручно на flasher.wadamesh.com\n(Wi-Fi ажурирање је за сада паузирано)","Ενημερώστε χειροκίνητα στο flasher.wadamesh.com\n(η ενημέρωση Wi-Fi είναι σε παύση)" ,"Atualize manualmente em flasher.wadamesh.com\n(a atualização por Wi-Fi está pausada por ora)","Actualizează manual la flasher.wadamesh.com\n(actualizarea Wi-Fi e momentan suspendată)" } }, + { "Wi-Fi needed to fetch the\nterrain profile for this path.\nConnect in Settings → Wi-Fi.", { 0, "Wifi nodig om het terreinprofiel\nvoor dit pad op te halen.\nVerbind in Instellingen → Wi-Fi.","WLAN nötig, um das Geländeprofil\nfür diesen Pfad zu laden.\nVerbinden in Einstellungen → Wi-Fi.","Wi-Fi requis pour le profil\nde terrain de ce chemin.\nConnectez dans Paramètres → Wi-Fi.","Wi-Fi necesario para el perfil\ndel terreno de esta ruta.\nConéctate en Ajustes → Wi-Fi.","Wi-Fi necessario per il profilo\ndel terreno di questo percorso.\nConnetti in Impostazioni → Wi-Fi.","Нужен Wi-Fi для профиля\nрельефа этого пути.\nПодключите в Настройки → Wi-Fi.","Потрібен Wi-Fi для профілю\nрельєфу цього шляху.\nПідключіть у Налаштування → Wi-Fi.","Нужен е Wi-Fi за профила на\nтерена за този път.\nСвържете в Настройки → Wi-Fi.","Потребан Wi-Fi за профил\nтерена ове путање.\nПовежите у Подешавања → Wi-Fi.","Απαιτείται Wi-Fi για το προφίλ\nεδάφους αυτής της διαδρομής.\nΣυνδεθείτε στις Ρυθμίσεις → Wi-Fi." ,"É preciso Wi-Fi para obter o perfil\ndo terreno desta rota.\nConecte em Ajustes → Wi-Fi.","E nevoie de Wi-Fi pentru profilul\nde teren al acestei căi.\nConectează-te în Setări → Wi-Fi." } }, + { "You can run setup later in Settings → Device", { 0, "Je kunt de installatie later uitvoeren in Instellingen → Apparaat","Sie können die Einrichtung später in Einstellungen → Gerät ausführen","Vous pouvez lancer la configuration plus tard dans Paramètres → Appareil","Puedes ejecutar la configuración luego en Ajustes → Dispositivo","Puoi eseguire la configurazione dopo in Impostazioni → Dispositivo","Настройку можно запустить позже в Настройки → Устройство","Налаштування можна запустити пізніше в Налаштування → Пристрій","Можете да стартирате настройката по-късно в Настройки → Устройство","Подешавање можете покренути касније у Подешавања → Уређај","Μπορείτε να εκτελέσετε τη ρύθμιση αργότερα στις Ρυθμίσεις → Συσκευή" ,"Você pode rodar a configuração depois em Ajustes → Aparelho","Poţi rula configurarea mai târziu în Setări → Dispozitiv" } }, + { "Your location is unknown.\nSet GPS / position in\nSettings → Profile first.", { 0, "Je locatie is onbekend.\nStel GPS / positie eerst in bij\nInstellingen → Profiel.","Ihr Standort ist unbekannt.\nGPS / Position zuerst in\nEinstellungen → Profil festlegen.","Votre position est inconnue.\nDéfinissez GPS / position dans\nParamètres → Profil d'abord.","Tu ubicación es desconocida.\nEstablece GPS / posición en\nAjustes → Perfil primero.","Posizione sconosciuta.\nImposta prima GPS / posizione in\nImpostazioni → Profilo.","Местоположение неизвестно.\nСначала задайте GPS / позицию в\nНастройки → Профиль.","Місцезнаходження невідоме.\nСпочатку задайте GPS / позицію в\nНалаштування → Профіль.","Местоположението е неизвестно.\nПърво задайте GPS / позиция в\nНастройки → Профил.","Локација је непозната.\nПрво поставите GPS / позицију у\nПодешавања → Профил.","Η τοποθεσία σας είναι άγνωστη.\nΟρίστε GPS / θέση στις\nΡυθμίσεις → Προφίλ πρώτα." ,"Sua localização é desconhecida.\nDefina GPS / posição em\nAjustes → Perfil primeiro.","Locaţia ta e necunoscută.\nSetează mai întâi GPS / poziţia\nîn Setări → Profil." } }, + { "Can't display image\n(JPEG only, <= 1024 px)", { 0, "Kan afbeelding niet tonen\n(alleen JPEG, <= 1024 px)","Bild kann nicht angezeigt werden\n(nur JPEG, <= 1024 px)","Impossible d'afficher l'image\n(JPEG uniquement, <= 1024 px)","No se puede mostrar la imagen\n(solo JPEG, <= 1024 px)","Impossibile mostrare l'immagine\n(solo JPEG, <= 1024 px)","Не удаётся показать изображение\n(только JPEG, <= 1024 пикс)","Не вдалося показати зображення\n(лише JPEG, <= 1024 пікс)","Изображението не може да се покаже\n(само JPEG, <= 1024 px)","Не могу приказати слику\n(само JPEG, <= 1024 px)","Αδυναμία εμφάνισης εικόνας\n(μόνο JPEG, <= 1024 px)" ,"Não dá para exibir a imagem\n(só JPEG, <= 1024 px)","Imaginea nu poate fi afişată\n(doar JPEG, <= 1024 px)" } }, + { "Couldn't start the analyzer.\nTry again.", { 0, "Kon de analyser niet starten.\nProbeer opnieuw.","Analyzer konnte nicht starten.\nErneut versuchen.","Impossible de démarrer l'analyseur.\nRéessayez.","No se pudo iniciar el analizador.\nInténtalo de nuevo.","Impossibile avviare l'analizzatore.\nRiprova.","Не удалось запустить анализатор.\nПопробуйте снова.","Не вдалося запустити аналізатор.\nСпробуйте знову.","Анализаторът не можа да стартира.\nОпитайте пак.","Анализатор није могао да се покрене.\nПокушајте поново.","Αδυναμία εκκίνησης αναλυτή.\nΔοκιμάστε ξανά." ,"Não foi possível iniciar o analisador.\nTente de novo.","Analizatorul nu a pornit.\nÎncearcă din nou." } }, // ---- Sweep pass 6: section headers, control labels, setup wizard, control center ---- - { "Auto-add contacts", { 0, "Contacten automatisch toevoegen","Kontakte automatisch hinzufügen","Ajout auto de contacts","Auto-añadir contactos","Aggiunta automatica contatti","Автодобавление контактов","Автододавання контактів","Автоматично добавяне на контакти","Аутоматско додавање контаката","Αυτόματη προσθήκη επαφών" ,"Auto-adicionar contatos"} }, - { "Experimental", { 0, "Experimenteel","Experimentell","Expérimental","Experimental","Sperimentale","Экспериментальное","Експериментальне","Експериментално","Експериментално","Πειραματικά" ,"Experimental"} }, - { "Discovered", { 0, "Ontdekt","Entdeckt","Découverts","Descubiertos","Scoperti","Обнаружено","Виявлено","Открити","Откривени","Ανακαλύφθηκαν" ,"Descobertos"} }, - { "Create private channel", { 0, "Privékanaal aanmaken","Privaten Kanal erstellen","Créer un canal privé","Crear canal privado","Crea canale privato","Создать приватный канал","Створити приватний канал","Създай частен канал","Направи приватни канал","Δημιουργία ιδιωτικού καναλιού" ,"Criar canal privado"} }, - { "Join hashtag channel", { 0, "Deelnemen aan hashtagkanaal","Hashtag-Kanal beitreten","Rejoindre un canal hashtag","Unirse a canal de hashtag","Unisciti a canale hashtag","Войти в канал хэштега","Приєднатися до каналу хештегу","Присъедини се към хаштаг канал","Придружи се хаштаг каналу","Συμμετοχή σε κανάλι hashtag" ,"Entrar em canal de hashtag"} }, - { "Join private channel", { 0, "Deelnemen aan privékanaal","Privaten Kanal beitreten","Rejoindre un canal privé","Unirse a canal privado","Unisciti a canale privato","Войти в приватный канал","Приєднатися до приватного каналу","Присъедини се към частен канал","Придружи се приватном каналу","Συμμετοχή σε ιδιωτικό κανάλι" ,"Entrar em canal privado"} }, - { "Send advert", { 0, "Advert verzenden","Advert senden","Envoyer une annonce","Enviar anuncio","Invia advert","Отправить advert","Надіслати advert","Изпрати advert","Пошаљи advert","Αποστολή advert" ,"Enviar advert"} }, - { "Share channel", { 0, "Kanaal delen","Kanal teilen","Partager le canal","Compartir canal","Condividi canale","Поделиться каналом","Поділитися каналом","Сподели канала","Подели канал","Κοινή χρήση καναλιού" ,"Compartilhar canal"} }, - { "Advert", { 0, "Advert","Advert","Annonce","Anuncio","Advert","Advert","Advert","Advert","Advert","Advert" ,"Advert"} }, - { "Advert location", { 0, "Advertlocatie","Advert-Standort","Position de l'annonce","Ubicación del anuncio","Posizione advert","Местоположение advert","Місцезнаходження advert","Местоположение на advert","Локација advert-а","Τοποθεσία advert" ,"Local do advert"} }, - { "Latitude", { 0, "Breedtegraad","Breitengrad","Latitude","Latitud","Latitudine","Широта","Широта","Географска ширина","Географска ширина","Γεωγραφικό πλάτος" ,"Latitude"} }, - { "Longitude", { 0, "Lengtegraad","Längengrad","Longitude","Longitud","Longitudine","Долгота","Довгота","Географска дължина","Географска дужина","Γεωγραφικό μήκος" ,"Longitude"} }, - { "Share location in advert", { 0, "Locatie delen in advert","Standort im Advert teilen","Partager la position dans l'annonce","Compartir ubicación en anuncio","Condividi posizione in advert","Делиться местоположением в advert","Ділитися місцезнаходженням в advert","Споделяй местоположението в advert","Дели локацију у advert-у","Κοινή χρήση θέσης στο advert" ,"Compartilhar local no advert"} }, - { "Node name", { 0, "Nodenaam","Node-Name","Nom du nœud","Nombre del nodo","Nome nodo","Имя узла","Назва вузла","Име на възела","Назив чвора","Όνομα κόμβου" ,"Nome do nó"} }, - { "Display name", { 0, "Weergavenaam","Anzeigename","Nom affiché","Nombre visible","Nome visualizzato","Отображаемое имя","Відображуване ім'я","Показвано име","Приказно име","Εμφανιζόμενο όνομα" ,"Nome exibido"} }, - { "Identity (public key)", { 0, "Identiteit (publieke sleutel)","Identität (öffentl. Schlüssel)","Identité (clé publique)","Identidad (clave pública)","Identità (chiave pubblica)","Идентификатор (открытый ключ)","Ідентичність (відкритий ключ)","Идентичност (публичен ключ)","Идентитет (јавни кључ)","Ταυτότητα (δημόσιο κλειδί)" ,"Identidade (chave pública)"} }, - { "Community preset (meshcomod web)", { 0, "Community-preset (meshcomod web)","Community-Vorgabe (meshcomod web)","Préréglage communauté (meshcomod web)","Preajuste comunidad (meshcomod web)","Preset community (meshcomod web)","Пресет сообщества (meshcomod web)","Пресет спільноти (meshcomod web)","Пресет на общността (meshcomod web)","Унапред (meshcomod web)","Προεπιλογή κοινότητας (meshcomod web)" ,"Predefinição da comunidade (meshcomod web)"} }, - { "Frequency / bandwidth", { 0, "Frequentie / bandbreedte","Frequenz / Bandbreite","Fréquence / bande passante","Frecuencia / ancho de banda","Frequenza / larghezza di banda","Частота / полоса","Частота / смуга","Честота / лента","Фреквенција / опсег","Συχνότητα / εύρος ζώνης" ,"Frequência / largura de banda"} }, - { "SF / CR / TX / AF", { 0, "SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF" ,"SF / CR / TX / AF"} }, - { "Multi-byte routing (path hash)", { 0, "Multi-byte routing (pad-hash)","Multibyte-Routing (Pfad-Hash)","Routage multi-octet (hash de chemin)","Enrutamiento multibyte (hash de ruta)","Routing multi-byte (hash percorso)","Многобайтовая маршрутизация (хеш пути)","Багатобайтова маршрутизація (хеш шляху)","Многобайтово маршрутиране (хеш на пътя)","Вишебајтно рутирање (хеш путање)","Δρομολόγηση πολλών byte (hash διαδρομής)" ,"Roteamento multibyte (hash de rota)"} }, - { "Region scope (#tag, blank = none)", { 0, "Regiobereik (#tag, leeg = geen)","Regionsbereich (#tag, leer = keiner)","Portée régionale (#tag, vide = aucune)","Alcance de región (#tag, vacío = ninguno)","Ambito regione (#tag, vuoto = nessuno)","Регион (#tag, пусто = нет)","Регіон (#tag, порожньо = немає)","Регионален обхват (#tag, празно = няма)","Опсег региона (#tag, празно = ниједан)","Εύρος περιοχής (#tag, κενό = κανένα)" ,"Escopo de região (#tag, vazio = nenhum)"} }, - { "Auto chat", { 0, "Auto chat","Auto-Chat","Chat auto","Chat auto","Chat auto","Авто чат","Авто чат","Авто чат","Ауто ћаскање","Αυτόματη συνομιλία" ,"Auto chat"} }, - { "Auto repeater", { 0, "Auto repeater","Auto-Repeater","Répéteur auto","Repetidor auto","Ripetitore auto","Авто ретранслятор","Авто ретранслятор","Авто ретранслатор","Ауто репетитор","Αυτόματος αναμεταδότης" ,"Auto repetidor"} }, - { "Auto room", { 0, "Auto room","Auto-Raum","Salon auto","Sala auto","Stanza auto","Авто комната","Авто кімната","Авто стая","Ауто соба","Αυτόματο δωμάτιο" ,"Auto sala"} }, - { "Auto sensor", { 0, "Auto sensor","Auto-Sensor","Capteur auto","Sensor auto","Sensore auto","Авто датчик","Авто датчик","Авто сензор","Ауто сензор","Αυτόματος αισθητήρας" ,"Auto sensor"} }, - { "Overwrite oldest", { 0, "Oudste overschrijven","Älteste überschreiben","Écraser le plus ancien","Sobrescribir el más antiguo","Sovrascrivi il più vecchio","Перезаписать старейший","Перезаписати найстаріший","Презапиши най-стария","Препиши најстарији","Αντικατάσταση παλαιότερου" ,"Sobrescrever o mais antigo"} }, - { "Multi ACKs", { 0, "Multi-ACK's","Multi-ACKs","Multi-ACK","Multi-ACK","Multi-ACK","Множественные ACK","Множинні ACK","Множествени ACK","Више ACK","Πολλαπλά ACK" ,"Multi ACKs"} }, - { "Client repeat", { 0, "Client herhaling","Client-Wiederholung","Répétition client","Repetición de cliente","Ripetizione client","Повтор клиента","Повтор клієнта","Повтаряне от клиент","Понављање клијента","Επανάληψη πελάτη" ,"Repetição do cliente"} }, - { "RX boosted gain", { 0, "RX versterkte gain","RX verstärkte Verstärkung","Gain RX boosté","Ganancia RX aumentada","Guadagno RX potenziato","Усиленное RX","Підсилений RX","Усилено RX","Појачан RX","Ενισχυμένο κέρδος RX" ,"Ganho RX reforçado"} }, - { "Duty meter", { 0, "Duty-meter","Duty-Anzeige","Indicateur de cycle","Medidor de ciclo","Misuratore duty","Индикатор нагрузки","Індикатор завантаження","Измервател на цикъла","Мерач циклуса","Μετρητής κύκλου" ,"Medidor de ciclo"} }, - { "Network name", { 0, "Netwerknaam","Netzwerkname","Nom du réseau","Nombre de red","Nome rete","Имя сети","Назва мережі","Име на мрежата","Назив мреже","Όνομα δικτύου" ,"Nome da rede"} }, - { "Name fragment", { 0, "Naamfragment","Namensfragment","Fragment de nom","Fragmento de nombre","Frammento nome","Часть имени","Частина імені","Част от име","Део имена","Τμήμα ονόματος" ,"Fragmento de nome"} }, - { "Theme", { 0, "Thema","Thema","Thème","Tema","Tema","Тема","Тема","Тема","Тема","Θέμα" ,"Tema"} }, - { "Lock", { 0, "Vergrendelen","Sperren","Verrouiller","Bloquear","Blocca","Блокировка","Блокування","Заключи","Закључај","Κλείδωμα" ,"Bloquear"} }, - { "Switch", { 0, "Wisselen","Wechseln","Basculer","Cambiar","Cambia","Переключить","Перемкнути","Превключи","Пребаци","Εναλλαγή" ,"Trocar"} }, - { "Wi-Fi on", { 0, "Wi-Fi aan","WLAN ein","Wi-Fi activé","Wi-Fi activado","Wi-Fi acceso","Wi-Fi вкл","Wi-Fi увімк","Wi-Fi вкл","Wi-Fi укљ","Wi-Fi ναι" ,"Wi-Fi ligado"} }, - { "Wi-Fi off", { 0, "Wi-Fi uit","WLAN aus","Wi-Fi désactivé","Wi-Fi apagado","Wi-Fi spento","Wi-Fi выкл","Wi-Fi вимк","Wi-Fi изкл","Wi-Fi искљ","Wi-Fi όχι" ,"Wi-Fi desligado"} }, - { "BLE on", { 0, "BLE aan","BLE ein","BLE activé","BLE activado","BLE acceso","BLE вкл","BLE увімк","BLE вкл","BLE укљ","BLE ναι" ,"BLE ligado"} }, - { "BLE off", { 0, "BLE uit","BLE aus","BLE désactivé","BLE apagado","BLE spento","BLE выкл","BLE вимк","BLE изкл","BLE искљ","BLE όχι" ,"BLE desligado"} }, - { "Bluetooth on", { 0, "Bluetooth aan","Bluetooth ein","Bluetooth activé","Bluetooth activado","Bluetooth acceso","Bluetooth вкл","Bluetooth увімк","Bluetooth вкл","Bluetooth укљ","Bluetooth ναι" ,"Bluetooth ligado"} }, - { "Bluetooth off", { 0, "Bluetooth uit","Bluetooth aus","Bluetooth désactivé","Bluetooth apagado","Bluetooth spento","Bluetooth выкл","Bluetooth вимк","Bluetooth изкл","Bluetooth искљ","Bluetooth όχι" ,"Bluetooth desligado"} }, - { "Bluetooth already on", { 0, "Bluetooth al aan","Bluetooth bereits ein","Bluetooth déjà activé","Bluetooth ya activado","Bluetooth già acceso","Bluetooth уже вкл","Bluetooth вже увімк","Bluetooth вече е вкл","Bluetooth већ укључен","Το Bluetooth είναι ήδη ενεργό" ,"Bluetooth já ligado"} }, - { "Bluetooth already off", { 0, "Bluetooth al uit","Bluetooth bereits aus","Bluetooth déjà désactivé","Bluetooth ya apagado","Bluetooth già spento","Bluetooth уже выкл","Bluetooth вже вимк","Bluetooth вече е изкл","Bluetooth већ искључен","Το Bluetooth είναι ήδη ανενεργό" ,"Bluetooth já desligado"} }, - { "GPS on", { 0, "GPS aan","GPS ein","GPS activé","GPS activado","GPS acceso","GPS вкл","GPS увімк","GPS вкл","GPS укљ","GPS ναι" ,"GPS ligado"} }, - { "GPS off", { 0, "GPS uit","GPS aus","GPS désactivé","GPS apagado","GPS spento","GPS выкл","GPS вимк","GPS изкл","GPS искљ","GPS όχι" ,"GPS desligado"} }, - { "Sound on", { 0, "Geluid aan","Ton ein","Son activé","Sonido activado","Suono acceso","Звук вкл","Звук увімк","Звук вкл","Звук укљ","Ήχος ναι" ,"Som ligado"} }, - { "Sound off", { 0, "Geluid uit","Ton aus","Son désactivé","Sonido apagado","Suono spento","Звук выкл","Звук вимк","Звук изкл","Звук искљ","Ήχος όχι" ,"Som desligado"} }, - { "TCP on", { 0, "TCP aan","TCP ein","TCP activé","TCP activado","TCP acceso","TCP вкл","TCP увімк","TCP вкл","TCP укљ","TCP ναι" ,"TCP ligado"} }, - { "TCP off", { 0, "TCP uit","TCP aus","TCP désactivé","TCP apagado","TCP spento","TCP выкл","TCP вимк","TCP изкл","TCP искљ","TCP όχι" ,"TCP desligado"} }, - { "Welcome to WADAMESH", { 0, "Welkom bij WADAMESH","Willkommen bei WADAMESH","Bienvenue sur WADAMESH","Bienvenido a WADAMESH","Benvenuto in WADAMESH","Добро пожаловать в WADAMESH","Ласкаво просимо до WADAMESH","Добре дошли в WADAMESH","Добро дошли у WADAMESH","Καλώς ήρθατε στο WADAMESH" ,"Bem-vindo ao WADAMESH"} }, - { "Get Started", { 0, "Aan de slag","Loslegen","Commencer","Empezar","Inizia","Начать","Почати","Започни","Започни","Ξεκινήστε" ,"Começar"} }, - { "Choose your name", { 0, "Kies je naam","Wählen Sie Ihren Namen","Choisissez votre nom","Elige tu nombre","Scegli il tuo nome","Выберите имя","Виберіть ім'я","Изберете името си","Изаберите своје име","Επιλέξτε το όνομά σας" ,"Escolha seu nome"} }, - { "Your name", { 0, "Je naam","Ihr Name","Votre nom","Tu nombre","Il tuo nome","Ваше имя","Ваше ім'я","Вашето име","Ваше име","Το όνομά σας" ,"Seu nome"} }, - { "Choose your region", { 0, "Kies je regio","Wählen Sie Ihre Region","Choisissez votre région","Elige tu región","Scegli la tua regione","Выберите регион","Виберіть регіон","Изберете региона си","Изаберите регион","Επιλέξτε την περιοχή σας" ,"Escolha sua região"} }, - { "Next", { 0, "Volgende","Weiter","Suivant","Siguiente","Avanti","Далее","Далі","Напред","Даље","Επόμενο" ,"Avançar"} }, - { "Skip", { 0, "Overslaan","Überspringen","Passer","Omitir","Salta","Пропустить","Пропустити","Пропусни","Прескочи","Παράλειψη" ,"Pular"} }, - { "Finish", { 0, "Voltooien","Fertig","Terminer","Finalizar","Fine","Готово","Готово","Готово","Заврши","Τέλος" ,"Concluir"} }, - { "Connect to Wi-Fi", { 0, "Verbinden met wifi","Mit WLAN verbinden","Se connecter au Wi-Fi","Conectar a Wi-Fi","Connetti al Wi-Fi","Подключиться к Wi-Fi","Підключитися до Wi-Fi","Свържи се с Wi-Fi","Повежи се на Wi-Fi","Σύνδεση σε Wi-Fi" ,"Conectar ao Wi-Fi"} }, - { "Password (empty = skip Wi-Fi)", { 0, "Wachtwoord (leeg = wifi overslaan)","Passwort (leer = WLAN überspringen)","Mot de passe (vide = ignorer le Wi-Fi)","Contraseña (vacío = omitir Wi-Fi)","Password (vuoto = salta Wi-Fi)","Пароль (пусто = пропустить Wi-Fi)","Пароль (порожньо = пропустити Wi-Fi)","Парола (празно = пропусни Wi-Fi)","Лозинка (празно = прескочи Wi-Fi)","Κωδικός (κενό = παράλειψη Wi-Fi)" ,"Senha (vazio = pular Wi-Fi)"} }, + { "Auto-add contacts", { 0, "Contacten automatisch toevoegen","Kontakte automatisch hinzufügen","Ajout auto de contacts","Auto-añadir contactos","Aggiunta automatica contatti","Автодобавление контактов","Автододавання контактів","Автоматично добавяне на контакти","Аутоматско додавање контаката","Αυτόματη προσθήκη επαφών" ,"Auto-adicionar contatos","Auto-adăugare contacte" } }, + { "Experimental", { 0, "Experimenteel","Experimentell","Expérimental","Experimental","Sperimentale","Экспериментальное","Експериментальне","Експериментално","Експериментално","Πειραματικά" ,"Experimental","Experimental" } }, + { "Discovered", { 0, "Ontdekt","Entdeckt","Découverts","Descubiertos","Scoperti","Обнаружено","Виявлено","Открити","Откривени","Ανακαλύφθηκαν" ,"Descobertos","Descoperite" } }, + { "Create private channel", { 0, "Privékanaal aanmaken","Privaten Kanal erstellen","Créer un canal privé","Crear canal privado","Crea canale privato","Создать приватный канал","Створити приватний канал","Създай частен канал","Направи приватни канал","Δημιουργία ιδιωτικού καναλιού" ,"Criar canal privado","Creează canal privat" } }, + { "Join hashtag channel", { 0, "Deelnemen aan hashtagkanaal","Hashtag-Kanal beitreten","Rejoindre un canal hashtag","Unirse a canal de hashtag","Unisciti a canale hashtag","Войти в канал хэштега","Приєднатися до каналу хештегу","Присъедини се към хаштаг канал","Придружи се хаштаг каналу","Συμμετοχή σε κανάλι hashtag" ,"Entrar em canal de hashtag","Intră într-un canal hashtag" } }, + { "Join private channel", { 0, "Deelnemen aan privékanaal","Privaten Kanal beitreten","Rejoindre un canal privé","Unirse a canal privado","Unisciti a canale privato","Войти в приватный канал","Приєднатися до приватного каналу","Присъедини се към частен канал","Придружи се приватном каналу","Συμμετοχή σε ιδιωτικό κανάλι" ,"Entrar em canal privado","Intră într-un canal privat" } }, + { "Send advert", { 0, "Advert verzenden","Advert senden","Envoyer une annonce","Enviar anuncio","Invia advert","Отправить advert","Надіслати advert","Изпрати advert","Пошаљи advert","Αποστολή advert" ,"Enviar advert","Trimite advert" } }, + { "Share channel", { 0, "Kanaal delen","Kanal teilen","Partager le canal","Compartir canal","Condividi canale","Поделиться каналом","Поділитися каналом","Сподели канала","Подели канал","Κοινή χρήση καναλιού" ,"Compartilhar canal","Partajează canalul" } }, + { "Advert", { 0, "Advert","Advert","Annonce","Anuncio","Advert","Advert","Advert","Advert","Advert","Advert" ,"Advert","Advert" } }, + { "Advert location", { 0, "Advertlocatie","Advert-Standort","Position de l'annonce","Ubicación del anuncio","Posizione advert","Местоположение advert","Місцезнаходження advert","Местоположение на advert","Локација advert-а","Τοποθεσία advert" ,"Local do advert","Locaţie advert" } }, + { "Latitude", { 0, "Breedtegraad","Breitengrad","Latitude","Latitud","Latitudine","Широта","Широта","Географска ширина","Географска ширина","Γεωγραφικό πλάτος" ,"Latitude","Latitudine" } }, + { "Longitude", { 0, "Lengtegraad","Längengrad","Longitude","Longitud","Longitudine","Долгота","Довгота","Географска дължина","Географска дужина","Γεωγραφικό μήκος" ,"Longitude","Longitudine" } }, + { "Share location in advert", { 0, "Locatie delen in advert","Standort im Advert teilen","Partager la position dans l'annonce","Compartir ubicación en anuncio","Condividi posizione in advert","Делиться местоположением в advert","Ділитися місцезнаходженням в advert","Споделяй местоположението в advert","Дели локацију у advert-у","Κοινή χρήση θέσης στο advert" ,"Compartilhar local no advert","Partajează locaţia în advert" } }, + { "Node name", { 0, "Nodenaam","Node-Name","Nom du nœud","Nombre del nodo","Nome nodo","Имя узла","Назва вузла","Име на възела","Назив чвора","Όνομα κόμβου" ,"Nome do nó","Nume nod" } }, + { "Display name", { 0, "Weergavenaam","Anzeigename","Nom affiché","Nombre visible","Nome visualizzato","Отображаемое имя","Відображуване ім'я","Показвано име","Приказно име","Εμφανιζόμενο όνομα" ,"Nome exibido","Nume afişat" } }, + { "Identity (public key)", { 0, "Identiteit (publieke sleutel)","Identität (öffentl. Schlüssel)","Identité (clé publique)","Identidad (clave pública)","Identità (chiave pubblica)","Идентификатор (открытый ключ)","Ідентичність (відкритий ключ)","Идентичност (публичен ключ)","Идентитет (јавни кључ)","Ταυτότητα (δημόσιο κλειδί)" ,"Identidade (chave pública)","Identitate (cheie publică)" } }, + { "Community preset (meshcomod web)", { 0, "Community-preset (meshcomod web)","Community-Vorgabe (meshcomod web)","Préréglage communauté (meshcomod web)","Preajuste comunidad (meshcomod web)","Preset community (meshcomod web)","Пресет сообщества (meshcomod web)","Пресет спільноти (meshcomod web)","Пресет на общността (meshcomod web)","Унапред (meshcomod web)","Προεπιλογή κοινότητας (meshcomod web)" ,"Predefinição da comunidade (meshcomod web)","Presetare comunitate (meshcomod web)" } }, + { "Frequency / bandwidth", { 0, "Frequentie / bandbreedte","Frequenz / Bandbreite","Fréquence / bande passante","Frecuencia / ancho de banda","Frequenza / larghezza di banda","Частота / полоса","Частота / смуга","Честота / лента","Фреквенција / опсег","Συχνότητα / εύρος ζώνης" ,"Frequência / largura de banda","Frecvenţă / lăţime de bandă" } }, + { "SF / CR / TX / AF", { 0, "SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF","SF / CR / TX / AF" ,"SF / CR / TX / AF","SF / CR / TX / AF" } }, + { "Multi-byte routing (path hash)", { 0, "Multi-byte routing (pad-hash)","Multibyte-Routing (Pfad-Hash)","Routage multi-octet (hash de chemin)","Enrutamiento multibyte (hash de ruta)","Routing multi-byte (hash percorso)","Многобайтовая маршрутизация (хеш пути)","Багатобайтова маршрутизація (хеш шляху)","Многобайтово маршрутиране (хеш на пътя)","Вишебајтно рутирање (хеш путање)","Δρομολόγηση πολλών byte (hash διαδρομής)" ,"Roteamento multibyte (hash de rota)","Rutare multi-octet (hash de cale)" } }, + { "Region scope (#tag, blank = none)", { 0, "Regiobereik (#tag, leeg = geen)","Regionsbereich (#tag, leer = keiner)","Portée régionale (#tag, vide = aucune)","Alcance de región (#tag, vacío = ninguno)","Ambito regione (#tag, vuoto = nessuno)","Регион (#tag, пусто = нет)","Регіон (#tag, порожньо = немає)","Регионален обхват (#tag, празно = няма)","Опсег региона (#tag, празно = ниједан)","Εύρος περιοχής (#tag, κενό = κανένα)" ,"Escopo de região (#tag, vazio = nenhum)","Domeniu regional (#tag, gol = niciunul)" } }, + { "Auto chat", { 0, "Auto chat","Auto-Chat","Chat auto","Chat auto","Chat auto","Авто чат","Авто чат","Авто чат","Ауто ћаскање","Αυτόματη συνομιλία" ,"Auto chat","Chat auto" } }, + { "Auto repeater", { 0, "Auto repeater","Auto-Repeater","Répéteur auto","Repetidor auto","Ripetitore auto","Авто ретранслятор","Авто ретранслятор","Авто ретранслатор","Ауто репетитор","Αυτόματος αναμεταδότης" ,"Auto repetidor","Repetor auto" } }, + { "Auto room", { 0, "Auto room","Auto-Raum","Salon auto","Sala auto","Stanza auto","Авто комната","Авто кімната","Авто стая","Ауто соба","Αυτόματο δωμάτιο" ,"Auto sala","Cameră auto" } }, + { "Auto sensor", { 0, "Auto sensor","Auto-Sensor","Capteur auto","Sensor auto","Sensore auto","Авто датчик","Авто датчик","Авто сензор","Ауто сензор","Αυτόματος αισθητήρας" ,"Auto sensor","Senzor auto" } }, + { "Overwrite oldest", { 0, "Oudste overschrijven","Älteste überschreiben","Écraser le plus ancien","Sobrescribir el más antiguo","Sovrascrivi il più vecchio","Перезаписать старейший","Перезаписати найстаріший","Презапиши най-стария","Препиши најстарији","Αντικατάσταση παλαιότερου" ,"Sobrescrever o mais antigo","Suprascrie cele mai vechi" } }, + { "Multi ACKs", { 0, "Multi-ACK's","Multi-ACKs","Multi-ACK","Multi-ACK","Multi-ACK","Множественные ACK","Множинні ACK","Множествени ACK","Више ACK","Πολλαπλά ACK" ,"Multi ACKs","ACK-uri multiple" } }, + { "Client repeat", { 0, "Client herhaling","Client-Wiederholung","Répétition client","Repetición de cliente","Ripetizione client","Повтор клиента","Повтор клієнта","Повтаряне от клиент","Понављање клијента","Επανάληψη πελάτη" ,"Repetição do cliente","Repetare client" } }, + { "RX boosted gain", { 0, "RX versterkte gain","RX verstärkte Verstärkung","Gain RX boosté","Ganancia RX aumentada","Guadagno RX potenziato","Усиленное RX","Підсилений RX","Усилено RX","Појачан RX","Ενισχυμένο κέρδος RX" ,"Ganho RX reforçado","Amplificare RX crescută" } }, + { "Duty meter", { 0, "Duty-meter","Duty-Anzeige","Indicateur de cycle","Medidor de ciclo","Misuratore duty","Индикатор нагрузки","Індикатор завантаження","Измервател на цикъла","Мерач циклуса","Μετρητής κύκλου" ,"Medidor de ciclo","Contor duty" } }, + { "Network name", { 0, "Netwerknaam","Netzwerkname","Nom du réseau","Nombre de red","Nome rete","Имя сети","Назва мережі","Име на мрежата","Назив мреже","Όνομα δικτύου" ,"Nome da rede","Nume reţea" } }, + { "Name fragment", { 0, "Naamfragment","Namensfragment","Fragment de nom","Fragmento de nombre","Frammento nome","Часть имени","Частина імені","Част от име","Део имена","Τμήμα ονόματος" ,"Fragmento de nome","Fragment de nume" } }, + { "Theme", { 0, "Thema","Thema","Thème","Tema","Tema","Тема","Тема","Тема","Тема","Θέμα" ,"Tema","Temă" } }, + { "Lock", { 0, "Vergrendelen","Sperren","Verrouiller","Bloquear","Blocca","Блокировка","Блокування","Заключи","Закључај","Κλείδωμα" ,"Bloquear","Blochează" } }, + { "Switch", { 0, "Wisselen","Wechseln","Basculer","Cambiar","Cambia","Переключить","Перемкнути","Превключи","Пребаци","Εναλλαγή" ,"Trocar","Comută" } }, + { "Wi-Fi on", { 0, "Wi-Fi aan","WLAN ein","Wi-Fi activé","Wi-Fi activado","Wi-Fi acceso","Wi-Fi вкл","Wi-Fi увімк","Wi-Fi вкл","Wi-Fi укљ","Wi-Fi ναι" ,"Wi-Fi ligado","Wi-Fi pornit" } }, + { "Wi-Fi off", { 0, "Wi-Fi uit","WLAN aus","Wi-Fi désactivé","Wi-Fi apagado","Wi-Fi spento","Wi-Fi выкл","Wi-Fi вимк","Wi-Fi изкл","Wi-Fi искљ","Wi-Fi όχι" ,"Wi-Fi desligado","Wi-Fi oprit" } }, + { "BLE on", { 0, "BLE aan","BLE ein","BLE activé","BLE activado","BLE acceso","BLE вкл","BLE увімк","BLE вкл","BLE укљ","BLE ναι" ,"BLE ligado","BLE pornit" } }, + { "BLE off", { 0, "BLE uit","BLE aus","BLE désactivé","BLE apagado","BLE spento","BLE выкл","BLE вимк","BLE изкл","BLE искљ","BLE όχι" ,"BLE desligado","BLE oprit" } }, + { "Bluetooth on", { 0, "Bluetooth aan","Bluetooth ein","Bluetooth activé","Bluetooth activado","Bluetooth acceso","Bluetooth вкл","Bluetooth увімк","Bluetooth вкл","Bluetooth укљ","Bluetooth ναι" ,"Bluetooth ligado","Bluetooth pornit" } }, + { "Bluetooth off", { 0, "Bluetooth uit","Bluetooth aus","Bluetooth désactivé","Bluetooth apagado","Bluetooth spento","Bluetooth выкл","Bluetooth вимк","Bluetooth изкл","Bluetooth искљ","Bluetooth όχι" ,"Bluetooth desligado","Bluetooth oprit" } }, + { "Bluetooth already on", { 0, "Bluetooth al aan","Bluetooth bereits ein","Bluetooth déjà activé","Bluetooth ya activado","Bluetooth già acceso","Bluetooth уже вкл","Bluetooth вже увімк","Bluetooth вече е вкл","Bluetooth већ укључен","Το Bluetooth είναι ήδη ενεργό" ,"Bluetooth já ligado","Bluetooth deja pornit" } }, + { "Bluetooth already off", { 0, "Bluetooth al uit","Bluetooth bereits aus","Bluetooth déjà désactivé","Bluetooth ya apagado","Bluetooth già spento","Bluetooth уже выкл","Bluetooth вже вимк","Bluetooth вече е изкл","Bluetooth већ искључен","Το Bluetooth είναι ήδη ανενεργό" ,"Bluetooth já desligado","Bluetooth deja oprit" } }, + { "GPS on", { 0, "GPS aan","GPS ein","GPS activé","GPS activado","GPS acceso","GPS вкл","GPS увімк","GPS вкл","GPS укљ","GPS ναι" ,"GPS ligado","GPS pornit" } }, + { "GPS off", { 0, "GPS uit","GPS aus","GPS désactivé","GPS apagado","GPS spento","GPS выкл","GPS вимк","GPS изкл","GPS искљ","GPS όχι" ,"GPS desligado","GPS oprit" } }, + { "Sound on", { 0, "Geluid aan","Ton ein","Son activé","Sonido activado","Suono acceso","Звук вкл","Звук увімк","Звук вкл","Звук укљ","Ήχος ναι" ,"Som ligado","Sunet pornit" } }, + { "Sound off", { 0, "Geluid uit","Ton aus","Son désactivé","Sonido apagado","Suono spento","Звук выкл","Звук вимк","Звук изкл","Звук искљ","Ήχος όχι" ,"Som desligado","Sunet oprit" } }, + { "TCP on", { 0, "TCP aan","TCP ein","TCP activé","TCP activado","TCP acceso","TCP вкл","TCP увімк","TCP вкл","TCP укљ","TCP ναι" ,"TCP ligado","TCP pornit" } }, + { "TCP off", { 0, "TCP uit","TCP aus","TCP désactivé","TCP apagado","TCP spento","TCP выкл","TCP вимк","TCP изкл","TCP искљ","TCP όχι" ,"TCP desligado","TCP oprit" } }, + { "Welcome to WADAMESH", { 0, "Welkom bij WADAMESH","Willkommen bei WADAMESH","Bienvenue sur WADAMESH","Bienvenido a WADAMESH","Benvenuto in WADAMESH","Добро пожаловать в WADAMESH","Ласкаво просимо до WADAMESH","Добре дошли в WADAMESH","Добро дошли у WADAMESH","Καλώς ήρθατε στο WADAMESH" ,"Bem-vindo ao WADAMESH","Bun venit la WADAMESH" } }, + { "Get Started", { 0, "Aan de slag","Loslegen","Commencer","Empezar","Inizia","Начать","Почати","Започни","Започни","Ξεκινήστε" ,"Começar","Începe" } }, + { "Choose your name", { 0, "Kies je naam","Wählen Sie Ihren Namen","Choisissez votre nom","Elige tu nombre","Scegli il tuo nome","Выберите имя","Виберіть ім'я","Изберете името си","Изаберите своје име","Επιλέξτε το όνομά σας" ,"Escolha seu nome","Alege-ţi numele" } }, + { "Your name", { 0, "Je naam","Ihr Name","Votre nom","Tu nombre","Il tuo nome","Ваше имя","Ваше ім'я","Вашето име","Ваше име","Το όνομά σας" ,"Seu nome","Numele tău" } }, + { "Choose your region", { 0, "Kies je regio","Wählen Sie Ihre Region","Choisissez votre région","Elige tu región","Scegli la tua regione","Выберите регион","Виберіть регіон","Изберете региона си","Изаберите регион","Επιλέξτε την περιοχή σας" ,"Escolha sua região","Alege-ţi regiunea" } }, + { "Next", { 0, "Volgende","Weiter","Suivant","Siguiente","Avanti","Далее","Далі","Напред","Даље","Επόμενο" ,"Avançar","Înainte" } }, + { "Skip", { 0, "Overslaan","Überspringen","Passer","Omitir","Salta","Пропустить","Пропустити","Пропусни","Прескочи","Παράλειψη" ,"Pular","Omite" } }, + { "Finish", { 0, "Voltooien","Fertig","Terminer","Finalizar","Fine","Готово","Готово","Готово","Заврши","Τέλος" ,"Concluir","Finalizează" } }, + { "Connect to Wi-Fi", { 0, "Verbinden met wifi","Mit WLAN verbinden","Se connecter au Wi-Fi","Conectar a Wi-Fi","Connetti al Wi-Fi","Подключиться к Wi-Fi","Підключитися до Wi-Fi","Свържи се с Wi-Fi","Повежи се на Wi-Fi","Σύνδεση σε Wi-Fi" ,"Conectar ao Wi-Fi","Conectare la Wi-Fi" } }, + { "Password (empty = skip Wi-Fi)", { 0, "Wachtwoord (leeg = wifi overslaan)","Passwort (leer = WLAN überspringen)","Mot de passe (vide = ignorer le Wi-Fi)","Contraseña (vacío = omitir Wi-Fi)","Password (vuoto = salta Wi-Fi)","Пароль (пусто = пропустить Wi-Fi)","Пароль (порожньо = пропустити Wi-Fi)","Парола (празно = пропусни Wi-Fi)","Лозинка (празно = прескочи Wi-Fi)","Κωδικός (κενό = παράλειψη Wi-Fi)" ,"Senha (vazio = pular Wi-Fi)","Parolă (gol = omite Wi-Fi)" } }, // ---- Sweep pass 7: action sheet / file manager / composer / status alerts ---- - { "Copy", { 0, "Kopiëren","Kopieren","Copier","Copiar","Copia","Копировать","Копіювати","Копирай","Копирај","Αντιγραφή" ,"Copiar"} }, - { "Cut", { 0, "Knippen","Ausschneiden","Couper","Cortar","Taglia","Вырезать","Вирізати","Изрежи","Исеци","Αποκοπή" ,"Recortar"} }, - { "Delete", { 0, "Verwijderen","Löschen","Supprimer","Eliminar","Elimina","Удалить","Видалити","Изтрий","Обриши","Διαγραφή" ,"Excluir"} }, - { "Remove", { 0, "Verwijderen","Entfernen","Retirer","Quitar","Rimuovi","Убрать","Прибрати","Премахни","Уклони","Αφαίρεση" ,"Remover"} }, - { "Rename", { 0, "Hernoemen","Umbenennen","Renommer","Renombrar","Rinomina","Переименовать","Перейменувати","Преименувай","Преименуј","Μετονομασία" ,"Renomear"} }, - { "Format", { 0, "Formatteren","Formatieren","Formater","Formatear","Formatta","Форматировать","Форматувати","Форматирай","Форматирај","Διαμόρφωση" ,"Formatar"} }, - { "Paste", { 0, "Plakken","Einfügen","Coller","Pegar","Incolla","Вставить","Вставити","Постави","Налепи","Επικόλληση" ,"Colar"} }, - { "Back", { 0, "Terug","Zurück","Retour","Atrás","Indietro","Назад","Назад","Назад","Назад","Πίσω" ,"Voltar"} }, - { "Reboot", { 0, "Herstarten","Neustart","Redémarrer","Reiniciar","Riavvia","Перезагрузка","Перезавантажити","Рестартирай","Рестартуј","Επανεκκίνηση" ,"Reiniciar"} }, - { "Mark read", { 0, "Markeer als gelezen","Als gelesen markieren","Marquer comme lu","Marcar como leído","Segna come letto","Отметить прочитанным","Позначити прочитаним","Маркирай прочетено","Означи прочитано","Σήμανση ως αναγνωσμένο" ,"Marcar como lido"} }, - { "New file", { 0, "Nieuw bestand","Neue Datei","Nouveau fichier","Nuevo archivo","Nuovo file","Новый файл","Новий файл","Нов файл","Нова датотека","Νέο αρχείο" ,"Novo arquivo"} }, - { "New folder", { 0, "Nieuwe map","Neuer Ordner","Nouveau dossier","Nueva carpeta","Nuova cartella","Новая папка","Нова тека","Нова папка","Нова фасцикла","Νέος φάκελος" ,"Nova pasta"} }, - { "Folder created", { 0, "Map aangemaakt","Ordner erstellt","Dossier créé","Carpeta creada","Cartella creata","Папка создана","Теку створено","Папката е създадена","Фасцикла направљена","Ο φάκελος δημιουργήθηκε" ,"Pasta criada"} }, - { "Empty file", { 0, "Leeg bestand","Leere Datei","Fichier vide","Archivo vacío","File vuoto","Пустой файл","Порожній файл","Празен файл","Празна датотека","Κενό αρχείο" ,"Arquivo vazio"} }, - { "Renamed", { 0, "Hernoemd","Umbenannt","Renommé","Renombrado","Rinominato","Переименовано","Перейменовано","Преименувано","Преименовано","Μετονομάστηκε" ,"Renomeado"} }, - { "Rename failed", { 0, "Hernoemen mislukt","Umbenennen fehlgeschlagen","Échec du renommage","Error al renombrar","Rinomina non riuscita","Не удалось переименовать","Не вдалося перейменувати","Преименуването неуспешно","Преименовање није успело","Αποτυχία μετονομασίας" ,"Falha ao renomear"} }, - { "Pasted", { 0, "Geplakt","Eingefügt","Collé","Pegado","Incollato","Вставлено","Вставлено","Поставено","Налепљено","Επικολλήθηκε" ,"Colado"} }, - { "Paste failed", { 0, "Plakken mislukt","Einfügen fehlgeschlagen","Échec du collage","Error al pegar","Incolla non riuscito","Не удалось вставить","Не вдалося вставити","Поставянето неуспешно","Лепљење није успело","Αποτυχία επικόλλησης" ,"Falha ao colar"} }, - { "Copied - Paste in a folder", { 0, "Gekopieerd - plak in een map","Kopiert - in einen Ordner einfügen","Copié - collez dans un dossier","Copiado - pega en una carpeta","Copiato - incolla in una cartella","Скопировано - вставьте в папку","Скопійовано - вставте в теку","Копирано - поставете в папка","Копирано - налепи у фасциклу","Αντιγράφηκε - επικολλήστε σε φάκελο" ,"Copiado - cole numa pasta"} }, - { "Cut - Paste in a folder", { 0, "Geknipt - plak in een map","Ausgeschnitten - in einen Ordner einfügen","Coupé - collez dans un dossier","Cortado - pega en una carpeta","Tagliato - incolla in una cartella","Вырезано - вставьте в папку","Вирізано - вставте в теку","Изрязано - поставете в папка","Исечено - налепи у фасциклу","Αποκόπηκε - επικολλήστε σε φάκελο" ,"Recortado - cole numa pasta"} }, - { "mkdir failed", { 0, "map maken mislukt","Ordner anlegen fehlgeschlagen","Échec de création du dossier","Error al crear carpeta","Creazione cartella non riuscita","Не удалось создать папку","Не вдалося створити теку","Създаването на папка неуспешно","Прављење фасцикле није успело","Αποτυχία δημιουργίας φακέλου" ,"falha no mkdir"} }, - { "Image too large (>4 MB)", { 0, "Afbeelding te groot (>4 MB)","Bild zu groß (>4 MB)","Image trop grande (>4 Mo)","Imagen demasiado grande (>4 MB)","Immagine troppo grande (>4 MB)","Изображение слишком большое (>4 МБ)","Зображення завелике (>4 МБ)","Изображението е твърде голямо (>4 MB)","Слика превелика (>4 MB)","Πολύ μεγάλη εικόνα (>4 MB)" ,"Imagem grande demais (>4 MB)"} }, - { "Type a message...", { 0, "Typ een bericht...","Nachricht eingeben...","Saisissez un message...","Escribe un mensaje...","Scrivi un messaggio...","Введите сообщение...","Введіть повідомлення...","Въведете съобщение...","Унесите поруку...","Πληκτρολογήστε μήνυμα..." ,"Digite uma mensagem..."} }, - { "search", { 0, "zoeken","Suche","rechercher","buscar","cerca","поиск","пошук","търсене","претрага","αναζήτηση" ,"buscar"} }, - { "leave empty to generate", { 0, "leeg laten om te genereren","leer lassen zum Erzeugen","laisser vide pour générer","dejar vacío para generar","lascia vuoto per generare","оставьте пустым для генерации","залиште порожнім для генерації","оставете празно за генериране","оставите празно за генерисање","κενό για δημιουργία" ,"deixe vazio para gerar"} }, - { "32 hex characters", { 0, "32 hex-tekens","32 Hex-Zeichen","32 caractères hex","32 caracteres hex","32 caratteri hex","32 hex-символа","32 hex-символи","32 hex знака","32 hex знака","32 hex χαρακτήρες" ,"32 caracteres hex"} }, - { "Enter exactly 32 hex characters.", { 0, "Voer precies 32 hex-tekens in.","Genau 32 Hex-Zeichen eingeben.","Saisissez exactement 32 caractères hex.","Introduce exactamente 32 caracteres hex.","Inserisci esattamente 32 caratteri hex.","Введите ровно 32 hex-символа.","Введіть рівно 32 hex-символи.","Въведете точно 32 hex знака.","Унесите тачно 32 hex знака.","Εισαγάγετε ακριβώς 32 hex χαρακτήρες." ,"Digite exatamente 32 caracteres hex."} }, - { "Secret must be 32 hex chars (or empty).", { 0, "Geheim moet 32 hex-tekens zijn (of leeg).","Geheimnis muss 32 Hex-Zeichen sein (oder leer).","Le secret doit faire 32 hex (ou vide).","El secreto debe tener 32 hex (o vacío).","Il segreto deve essere 32 hex (o vuoto).","Секрет — 32 hex-символа (или пусто).","Секрет — 32 hex-символи (або порожньо).","Тайната трябва да е 32 hex (или празно).","Тајна мора бити 32 hex (или празно).","Το μυστικό πρέπει 32 hex (ή κενό)." ,"O segredo deve ter 32 hex (ou vazio)."} }, - { "Optional — type your network, or leave blank to skip.", { 0, "Optioneel — typ je netwerk, of laat leeg om over te slaan.","Optional — Netzwerk eingeben oder leer lassen.","Facultatif — saisissez votre réseau, ou laissez vide.","Opcional — escribe tu red, o déjalo en blanco.","Opzionale — digita la rete, o lascia vuoto.","Необязательно — введите сеть или оставьте пустым.","Необов'язково — введіть мережу або залиште порожнім.","По избор — въведете мрежата си или оставете празно.","Опционо — унесите мрежу или оставите празно.","Προαιρετικό — πληκτρολογήστε δίκτυο ή αφήστε κενό." ,"Opcional — digite sua rede, ou deixe em branco para pular."} }, - { "Saved", { 0, "Opgeslagen","Gespeichert","Enregistré","Guardado","Salvato","Сохранено","Збережено","Запазено","Сачувано","Αποθηκεύτηκε" ,"Salvo"} }, - { "Advert sent", { 0, "Advert verzonden","Advert gesendet","Annonce envoyée","Anuncio enviado","Advert inviato","Advert отправлен","Advert надіслано","Advert изпратен","Advert послат","Στάλθηκε advert" ,"Advert enviado"} }, - { "Advert failed", { 0, "Advert mislukt","Advert fehlgeschlagen","Échec de l'annonce","Error en el anuncio","Advert non riuscito","Сбой advert","Помилка advert","Advert неуспешен","Advert није успео","Αποτυχία advert" ,"Falha no advert"} }, - { "Flood advert sent", { 0, "Flood-advert verzonden","Flood-Advert gesendet","Annonce flood envoyée","Anuncio flood enviado","Advert flood inviato","Flood advert отправлен","Flood advert надіслано","Flood advert изпратен","Flood advert послат","Στάλθηκε flood advert" ,"Flood advert enviado"} }, - { "Zero-hop advert sent", { 0, "Zero-hop advert verzonden","Zero-Hop-Advert gesendet","Annonce zero-hop envoyée","Anuncio zero-hop enviado","Advert zero-hop inviato","Zero-hop advert отправлен","Zero-hop advert надіслано","Zero-hop advert изпратен","Zero-hop advert послат","Στάλθηκε zero-hop advert" ,"Zero-hop advert enviado"} }, - { "Radio applied", { 0, "Radio toegepast","Funk angewendet","Radio appliquée","Radio aplicada","Radio applicata","Радио применено","Радіо застосовано","Радиото е приложено","Радио примењен","Εφαρμόστηκε ράδιο" ,"Rádio aplicado"} }, - { "Radio + region set", { 0, "Radio + regio ingesteld","Funk + Region gesetzt","Radio + région définies","Radio + región establecidas","Radio + regione impostate","Радио + регион заданы","Радіо + регіон задано","Радио + регион зададени","Радио + регион постављени","Ορίστηκαν ράδιο + περιοχή" ,"Rádio + região definidos"} }, - { "Chat removed", { 0, "Chat verwijderd","Chat entfernt","Discussion supprimée","Chat eliminado","Chat rimossa","Чат удалён","Чат видалено","Чатът е премахнат","Ћаскање уклоњено","Η συνομιλία αφαιρέθηκε" ,"Chat removido"} }, - { "Channel removed", { 0, "Kanaal verwijderd","Kanal entfernt","Canal supprimé","Canal eliminado","Canale rimosso","Канал удалён","Канал видалено","Каналът е премахнат","Канал уклоњен","Το κανάλι αφαιρέθηκε" ,"Canal removido"} }, - { "Sender blocked", { 0, "Afzender geblokkeerd","Absender blockiert","Expéditeur bloqué","Remitente bloqueado","Mittente bloccato","Отправитель заблокирован","Відправника заблоковано","Подателят е блокиран","Пошиљалац блокиран","Ο αποστολέας αποκλείστηκε" ,"Remetente bloqueado"} }, - { "Can't block — not a contact", { 0, "Kan niet blokkeren — geen contact","Blockieren nicht möglich — kein Kontakt","Blocage impossible — pas un contact","No se puede bloquear — no es un contacto","Impossibile bloccare — non è un contatto","Нельзя заблокировать — не контакт","Не можна заблокувати — не контакт","Не може да се блокира — не е контакт","Не могу да блокирам — није контакт","Αδυναμία αποκλεισμού — δεν είναι επαφή" ,"Não dá para bloquear — não é contato"} }, - { "Added to favorites", { 0, "Toegevoegd aan favorieten","Zu Favoriten hinzugefügt","Ajouté aux favoris","Añadido a favoritos","Aggiunto ai preferiti","Добавлено в избранное","Додано до обраного","Добавено в любими","Додато у омиљене","Προστέθηκε στα αγαπημένα" ,"Adicionado aos favoritos"} }, - { "Removed from favorites", { 0, "Verwijderd uit favorieten","Aus Favoriten entfernt","Retiré des favoris","Quitado de favoritos","Rimosso dai preferiti","Удалено из избранного","Видалено з обраного","Премахнато от любими","Уклоњено из омиљених","Αφαιρέθηκε από τα αγαπημένα" ,"Removido dos favoritos"} }, - { "Taste the rainbow!", { 0, "Proef de regenboog!","Schmecke den Regenbogen!","Goûtez l'arc-en-ciel !","¡Saborea el arcoíris!","Assapora l'arcobaleno!","Попробуй радугу!","Скуштуй веселку!","Вкуси дъгата!","Окуси дугу!","Γευτείτε το ουράνιο τόξο!" ,"Prove o arco-íris!"} }, - { "Chat bubbles: plain", { 0, "Chatbubbels: effen","Chat-Blasen: schlicht","Bulles : simples","Burbujas: lisas","Fumetti: semplici","Пузыри: простые","Бульбашки: прості","Балончета: семпли","Облачићи: обични","Φούσκες: απλές" ,"Balões de chat: simples"} }, - { "Accent popups: on", { 0, "Accent-popups: aan","Akzent-Popups: ein","Fenêtres d'accents : activé","Ventanas de acentos: activado","Popup accenti: attivi","Подсказки акцентов: вкл","Підказки акцентів: увімк","Изскачащи акценти: вкл","Искачући акценти: укљ","Αναδυόμενα τόνων: ναι" ,"Pop-ups de acento: ligados"} }, - { "Accent popups: off", { 0, "Accent-popups: uit","Akzent-Popups: aus","Fenêtres d'accents : désactivé","Ventanas de acentos: desactivado","Popup accenti: disattivi","Подсказки акцентов: выкл","Підказки акцентів: вимк","Изскачащи акценти: изкл","Искачући акценти: искљ","Αναδυόμενα τόνων: όχι" ,"Pop-ups de acento: desligados"} }, - { "Failed to save channel.", { 0, "Kan kanaal niet opslaan.","Kanal konnte nicht gespeichert werden.","Échec de l'enregistrement du canal.","Error al guardar el canal.","Impossibile salvare il canale.","Не удалось сохранить канал.","Не вдалося зберегти канал.","Каналът не можа да се запази.","Чување канала није успело.","Αποτυχία αποθήκευσης καναλιού." ,"Falha ao salvar o canal."} }, - { "Distance: km", { 0, "Afstand: km","Entfernung: km","Distance : km","Distancia: km","Distanza: km","Расстояние: км","Відстань: км","Разстояние: км","Растојање: km","Απόσταση: km" ,"Distância: km"} }, - { "Distance: miles", { 0, "Afstand: mijlen","Entfernung: Meilen","Distance : miles","Distancia: millas","Distanza: miglia","Расстояние: мили","Відстань: милі","Разстояние: мили","Растојање: миље","Απόσταση: μίλια" ,"Distância: milhas"} }, - { "Map tiles: microSD", { 0, "Kaarttegels: microSD","Kartenkacheln: microSD","Tuiles : microSD","Teselas: microSD","Riquadri: microSD","Тайлы карты: microSD","Плитки карти: microSD","Плочки: microSD","Плочице: microSD","Πλακίδια: microSD" ,"Tiles do mapa: microSD"} }, - { "Map tiles: server", { 0, "Kaarttegels: server","Kartenkacheln: Server","Tuiles : serveur","Teselas: servidor","Riquadri: server","Тайлы карты: сервер","Плитки карти: сервер","Плочки: сървър","Плочице: сервер","Πλακίδια: διακομιστής" ,"Tiles do mapa: servidor"} }, - { "Mark all chats and channels as read?", { 0, "Alle chats en kanalen als gelezen markeren?","Alle Chats und Kanäle als gelesen markieren?","Marquer tous les chats et canaux comme lus ?","¿Marcar todos los chats y canales como leídos?","Segnare tutte le chat e i canali come letti?","Отметить все чаты и каналы прочитанными?","Позначити всі чати й канали прочитаними?","Да отбележа всички чатове и канали като прочетени?","Означити сва ћаскања и канале као прочитане?","Σήμανση όλων ως αναγνωσμένα;" ,"Marcar todos os chats e canais como lidos?"} }, - { "Import this backup?\nReplaces identity,\nchannels & contacts,\nthen reboots.", { 0, "Deze back-up importeren?\nVervangt identiteit,\nkanalen & contacten,\nherstart daarna.","Dieses Backup importieren?\nErsetzt Identität,\nKanäle & Kontakte,\ndann Neustart.","Importer cette sauvegarde ?\nRemplace l'identité,\nles canaux & contacts,\npuis redémarre.","¿Importar esta copia?\nReemplaza identidad,\ncanales y contactos,\nluego reinicia.","Importare questo backup?\nSostituisce identità,\ncanali e contatti,\npoi riavvia.","Импортировать копию?\nЗаменит личность,\nканалы и контакты,\nзатем перезагрузка.","Імпортувати копію?\nЗамінить ідентичність,\nканали й контакти,\nпотім перезавантаження.","Да внеса този архив?\nЗаменя самоличност,\nканали и контакти,\nпосле рестартира.","Увези ову копију?\nЗамењује идентитет,\nканале и контакте,\nпа рестартује.","Εισαγωγή αντιγράφου;\nΑντικαθιστά ταυτότητα,\nκανάλια & επαφές,\nμετά επανεκκινεί." ,"Importar este backup?\nSubstitui identidade,\ncanais e contatos,\ndepois reinicia."} }, - { "Every node you talk to must match. You can change this later in Settings.", { 0, "Elke node waarmee je praat moet overeenkomen. Je kunt dit later wijzigen in Instellingen.","Jeder Knoten muss übereinstimmen. Sie können dies später in den Einstellungen ändern.","Chaque nœud doit correspondre. Modifiable plus tard dans Paramètres.","Cada nodo debe coincidir. Puedes cambiarlo luego en Ajustes.","Ogni nodo deve corrispondere. Modificabile dopo in Impostazioni.","Все узлы должны совпадать. Можно изменить позже в Настройках.","Усі вузли мають збігатися. Можна змінити пізніше в Налаштуваннях.","Всеки възел трябва да съвпада. Можете да промените по-късно в Настройки.","Сваки чвор мора да се поклапа. Касније у Подешавањима.","Κάθε κόμβος πρέπει να ταιριάζει. Αλλαγή αργότερα στις Ρυθμίσεις." ,"Todo nó com quem você fala deve coincidir. Você pode mudar isto depois em Ajustes."} }, - { "How you'll appear to other nodes. You can change this later in Settings.", { 0, "Hoe je verschijnt voor andere nodes. Je kunt dit later wijzigen in Instellingen.","Wie Sie für andere Knoten erscheinen. Später in den Einstellungen änderbar.","Comment vous apparaîtrez aux autres nœuds. Modifiable dans Paramètres.","Cómo aparecerás ante otros nodos. Puedes cambiarlo en Ajustes.","Come apparirai agli altri nodi. Modificabile in Impostazioni.","Как вы будете видны другим узлам. Можно изменить в Настройках.","Як вас бачитимуть інші вузли. Можна змінити в Налаштуваннях.","Как ще изглеждате за други възли. Промяна в Настройки.","Како ћете изгледати другим чворовима. У Подешавањима.","Πώς θα εμφανίζεστε σε άλλους κόμβους. Αλλαγή στις Ρυθμίσεις." ,"Como você aparecerá para outros nós. Você pode mudar isto depois em Ajustes."} }, - { "e.g. Family", { 0, "bijv. Familie","z. B. Familie","ex. Famille","p. ej. Familia","es. Famiglia","напр. Семья","напр. Сім'я","напр. Семейство","нпр. Породица","π.χ. Οικογένεια" ,"ex. Família"} }, - { "e.g. mesh", { 0, "bijv. mesh","z. B. mesh","ex. mesh","p. ej. mesh","es. mesh","напр. mesh","напр. mesh","напр. mesh","нпр. mesh","π.χ. mesh" ,"ex. mesh"} }, + { "Copy", { 0, "Kopiëren","Kopieren","Copier","Copiar","Copia","Копировать","Копіювати","Копирай","Копирај","Αντιγραφή" ,"Copiar","Copiază" } }, + { "Cut", { 0, "Knippen","Ausschneiden","Couper","Cortar","Taglia","Вырезать","Вирізати","Изрежи","Исеци","Αποκοπή" ,"Recortar","Decupează" } }, + { "Delete", { 0, "Verwijderen","Löschen","Supprimer","Eliminar","Elimina","Удалить","Видалити","Изтрий","Обриши","Διαγραφή" ,"Excluir","Şterge" } }, + { "Remove", { 0, "Verwijderen","Entfernen","Retirer","Quitar","Rimuovi","Убрать","Прибрати","Премахни","Уклони","Αφαίρεση" ,"Remover","Elimină" } }, + { "Rename", { 0, "Hernoemen","Umbenennen","Renommer","Renombrar","Rinomina","Переименовать","Перейменувати","Преименувай","Преименуј","Μετονομασία" ,"Renomear","Redenumeşte" } }, + { "Format", { 0, "Formatteren","Formatieren","Formater","Formatear","Formatta","Форматировать","Форматувати","Форматирай","Форматирај","Διαμόρφωση" ,"Formatar","Formatează" } }, + { "Paste", { 0, "Plakken","Einfügen","Coller","Pegar","Incolla","Вставить","Вставити","Постави","Налепи","Επικόλληση" ,"Colar","Lipeşte" } }, + { "Back", { 0, "Terug","Zurück","Retour","Atrás","Indietro","Назад","Назад","Назад","Назад","Πίσω" ,"Voltar","Înapoi" } }, + { "Reboot", { 0, "Herstarten","Neustart","Redémarrer","Reiniciar","Riavvia","Перезагрузка","Перезавантажити","Рестартирай","Рестартуј","Επανεκκίνηση" ,"Reiniciar","Reporneşte" } }, + { "Mark read", { 0, "Markeer als gelezen","Als gelesen markieren","Marquer comme lu","Marcar como leído","Segna come letto","Отметить прочитанным","Позначити прочитаним","Маркирай прочетено","Означи прочитано","Σήμανση ως αναγνωσμένο" ,"Marcar como lido","Marchează citit" } }, + { "New file", { 0, "Nieuw bestand","Neue Datei","Nouveau fichier","Nuevo archivo","Nuovo file","Новый файл","Новий файл","Нов файл","Нова датотека","Νέο αρχείο" ,"Novo arquivo","Fişier nou" } }, + { "New folder", { 0, "Nieuwe map","Neuer Ordner","Nouveau dossier","Nueva carpeta","Nuova cartella","Новая папка","Нова тека","Нова папка","Нова фасцикла","Νέος φάκελος" ,"Nova pasta","Folder nou" } }, + { "Folder created", { 0, "Map aangemaakt","Ordner erstellt","Dossier créé","Carpeta creada","Cartella creata","Папка создана","Теку створено","Папката е създадена","Фасцикла направљена","Ο φάκελος δημιουργήθηκε" ,"Pasta criada","Folder creat" } }, + { "Empty file", { 0, "Leeg bestand","Leere Datei","Fichier vide","Archivo vacío","File vuoto","Пустой файл","Порожній файл","Празен файл","Празна датотека","Κενό αρχείο" ,"Arquivo vazio","Fişier gol" } }, + { "Renamed", { 0, "Hernoemd","Umbenannt","Renommé","Renombrado","Rinominato","Переименовано","Перейменовано","Преименувано","Преименовано","Μετονομάστηκε" ,"Renomeado","Redenumit" } }, + { "Rename failed", { 0, "Hernoemen mislukt","Umbenennen fehlgeschlagen","Échec du renommage","Error al renombrar","Rinomina non riuscita","Не удалось переименовать","Не вдалося перейменувати","Преименуването неуспешно","Преименовање није успело","Αποτυχία μετονομασίας" ,"Falha ao renomear","Redenumire eşuată" } }, + { "Pasted", { 0, "Geplakt","Eingefügt","Collé","Pegado","Incollato","Вставлено","Вставлено","Поставено","Налепљено","Επικολλήθηκε" ,"Colado","Lipit" } }, + { "Paste failed", { 0, "Plakken mislukt","Einfügen fehlgeschlagen","Échec du collage","Error al pegar","Incolla non riuscito","Не удалось вставить","Не вдалося вставити","Поставянето неуспешно","Лепљење није успело","Αποτυχία επικόλλησης" ,"Falha ao colar","Lipire eşuată" } }, + { "Copied - Paste in a folder", { 0, "Gekopieerd - plak in een map","Kopiert - in einen Ordner einfügen","Copié - collez dans un dossier","Copiado - pega en una carpeta","Copiato - incolla in una cartella","Скопировано - вставьте в папку","Скопійовано - вставте в теку","Копирано - поставете в папка","Копирано - налепи у фасциклу","Αντιγράφηκε - επικολλήστε σε φάκελο" ,"Copiado - cole numa pasta","Copiat - lipeşte într-un folder" } }, + { "Cut - Paste in a folder", { 0, "Geknipt - plak in een map","Ausgeschnitten - in einen Ordner einfügen","Coupé - collez dans un dossier","Cortado - pega en una carpeta","Tagliato - incolla in una cartella","Вырезано - вставьте в папку","Вирізано - вставте в теку","Изрязано - поставете в папка","Исечено - налепи у фасциклу","Αποκόπηκε - επικολλήστε σε φάκελο" ,"Recortado - cole numa pasta","Decupat - lipeşte într-un folder" } }, + { "mkdir failed", { 0, "map maken mislukt","Ordner anlegen fehlgeschlagen","Échec de création du dossier","Error al crear carpeta","Creazione cartella non riuscita","Не удалось создать папку","Не вдалося створити теку","Създаването на папка неуспешно","Прављење фасцикле није успело","Αποτυχία δημιουργίας φακέλου" ,"falha no mkdir","mkdir eşuat" } }, + { "Image too large (>4 MB)", { 0, "Afbeelding te groot (>4 MB)","Bild zu groß (>4 MB)","Image trop grande (>4 Mo)","Imagen demasiado grande (>4 MB)","Immagine troppo grande (>4 MB)","Изображение слишком большое (>4 МБ)","Зображення завелике (>4 МБ)","Изображението е твърде голямо (>4 MB)","Слика превелика (>4 MB)","Πολύ μεγάλη εικόνα (>4 MB)" ,"Imagem grande demais (>4 MB)","Imagine prea mare (>4 MB)" } }, + { "Type a message...", { 0, "Typ een bericht...","Nachricht eingeben...","Saisissez un message...","Escribe un mensaje...","Scrivi un messaggio...","Введите сообщение...","Введіть повідомлення...","Въведете съобщение...","Унесите поруку...","Πληκτρολογήστε μήνυμα..." ,"Digite uma mensagem...","Scrie un mesaj..." } }, + { "search", { 0, "zoeken","Suche","rechercher","buscar","cerca","поиск","пошук","търсене","претрага","αναζήτηση" ,"buscar","caută" } }, + { "leave empty to generate", { 0, "leeg laten om te genereren","leer lassen zum Erzeugen","laisser vide pour générer","dejar vacío para generar","lascia vuoto per generare","оставьте пустым для генерации","залиште порожнім для генерації","оставете празно за генериране","оставите празно за генерисање","κενό για δημιουργία" ,"deixe vazio para gerar","lasă gol pentru generare" } }, + { "32 hex characters", { 0, "32 hex-tekens","32 Hex-Zeichen","32 caractères hex","32 caracteres hex","32 caratteri hex","32 hex-символа","32 hex-символи","32 hex знака","32 hex знака","32 hex χαρακτήρες" ,"32 caracteres hex","32 caractere hex" } }, + { "Enter exactly 32 hex characters.", { 0, "Voer precies 32 hex-tekens in.","Genau 32 Hex-Zeichen eingeben.","Saisissez exactement 32 caractères hex.","Introduce exactamente 32 caracteres hex.","Inserisci esattamente 32 caratteri hex.","Введите ровно 32 hex-символа.","Введіть рівно 32 hex-символи.","Въведете точно 32 hex знака.","Унесите тачно 32 hex знака.","Εισαγάγετε ακριβώς 32 hex χαρακτήρες." ,"Digite exatamente 32 caracteres hex.","Introdu exact 32 caractere hex." } }, + { "Secret must be 32 hex chars (or empty).", { 0, "Geheim moet 32 hex-tekens zijn (of leeg).","Geheimnis muss 32 Hex-Zeichen sein (oder leer).","Le secret doit faire 32 hex (ou vide).","El secreto debe tener 32 hex (o vacío).","Il segreto deve essere 32 hex (o vuoto).","Секрет — 32 hex-символа (или пусто).","Секрет — 32 hex-символи (або порожньо).","Тайната трябва да е 32 hex (или празно).","Тајна мора бити 32 hex (или празно).","Το μυστικό πρέπει 32 hex (ή κενό)." ,"O segredo deve ter 32 hex (ou vazio).","Secretul trebuie să aibă 32 caractere hex (sau gol)." } }, + { "Optional — type your network, or leave blank to skip.", { 0, "Optioneel — typ je netwerk, of laat leeg om over te slaan.","Optional — Netzwerk eingeben oder leer lassen.","Facultatif — saisissez votre réseau, ou laissez vide.","Opcional — escribe tu red, o déjalo en blanco.","Opzionale — digita la rete, o lascia vuoto.","Необязательно — введите сеть или оставьте пустым.","Необов'язково — введіть мережу або залиште порожнім.","По избор — въведете мрежата си или оставете празно.","Опционо — унесите мрежу или оставите празно.","Προαιρετικό — πληκτρολογήστε δίκτυο ή αφήστε κενό." ,"Opcional — digite sua rede, ou deixe em branco para pular.","Opţional — tastează reţeaua sau lasă gol pentru a omite." } }, + { "Saved", { 0, "Opgeslagen","Gespeichert","Enregistré","Guardado","Salvato","Сохранено","Збережено","Запазено","Сачувано","Αποθηκεύτηκε" ,"Salvo","Salvat" } }, + { "Advert sent", { 0, "Advert verzonden","Advert gesendet","Annonce envoyée","Anuncio enviado","Advert inviato","Advert отправлен","Advert надіслано","Advert изпратен","Advert послат","Στάλθηκε advert" ,"Advert enviado","Advert trimis" } }, + { "Advert failed", { 0, "Advert mislukt","Advert fehlgeschlagen","Échec de l'annonce","Error en el anuncio","Advert non riuscito","Сбой advert","Помилка advert","Advert неуспешен","Advert није успео","Αποτυχία advert" ,"Falha no advert","Advert eşuat" } }, + { "Flood advert sent", { 0, "Flood-advert verzonden","Flood-Advert gesendet","Annonce flood envoyée","Anuncio flood enviado","Advert flood inviato","Flood advert отправлен","Flood advert надіслано","Flood advert изпратен","Flood advert послат","Στάλθηκε flood advert" ,"Flood advert enviado","Advert flood trimis" } }, + { "Zero-hop advert sent", { 0, "Zero-hop advert verzonden","Zero-Hop-Advert gesendet","Annonce zero-hop envoyée","Anuncio zero-hop enviado","Advert zero-hop inviato","Zero-hop advert отправлен","Zero-hop advert надіслано","Zero-hop advert изпратен","Zero-hop advert послат","Στάλθηκε zero-hop advert" ,"Zero-hop advert enviado","Advert zero-hop trimis" } }, + { "Radio applied", { 0, "Radio toegepast","Funk angewendet","Radio appliquée","Radio aplicada","Radio applicata","Радио применено","Радіо застосовано","Радиото е приложено","Радио примењен","Εφαρμόστηκε ράδιο" ,"Rádio aplicado","Radio aplicat" } }, + { "Radio + region set", { 0, "Radio + regio ingesteld","Funk + Region gesetzt","Radio + région définies","Radio + región establecidas","Radio + regione impostate","Радио + регион заданы","Радіо + регіон задано","Радио + регион зададени","Радио + регион постављени","Ορίστηκαν ράδιο + περιοχή" ,"Rádio + região definidos","Radio + regiune setate" } }, + { "Chat removed", { 0, "Chat verwijderd","Chat entfernt","Discussion supprimée","Chat eliminado","Chat rimossa","Чат удалён","Чат видалено","Чатът е премахнат","Ћаскање уклоњено","Η συνομιλία αφαιρέθηκε" ,"Chat removido","Chat eliminat" } }, + { "Channel removed", { 0, "Kanaal verwijderd","Kanal entfernt","Canal supprimé","Canal eliminado","Canale rimosso","Канал удалён","Канал видалено","Каналът е премахнат","Канал уклоњен","Το κανάλι αφαιρέθηκε" ,"Canal removido","Canal eliminat" } }, + { "Sender blocked", { 0, "Afzender geblokkeerd","Absender blockiert","Expéditeur bloqué","Remitente bloqueado","Mittente bloccato","Отправитель заблокирован","Відправника заблоковано","Подателят е блокиран","Пошиљалац блокиран","Ο αποστολέας αποκλείστηκε" ,"Remetente bloqueado","Expeditor blocat" } }, + { "Can't block — not a contact", { 0, "Kan niet blokkeren — geen contact","Blockieren nicht möglich — kein Kontakt","Blocage impossible — pas un contact","No se puede bloquear — no es un contacto","Impossibile bloccare — non è un contatto","Нельзя заблокировать — не контакт","Не можна заблокувати — не контакт","Не може да се блокира — не е контакт","Не могу да блокирам — није контакт","Αδυναμία αποκλεισμού — δεν είναι επαφή" ,"Não dá para bloquear — não é contato","Nu se poate bloca — nu e contact" } }, + { "Added to favorites", { 0, "Toegevoegd aan favorieten","Zu Favoriten hinzugefügt","Ajouté aux favoris","Añadido a favoritos","Aggiunto ai preferiti","Добавлено в избранное","Додано до обраного","Добавено в любими","Додато у омиљене","Προστέθηκε στα αγαπημένα" ,"Adicionado aos favoritos","Adăugat la favorite" } }, + { "Removed from favorites", { 0, "Verwijderd uit favorieten","Aus Favoriten entfernt","Retiré des favoris","Quitado de favoritos","Rimosso dai preferiti","Удалено из избранного","Видалено з обраного","Премахнато от любими","Уклоњено из омиљених","Αφαιρέθηκε από τα αγαπημένα" ,"Removido dos favoritos","Eliminat de la favorite" } }, + { "Taste the rainbow!", { 0, "Proef de regenboog!","Schmecke den Regenbogen!","Goûtez l'arc-en-ciel !","¡Saborea el arcoíris!","Assapora l'arcobaleno!","Попробуй радугу!","Скуштуй веселку!","Вкуси дъгата!","Окуси дугу!","Γευτείτε το ουράνιο τόξο!" ,"Prove o arco-íris!","Gustă curcubeul!" } }, + { "Chat bubbles: plain", { 0, "Chatbubbels: effen","Chat-Blasen: schlicht","Bulles : simples","Burbujas: lisas","Fumetti: semplici","Пузыри: простые","Бульбашки: прості","Балончета: семпли","Облачићи: обични","Φούσκες: απλές" ,"Balões de chat: simples","Baloane de chat: simple" } }, + { "Accent popups: on", { 0, "Accent-popups: aan","Akzent-Popups: ein","Fenêtres d'accents : activé","Ventanas de acentos: activado","Popup accenti: attivi","Подсказки акцентов: вкл","Підказки акцентів: увімк","Изскачащи акценти: вкл","Искачући акценти: укљ","Αναδυόμενα τόνων: ναι" ,"Pop-ups de acento: ligados","Popupuri accente: pornite" } }, + { "Accent popups: off", { 0, "Accent-popups: uit","Akzent-Popups: aus","Fenêtres d'accents : désactivé","Ventanas de acentos: desactivado","Popup accenti: disattivi","Подсказки акцентов: выкл","Підказки акцентів: вимк","Изскачащи акценти: изкл","Искачући акценти: искљ","Αναδυόμενα τόνων: όχι" ,"Pop-ups de acento: desligados","Popupuri accente: oprite" } }, + { "Failed to save channel.", { 0, "Kan kanaal niet opslaan.","Kanal konnte nicht gespeichert werden.","Échec de l'enregistrement du canal.","Error al guardar el canal.","Impossibile salvare il canale.","Не удалось сохранить канал.","Не вдалося зберегти канал.","Каналът не можа да се запази.","Чување канала није успело.","Αποτυχία αποθήκευσης καναλιού." ,"Falha ao salvar o canal.","Salvarea canalului a eşuat." } }, + { "Distance: km", { 0, "Afstand: km","Entfernung: km","Distance : km","Distancia: km","Distanza: km","Расстояние: км","Відстань: км","Разстояние: км","Растојање: km","Απόσταση: km" ,"Distância: km","Distanţă: km" } }, + { "Distance: miles", { 0, "Afstand: mijlen","Entfernung: Meilen","Distance : miles","Distancia: millas","Distanza: miglia","Расстояние: мили","Відстань: милі","Разстояние: мили","Растојање: миље","Απόσταση: μίλια" ,"Distância: milhas","Distanţă: mile" } }, + { "Map tiles: microSD", { 0, "Kaarttegels: microSD","Kartenkacheln: microSD","Tuiles : microSD","Teselas: microSD","Riquadri: microSD","Тайлы карты: microSD","Плитки карти: microSD","Плочки: microSD","Плочице: microSD","Πλακίδια: microSD" ,"Tiles do mapa: microSD","Dale hartă: microSD" } }, + { "Map tiles: server", { 0, "Kaarttegels: server","Kartenkacheln: Server","Tuiles : serveur","Teselas: servidor","Riquadri: server","Тайлы карты: сервер","Плитки карти: сервер","Плочки: сървър","Плочице: сервер","Πλακίδια: διακομιστής" ,"Tiles do mapa: servidor","Dale hartă: server" } }, + { "Mark all chats and channels as read?", { 0, "Alle chats en kanalen als gelezen markeren?","Alle Chats und Kanäle als gelesen markieren?","Marquer tous les chats et canaux comme lus ?","¿Marcar todos los chats y canales como leídos?","Segnare tutte le chat e i canali come letti?","Отметить все чаты и каналы прочитанными?","Позначити всі чати й канали прочитаними?","Да отбележа всички чатове и канали като прочетени?","Означити сва ћаскања и канале као прочитане?","Σήμανση όλων ως αναγνωσμένα;" ,"Marcar todos os chats e canais como lidos?","Marchezi toate chaturile şi canalele ca citite?" } }, + { "Import this backup?\nReplaces identity,\nchannels & contacts,\nthen reboots.", { 0, "Deze back-up importeren?\nVervangt identiteit,\nkanalen & contacten,\nherstart daarna.","Dieses Backup importieren?\nErsetzt Identität,\nKanäle & Kontakte,\ndann Neustart.","Importer cette sauvegarde ?\nRemplace l'identité,\nles canaux & contacts,\npuis redémarre.","¿Importar esta copia?\nReemplaza identidad,\ncanales y contactos,\nluego reinicia.","Importare questo backup?\nSostituisce identità,\ncanali e contatti,\npoi riavvia.","Импортировать копию?\nЗаменит личность,\nканалы и контакты,\nзатем перезагрузка.","Імпортувати копію?\nЗамінить ідентичність,\nканали й контакти,\nпотім перезавантаження.","Да внеса този архив?\nЗаменя самоличност,\nканали и контакти,\nпосле рестартира.","Увези ову копију?\nЗамењује идентитет,\nканале и контакте,\nпа рестартује.","Εισαγωγή αντιγράφου;\nΑντικαθιστά ταυτότητα,\nκανάλια & επαφές,\nμετά επανεκκινεί." ,"Importar este backup?\nSubstitui identidade,\ncanais e contatos,\ndepois reinicia.","Imporţi acest backup?\nÎnlocuieşte identitatea,\ncanalele şi contactele,\napoi reporneşte." } }, + { "Every node you talk to must match. You can change this later in Settings.", { 0, "Elke node waarmee je praat moet overeenkomen. Je kunt dit later wijzigen in Instellingen.","Jeder Knoten muss übereinstimmen. Sie können dies später in den Einstellungen ändern.","Chaque nœud doit correspondre. Modifiable plus tard dans Paramètres.","Cada nodo debe coincidir. Puedes cambiarlo luego en Ajustes.","Ogni nodo deve corrispondere. Modificabile dopo in Impostazioni.","Все узлы должны совпадать. Можно изменить позже в Настройках.","Усі вузли мають збігатися. Можна змінити пізніше в Налаштуваннях.","Всеки възел трябва да съвпада. Можете да промените по-късно в Настройки.","Сваки чвор мора да се поклапа. Касније у Подешавањима.","Κάθε κόμβος πρέπει να ταιριάζει. Αλλαγή αργότερα στις Ρυθμίσεις." ,"Todo nó com quem você fala deve coincidir. Você pode mudar isto depois em Ajustes.","Fiecare nod cu care vorbeşti trebuie să corespundă. Poţi schimba asta mai târziu în Setări." } }, + { "How you'll appear to other nodes. You can change this later in Settings.", { 0, "Hoe je verschijnt voor andere nodes. Je kunt dit later wijzigen in Instellingen.","Wie Sie für andere Knoten erscheinen. Später in den Einstellungen änderbar.","Comment vous apparaîtrez aux autres nœuds. Modifiable dans Paramètres.","Cómo aparecerás ante otros nodos. Puedes cambiarlo en Ajustes.","Come apparirai agli altri nodi. Modificabile in Impostazioni.","Как вы будете видны другим узлам. Можно изменить в Настройках.","Як вас бачитимуть інші вузли. Можна змінити в Налаштуваннях.","Как ще изглеждате за други възли. Промяна в Настройки.","Како ћете изгледати другим чворовима. У Подешавањима.","Πώς θα εμφανίζεστε σε άλλους κόμβους. Αλλαγή στις Ρυθμίσεις." ,"Como você aparecerá para outros nós. Você pode mudar isto depois em Ajustes.","Cum vei apărea altor noduri. Poţi schimba asta mai târziu în Setări." } }, + { "e.g. Family", { 0, "bijv. Familie","z. B. Familie","ex. Famille","p. ej. Familia","es. Famiglia","напр. Семья","напр. Сім'я","напр. Семейство","нпр. Породица","π.χ. Οικογένεια" ,"ex. Família","ex. Familie" } }, + { "e.g. mesh", { 0, "bijv. mesh","z. B. mesh","ex. mesh","p. ej. mesh","es. mesh","напр. mesh","напр. mesh","напр. mesh","нпр. mesh","π.χ. mesh" ,"ex. mesh","ex. mesh" } }, // ---- Sweep pass 8 (final): snprintf status / info lines ---- - { "GPS: fix", { 0, "GPS: fix","GPS: Fix","GPS : point","GPS: fijado","GPS: fix","GPS: есть","GPS: фікс","GPS: фикс","GPS: фикс","GPS: στίγμα" ,"GPS: fixado"} }, - { "GPS: off", { 0, "GPS: uit","GPS: aus","GPS : désactivé","GPS: apagado","GPS: spento","GPS: выкл","GPS: вимк","GPS: изкл","GPS: искљ","GPS: ανενεργό" ,"GPS: desligado"} }, - { "GPS: acquiring...", { 0, "GPS: zoeken...","GPS: suche...","GPS : acquisition...","GPS: buscando...","GPS: ricerca...","GPS: поиск...","GPS: пошук...","GPS: търсене...","GPS: тражим...","GPS: αναζήτηση..." ,"GPS: adquirindo..."} }, - { "GPS unset", { 0, "GPS niet ingesteld","GPS nicht gesetzt","GPS non défini","GPS sin definir","GPS non impostato","GPS не задан","GPS не задано","GPS не е зададен","GPS није постављен","GPS μη ορισμένο" ,"GPS não definido"} }, - { "(RTC unset)", { 0, "(RTC niet ingesteld)","(RTC nicht gesetzt)","(RTC non réglée)","(RTC sin ajustar)","(RTC non impostato)","(RTC не задан)","(RTC не задано)","(RTC не е зададен)","(RTC није постављен)","(RTC μη ορισμένο)" ,"(RTC não definido)"} }, - { "Screen timeout: %ds", { 0, "Schermtime-out: %ds","Bildschirm-Timeout: %ds","Délai d'écran : %ds","Tiempo de pantalla: %ds","Timeout schermo: %ds","Тайм-аут экрана: %dс","Тайм-аут екрана: %dс","Изключване на екрана: %dс","Истек екрана: %ds","Λήξη οθόνης: %ds" ,"Tempo de tela: %ds"} }, - { "Screen timeout: never", { 0, "Schermtime-out: nooit","Bildschirm-Timeout: nie","Délai d'écran : jamais","Tiempo de pantalla: nunca","Timeout schermo: mai","Тайм-аут экрана: никогда","Тайм-аут екрана: ніколи","Изключване на екрана: никога","Истек екрана: никад","Λήξη οθόνης: ποτέ" ,"Tempo de tela: nunca"} }, - { "Time offset: %+d h", { 0, "Tijdverschuiving: %+d u","Zeitversatz: %+d h","Décalage horaire : %+d h","Desfase horario: %+d h","Sfasamento orario: %+d h","Смещение времени: %+d ч","Зсув часу: %+d год","Изместване: %+d ч","Помак времена: %+d h","Μετατόπιση ώρας: %+d h" ,"Fuso horário: %+d h"} }, - { "Time %s", { 0, "Tijd %s","Zeit %s","Heure %s","Hora %s","Ora %s","Время %s","Час %s","Време %s","Време %s","Ώρα %s" ,"Hora %s"} }, - { "Pairing code: %06lu", { 0, "Koppelcode: %06lu","Kopplungscode: %06lu","Code d'appairage : %06lu","Código de emparejamiento: %06lu","Codice di accoppiamento: %06lu","Код сопряжения: %06lu","Код сполучення: %06lu","Код за сдвояване: %06lu","Код за упаривање: %06lu","Κωδικός σύζευξης: %06lu" ,"Código de pareamento: %06lu"} }, - { "Pairing code: n/a", { 0, "Koppelcode: n.v.t.","Kopplungscode: n. v.","Code d'appairage : n/d","Código de emparejamiento: n/d","Codice di accoppiamento: n/d","Код сопряжения: н/д","Код сполучення: н/д","Код за сдвояване: н/п","Код за упаривање: н/д","Κωδικός σύζευξης: μ/δ" ,"Código de pareamento: n/d"} }, - { "Path hash: %u byte%s", { 0, "Pad-hash: %u byte%s","Pfad-Hash: %u Byte%s","Hash de chemin : %u octet%s","Hash de ruta: %u byte%s","Hash percorso: %u byte%s","Хеш пути: %u байт%s","Хеш шляху: %u байт%s","Хеш на пътя: %u байт%s","Хеш путање: %u бајт%s","Hash διαδρομής: %u byte%s" ,"Hash de rota: %u byte%s"} }, - { "Calibrated: 100%% = %u.%02u V (long-press to reset)", { 0, "Gekalibreerd: 100%% = %u.%02u V (lang indrukken om te resetten)","Kalibriert: 100%% = %u.%02u V (lang drücken zum Zurücksetzen)","Étalonné : 100%% = %u.%02u V (appui long pour réinitialiser)","Calibrado: 100%% = %u.%02u V (mantén para restablecer)","Calibrato: 100%% = %u.%02u V (tieni premuto per ripristinare)","Калибровано: 100%% = %u.%02u В (долгое нажатие — сброс)","Калібровано: 100%% = %u.%02u В (довге натискання — скидання)","Калибрирано: 100%% = %u.%02u V (задръжте за нулиране)","Калибрисано: 100%% = %u.%02u V (дуго за ресет)","Βαθμονομήθηκε: 100%% = %u.%02u V (παρατεταμένο για επαναφορά)" ,"Calibrado: 100%% = %u.%02u V (segure para redefinir)"} }, - { "Centered on %s", { 0, "Gecentreerd op %s","Zentriert auf %s","Centré sur %s","Centrado en %s","Centrato su %s","Центрировано на %s","Центровано на %s","Центрирано на %s","Центрирано на %s","Κεντραρισμένο σε %s" ,"Centralizado em %s"} }, - { "Copied %s", { 0, "%s gekopieerd","%s kopiert","%s copié","%s copiado","%s copiato","%s скопировано","%s скопійовано","%s копирано","%s копирано","Αντιγράφηκε %s" ,"%s copiado"} }, - { "No reply from %s", { 0, "Geen antwoord van %s","Keine Antwort von %s","Pas de réponse de %s","Sin respuesta de %s","Nessuna risposta da %s","Нет ответа от %s","Немає відповіді від %s","Няма отговор от %s","Нема одговора од %s","Καμία απάντηση από %s" ,"Sem resposta de %s"} }, - { "Joined %.40s", { 0, "Deelgenomen aan %.40s","%.40s beigetreten","Rejoint %.40s","Unido a %.40s","Unito a %.40s","Вы вошли в %.40s","Приєднано до %.40s","Присъединено към %.40s","Придружено %.40s","Συμμετοχή σε %.40s" ,"Entrou em %.40s"} }, - { "Login OK (perms %u)", { 0, "Inloggen OK (rechten %u)","Anmeldung OK (Rechte %u)","Connexion OK (perms %u)","Sesión OK (perms %u)","Accesso OK (permessi %u)","Вход OK (права %u)","Вхід OK (права %u)","Вход OK (права %u)","Пријава OK (дозволе %u)","Σύνδεση OK (δικαι. %u)" ,"Login OK (perms %u)"} }, - { "Saved to slot %d", { 0, "Opgeslagen in slot %d","In Slot %d gespeichert","Enregistré dans l'emplacement %d","Guardado en ranura %d","Salvato nello slot %d","Сохранено в слот %d","Збережено в слот %d","Запазено в слот %d","Сачувано у слот %d","Αποθηκεύτηκε στη θέση %d" ,"Salvo no slot %d"} }, - { "Slot %d cleared", { 0, "Slot %d gewist","Slot %d gelöscht","Emplacement %d effacé","Ranura %d borrada","Slot %d cancellato","Слот %d очищен","Слот %d очищено","Слот %d изчистен","Слот %d обрисан","Η θέση %d εκκαθαρίστηκε" ,"Slot %d limpo"} }, - { "now on channel %s", { 0, "nu op kanaal %s","jetzt auf Kanal %s","maintenant sur le canal %s","ahora en el canal %s","ora sul canale %s","теперь в канале %s","тепер у каналі %s","сега в канал %s","сада на каналу %s","τώρα στο κανάλι %s" ,"agora no canal %s"} }, - { "now talking to %s", { 0, "praat nu met %s","spricht jetzt mit %s","conversation avec %s","ahora hablando con %s","ora in chat con %s","теперь общаетесь с %s","тепер спілкуєтесь з %s","сега говорите с %s","сада причате са %s","τώρα μιλάτε με %s" ,"agora falando com %s"} }, - { "Delete chat with \"%s\"?\nMessage history will be cleared.", { 0, "Chat met \"%s\" verwijderen?\nBerichtgeschiedenis wordt gewist.","Chat mit \"%s\" löschen?\nNachrichtenverlauf wird gelöscht.","Supprimer la discussion avec \"%s\" ?\nL'historique sera effacé.","¿Eliminar el chat con \"%s\"?\nSe borrará el historial.","Eliminare la chat con \"%s\"?\nLa cronologia sarà cancellata.","Удалить чат с \"%s\"?\nИстория будет очищена.","Видалити чат із \"%s\"?\nІсторію буде очищено.","Изтриване на чата с \"%s\"?\nИсторията ще се изчисти.","Обрисати ћаскање са \"%s\"?\nИсторија ће бити обрисана.","Διαγραφή συνομιλίας με \"%s\";\nΤο ιστορικό θα διαγραφεί." ,"Excluir chat com \"%s\"?\nO histórico de mensagens será apagado."} }, - { "Remove channel \"%s\"?\nLeaves it on this device only.", { 0, "Kanaal \"%s\" verwijderen?\nAlleen van dit apparaat.","Kanal \"%s\" entfernen?\nNur von diesem Gerät.","Retirer le canal \"%s\" ?\nUniquement de cet appareil.","¿Quitar el canal \"%s\"?\nSolo de este dispositivo.","Rimuovere il canale \"%s\"?\nSolo da questo dispositivo.","Удалить канал \"%s\"?\nТолько с этого устройства.","Видалити канал \"%s\"?\nЛише з цього пристрою.","Премахване на канал \"%s\"?\nСамо от това устройство.","Уклонити канал \"%s\"?\nСамо са овог уређаја.","Αφαίρεση καναλιού \"%s\";\nΜόνο από αυτή τη συσκευή." ,"Remover canal \"%s\"?\nDeixa-o só neste aparelho."} }, - { "Firmware beta_%d\nCouldn't reach the update server", { 0, "Firmware beta_%d\nKon de updateserver niet bereiken","Firmware beta_%d\nUpdate-Server nicht erreichbar","Firmware beta_%d\nServeur de mise à jour injoignable","Firmware beta_%d\nNo se pudo contactar el servidor","Firmware beta_%d\nServer di aggiornamento irraggiungibile","Прошивка beta_%d\nСервер обновлений недоступен","Прошивка beta_%d\nСервер оновлень недоступний","Фърмуер beta_%d\nСървърът за обновяване е недостъпен","Фирмвер beta_%d\nСервер ажурирања недоступан","Firmware beta_%d\nΑδυναμία πρόσβασης στον διακομιστή" ,"Firmware beta_%d\nNão foi possível acessar o servidor de atualização"} }, - { "Internal storage %s / %s", { 0, "Interne opslag %s / %s","Interner Speicher %s / %s","Stockage interne %s / %s","Almacen. interno %s / %s","Archiviazione interna %s / %s","Внутр. память %s / %s","Внутр. пам'ять %s / %s","Вътрешна памет %s / %s","Интерна меморија %s / %s","Εσωτ. χώρος %s / %s" ,"Armazenamento interno %s / %s"} }, - { "SD card %s (hold: format)", { 0, "SD-kaart %s (vasthouden: formatteren)","SD-Karte %s (halten: formatieren)","Carte SD %s (maintenir : formater)","Tarjeta SD %s (mantener: formatear)","Scheda SD %s (tieni: formatta)","SD-карта %s (удерж.: формат.)","SD-картка %s (утрим.: формат.)","SD карта %s (задръж: формат)","SD картица %s (држи: формат)","Κάρτα SD %s (κράτημα: διαμόρφωση)" ,"Cartão SD %s (segure: formatar)"} }, - { "SD formatted - %s (MESHCOMOD)", { 0, "SD geformatteerd - %s (MESHCOMOD)","SD formatiert - %s (MESHCOMOD)","SD formatée - %s (MESHCOMOD)","SD formateada - %s (MESHCOMOD)","SD formattata - %s (MESHCOMOD)","SD отформатирована - %s (MESHCOMOD)","SD відформатовано - %s (MESHCOMOD)","SD форматирана - %s (MESHCOMOD)","SD форматирана - %s (MESHCOMOD)","SD διαμορφώθηκε - %s (MESHCOMOD)" ,"SD formatado - %s (MESHCOMOD)"} }, - { "SPIFFS %u/%u KB used", { 0, "SPIFFS %u/%u KB gebruikt","SPIFFS %u/%u KB belegt","SPIFFS %u/%u Ko utilisés","SPIFFS %u/%u KB usados","SPIFFS %u/%u KB usati","SPIFFS %u/%u КБ занято","SPIFFS %u/%u КБ використано","SPIFFS %u/%u KB използвани","SPIFFS %u/%u KB заузето","SPIFFS %u/%u KB σε χρήση" ,"SPIFFS %u/%u KB usados"} }, - { "contacts (%d):", { 0, "contacten (%d):","Kontakte (%d):","contacts (%d) :","contactos (%d):","contatti (%d):","контакты (%d):","контакти (%d):","контакти (%d):","контакти (%d):","επαφές (%d):" ,"contatos (%d):"} }, - { "%d on map", { 0, "%d op kaart","%d auf Karte","%d sur la carte","%d en el mapa","%d sulla mappa","%d на карте","%d на карті","%d на картата","%d на мапи","%d στον χάρτη" ,"%d no mapa"} }, - { "Data -> SD card on reboot\n(card must be inserted)", { 0, "Gegevens -> SD-kaart bij herstart\n(kaart moet geplaatst zijn)","Daten -> SD-Karte bei Neustart\n(Karte muss eingelegt sein)","Données -> carte SD au redémarrage\n(carte requise)","Datos -> tarjeta SD al reiniciar\n(tarjeta requerida)","Dati -> scheda SD al riavvio\n(scheda richiesta)","Данные -> SD-карта при перезагрузке\n(нужна карта)","Дані -> SD-картка при перезавантаженні\n(потрібна картка)","Данни -> SD карта при рестарт\n(нужна е карта)","Подаци -> SD картица при рестарту\n(потребна картица)","Δεδομένα -> κάρτα SD στην επανεκκίνηση\n(απαιτείται κάρτα)" ,"Dados -> cartão SD ao reiniciar\n(cartão deve estar inserido)"} }, - { "Data -> internal on reboot", { 0, "Gegevens -> intern bij herstart","Daten -> intern bei Neustart","Données -> interne au redémarrage","Datos -> interno al reiniciar","Dati -> interno al riavvio","Данные -> внутр. при перезагрузке","Дані -> внутр. при перезавантаженні","Данни -> вътрешна при рестарт","Подаци -> интерно при рестарту","Δεδομένα -> εσωτερικά στην επανεκκίνηση" ,"Dados -> interno ao reiniciar"} }, - { "RangeTest sent (direct)", { 0, "RangeTest verzonden (direct)","RangeTest gesendet (direkt)","RangeTest envoyé (direct)","RangeTest enviado (directo)","RangeTest inviato (diretto)","RangeTest отправлен (прямой)","RangeTest надіслано (прямий)","RangeTest изпратен (директен)","RangeTest послат (директно)","Στάλθηκε RangeTest (άμεσο)" ,"RangeTest enviado (direto)"} }, - { "RangeTest sent (flood)", { 0, "RangeTest verzonden (flood)","RangeTest gesendet (Flood)","RangeTest envoyé (flood)","RangeTest enviado (flood)","RangeTest inviato (flood)","RangeTest отправлен (flood)","RangeTest надіслано (flood)","RangeTest изпратен (flood)","RangeTest послат (flood)","Στάλθηκε RangeTest (flood)" ,"RangeTest enviado (flood)"} }, - { "Telemetry req (direct)…", { 0, "Telemetrie-verzoek (direct)…","Telemetrie-Anfrage (direkt)…","Requête télémétrie (direct)…","Solicitud telemetría (directo)…","Richiesta telemetria (diretto)…","Запрос телеметрии (прямой)…","Запит телеметрії (прямий)…","Заявка за телеметрия (директен)…","Захтев телеметрије (директно)…","Αίτημα τηλεμετρίας (άμεσο)…" ,"Req. de telemetria (direto)…"} }, - { "Telemetry req (flood)…", { 0, "Telemetrie-verzoek (flood)…","Telemetrie-Anfrage (Flood)…","Requête télémétrie (flood)…","Solicitud telemetría (flood)…","Richiesta telemetria (flood)…","Запрос телеметрии (flood)…","Запит телеметрії (flood)…","Заявка за телеметрия (flood)…","Захтев телеметрије (flood)…","Αίτημα τηλεμετρίας (flood)…" ,"Req. de telemetria (flood)…"} }, - { "Pinged (direct) — waiting…", { 0, "Ping (direct) — wachten…","Ping (direkt) — warte…","Ping (direct) — attente…","Ping (directo) — esperando…","Ping (diretto) — attesa…","Ping (прямой) — ожидание…","Ping (прямий) — очікування…","Ping (директен) — изчакване…","Ping (директно) — чекање…","Ping (άμεσο) — αναμονή…" ,"Ping (direto) — aguardando…"} }, - { "Pinged (flood) — waiting…", { 0, "Ping (flood) — wachten…","Ping (Flood) — warte…","Ping (flood) — attente…","Ping (flood) — esperando…","Ping (flood) — attesa…","Ping (flood) — ожидание…","Ping (flood) — очікування…","Ping (flood) — изчакване…","Ping (flood) — чекање…","Ping (flood) — αναμονή…" ,"Ping (flood) — aguardando…"} }, - { "Unread %d", { 0, "Ongelezen %d","Ungelesen %d","Non lus %d","Sin leer %d","Non letti %d","Непроч. %d","Непроч. %d","Непроч. %d","Непроч. %d","Μη αναγν. %d" ,"Não lidas %d"} }, - { "Unread %d\nRAM %u%% · PSRAM %u%%", { 0, "Ongelezen %d\nRAM %u%% · PSRAM %u%%","Ungelesen %d\nRAM %u%% · PSRAM %u%%","Non lus %d\nRAM %u%% · PSRAM %u%%","Sin leer %d\nRAM %u%% · PSRAM %u%%","Non letti %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Μη αναγν. %d\nRAM %u%% · PSRAM %u%%" ,"Não lidas %d\nRAM %u%% · PSRAM %u%%"} }, - { "Analyzing terrain…\n(retry %d of 3)", { 0, "Terrein analyseren…\n(poging %d van 3)","Gelände wird analysiert…\n(Versuch %d von 3)","Analyse du terrain…\n(essai %d sur 3)","Analizando terreno…\n(intento %d de 3)","Analisi del terreno…\n(tentativo %d di 3)","Анализ рельефа…\n(попытка %d из 3)","Аналіз рельєфу…\n(спроба %d з 3)","Анализ на терена…\n(опит %d от 3)","Анализа терена…\n(покушај %d од 3)","Ανάλυση εδάφους…\n(προσπάθεια %d από 3)" ,"Analisando o terreno…\n(tentativa %d de 3)"} }, + { "GPS: fix", { 0, "GPS: fix","GPS: Fix","GPS : point","GPS: fijado","GPS: fix","GPS: есть","GPS: фікс","GPS: фикс","GPS: фикс","GPS: στίγμα" ,"GPS: fixado","GPS: fix" } }, + { "GPS: off", { 0, "GPS: uit","GPS: aus","GPS : désactivé","GPS: apagado","GPS: spento","GPS: выкл","GPS: вимк","GPS: изкл","GPS: искљ","GPS: ανενεργό" ,"GPS: desligado","GPS: oprit" } }, + { "GPS: acquiring...", { 0, "GPS: zoeken...","GPS: suche...","GPS : acquisition...","GPS: buscando...","GPS: ricerca...","GPS: поиск...","GPS: пошук...","GPS: търсене...","GPS: тражим...","GPS: αναζήτηση..." ,"GPS: adquirindo...","GPS: căutare semnal..." } }, + { "GPS unset", { 0, "GPS niet ingesteld","GPS nicht gesetzt","GPS non défini","GPS sin definir","GPS non impostato","GPS не задан","GPS не задано","GPS не е зададен","GPS није постављен","GPS μη ορισμένο" ,"GPS não definido","GPS nesetat" } }, + { "(RTC unset)", { 0, "(RTC niet ingesteld)","(RTC nicht gesetzt)","(RTC non réglée)","(RTC sin ajustar)","(RTC non impostato)","(RTC не задан)","(RTC не задано)","(RTC не е зададен)","(RTC није постављен)","(RTC μη ορισμένο)" ,"(RTC não definido)","(RTC nesetat)" } }, + { "Screen timeout: %ds", { 0, "Schermtime-out: %ds","Bildschirm-Timeout: %ds","Délai d'écran : %ds","Tiempo de pantalla: %ds","Timeout schermo: %ds","Тайм-аут экрана: %dс","Тайм-аут екрана: %dс","Изключване на екрана: %dс","Истек екрана: %ds","Λήξη οθόνης: %ds" ,"Tempo de tela: %ds","Stingere ecran: %ds" } }, + { "Screen timeout: never", { 0, "Schermtime-out: nooit","Bildschirm-Timeout: nie","Délai d'écran : jamais","Tiempo de pantalla: nunca","Timeout schermo: mai","Тайм-аут экрана: никогда","Тайм-аут екрана: ніколи","Изключване на екрана: никога","Истек екрана: никад","Λήξη οθόνης: ποτέ" ,"Tempo de tela: nunca","Stingere ecran: niciodată" } }, + { "Time offset: %+d h", { 0, "Tijdverschuiving: %+d u","Zeitversatz: %+d h","Décalage horaire : %+d h","Desfase horario: %+d h","Sfasamento orario: %+d h","Смещение времени: %+d ч","Зсув часу: %+d год","Изместване: %+d ч","Помак времена: %+d h","Μετατόπιση ώρας: %+d h" ,"Fuso horário: %+d h","Decalaj orar: %+d h" } }, + { "Time %s", { 0, "Tijd %s","Zeit %s","Heure %s","Hora %s","Ora %s","Время %s","Час %s","Време %s","Време %s","Ώρα %s" ,"Hora %s","Ora %s" } }, + { "Pairing code: %06lu", { 0, "Koppelcode: %06lu","Kopplungscode: %06lu","Code d'appairage : %06lu","Código de emparejamiento: %06lu","Codice di accoppiamento: %06lu","Код сопряжения: %06lu","Код сполучення: %06lu","Код за сдвояване: %06lu","Код за упаривање: %06lu","Κωδικός σύζευξης: %06lu" ,"Código de pareamento: %06lu","Cod de asociere: %06lu" } }, + { "Pairing code: n/a", { 0, "Koppelcode: n.v.t.","Kopplungscode: n. v.","Code d'appairage : n/d","Código de emparejamiento: n/d","Codice di accoppiamento: n/d","Код сопряжения: н/д","Код сполучення: н/д","Код за сдвояване: н/п","Код за упаривање: н/д","Κωδικός σύζευξης: μ/δ" ,"Código de pareamento: n/d","Cod de asociere: n/a" } }, + { "Path hash: %u byte%s", { 0, "Pad-hash: %u byte%s","Pfad-Hash: %u Byte%s","Hash de chemin : %u octet%s","Hash de ruta: %u byte%s","Hash percorso: %u byte%s","Хеш пути: %u байт%s","Хеш шляху: %u байт%s","Хеш на пътя: %u байт%s","Хеш путање: %u бајт%s","Hash διαδρομής: %u byte%s" ,"Hash de rota: %u byte%s","Hash cale: %u byte%s" } }, + { "Calibrated: 100%% = %u.%02u V (long-press to reset)", { 0, "Gekalibreerd: 100%% = %u.%02u V (lang indrukken om te resetten)","Kalibriert: 100%% = %u.%02u V (lang drücken zum Zurücksetzen)","Étalonné : 100%% = %u.%02u V (appui long pour réinitialiser)","Calibrado: 100%% = %u.%02u V (mantén para restablecer)","Calibrato: 100%% = %u.%02u V (tieni premuto per ripristinare)","Калибровано: 100%% = %u.%02u В (долгое нажатие — сброс)","Калібровано: 100%% = %u.%02u В (довге натискання — скидання)","Калибрирано: 100%% = %u.%02u V (задръжте за нулиране)","Калибрисано: 100%% = %u.%02u V (дуго за ресет)","Βαθμονομήθηκε: 100%% = %u.%02u V (παρατεταμένο για επαναφορά)" ,"Calibrado: 100%% = %u.%02u V (segure para redefinir)","Calibrat: 100%% = %u.%02u V (apasă lung pentru resetare)" } }, + { "Centered on %s", { 0, "Gecentreerd op %s","Zentriert auf %s","Centré sur %s","Centrado en %s","Centrato su %s","Центрировано на %s","Центровано на %s","Центрирано на %s","Центрирано на %s","Κεντραρισμένο σε %s" ,"Centralizado em %s","Centrat pe %s" } }, + { "Copied %s", { 0, "%s gekopieerd","%s kopiert","%s copié","%s copiado","%s copiato","%s скопировано","%s скопійовано","%s копирано","%s копирано","Αντιγράφηκε %s" ,"%s copiado","Copiat %s" } }, + { "No reply from %s", { 0, "Geen antwoord van %s","Keine Antwort von %s","Pas de réponse de %s","Sin respuesta de %s","Nessuna risposta da %s","Нет ответа от %s","Немає відповіді від %s","Няма отговор от %s","Нема одговора од %s","Καμία απάντηση από %s" ,"Sem resposta de %s","Niciun răspuns de la %s" } }, + { "Joined %.40s", { 0, "Deelgenomen aan %.40s","%.40s beigetreten","Rejoint %.40s","Unido a %.40s","Unito a %.40s","Вы вошли в %.40s","Приєднано до %.40s","Присъединено към %.40s","Придружено %.40s","Συμμετοχή σε %.40s" ,"Entrou em %.40s","Intrat în %.40s" } }, + { "Login OK (perms %u)", { 0, "Inloggen OK (rechten %u)","Anmeldung OK (Rechte %u)","Connexion OK (perms %u)","Sesión OK (perms %u)","Accesso OK (permessi %u)","Вход OK (права %u)","Вхід OK (права %u)","Вход OK (права %u)","Пријава OK (дозволе %u)","Σύνδεση OK (δικαι. %u)" ,"Login OK (perms %u)","Autentificare OK (permisiuni %u)" } }, + { "Saved to slot %d", { 0, "Opgeslagen in slot %d","In Slot %d gespeichert","Enregistré dans l'emplacement %d","Guardado en ranura %d","Salvato nello slot %d","Сохранено в слот %d","Збережено в слот %d","Запазено в слот %d","Сачувано у слот %d","Αποθηκεύτηκε στη θέση %d" ,"Salvo no slot %d","Salvat în slotul %d" } }, + { "Slot %d cleared", { 0, "Slot %d gewist","Slot %d gelöscht","Emplacement %d effacé","Ranura %d borrada","Slot %d cancellato","Слот %d очищен","Слот %d очищено","Слот %d изчистен","Слот %d обрисан","Η θέση %d εκκαθαρίστηκε" ,"Slot %d limpo","Slot %d golit" } }, + { "now on channel %s", { 0, "nu op kanaal %s","jetzt auf Kanal %s","maintenant sur le canal %s","ahora en el canal %s","ora sul canale %s","теперь в канале %s","тепер у каналі %s","сега в канал %s","сада на каналу %s","τώρα στο κανάλι %s" ,"agora no canal %s","acum pe canalul %s" } }, + { "now talking to %s", { 0, "praat nu met %s","spricht jetzt mit %s","conversation avec %s","ahora hablando con %s","ora in chat con %s","теперь общаетесь с %s","тепер спілкуєтесь з %s","сега говорите с %s","сада причате са %s","τώρα μιλάτε με %s" ,"agora falando com %s","acum vorbeşti cu %s" } }, + { "Delete chat with \"%s\"?\nMessage history will be cleared.", { 0, "Chat met \"%s\" verwijderen?\nBerichtgeschiedenis wordt gewist.","Chat mit \"%s\" löschen?\nNachrichtenverlauf wird gelöscht.","Supprimer la discussion avec \"%s\" ?\nL'historique sera effacé.","¿Eliminar el chat con \"%s\"?\nSe borrará el historial.","Eliminare la chat con \"%s\"?\nLa cronologia sarà cancellata.","Удалить чат с \"%s\"?\nИстория будет очищена.","Видалити чат із \"%s\"?\nІсторію буде очищено.","Изтриване на чата с \"%s\"?\nИсторията ще се изчисти.","Обрисати ћаскање са \"%s\"?\nИсторија ће бити обрисана.","Διαγραφή συνομιλίας με \"%s\";\nΤο ιστορικό θα διαγραφεί." ,"Excluir chat com \"%s\"?\nO histórico de mensagens será apagado.","Ştergi chatul cu \"%s\"?\nIstoricul mesajelor va fi şters." } }, + { "Remove channel \"%s\"?\nLeaves it on this device only.", { 0, "Kanaal \"%s\" verwijderen?\nAlleen van dit apparaat.","Kanal \"%s\" entfernen?\nNur von diesem Gerät.","Retirer le canal \"%s\" ?\nUniquement de cet appareil.","¿Quitar el canal \"%s\"?\nSolo de este dispositivo.","Rimuovere il canale \"%s\"?\nSolo da questo dispositivo.","Удалить канал \"%s\"?\nТолько с этого устройства.","Видалити канал \"%s\"?\nЛише з цього пристрою.","Премахване на канал \"%s\"?\nСамо от това устройство.","Уклонити канал \"%s\"?\nСамо са овог уређаја.","Αφαίρεση καναλιού \"%s\";\nΜόνο από αυτή τη συσκευή." ,"Remover canal \"%s\"?\nDeixa-o só neste aparelho.","Elimini canalul \"%s\"?\nÎl elimină doar de pe acest dispozitiv." } }, + { "Firmware beta_%d\nCouldn't reach the update server", { 0, "Firmware beta_%d\nKon de updateserver niet bereiken","Firmware beta_%d\nUpdate-Server nicht erreichbar","Firmware beta_%d\nServeur de mise à jour injoignable","Firmware beta_%d\nNo se pudo contactar el servidor","Firmware beta_%d\nServer di aggiornamento irraggiungibile","Прошивка beta_%d\nСервер обновлений недоступен","Прошивка beta_%d\nСервер оновлень недоступний","Фърмуер beta_%d\nСървърът за обновяване е недостъпен","Фирмвер beta_%d\nСервер ажурирања недоступан","Firmware beta_%d\nΑδυναμία πρόσβασης στον διακομιστή" ,"Firmware beta_%d\nNão foi possível acessar o servidor de atualização","Firmware beta_%d\nServerul de actualizare nu a putut fi contactat" } }, + { "Internal storage %s / %s", { 0, "Interne opslag %s / %s","Interner Speicher %s / %s","Stockage interne %s / %s","Almacen. interno %s / %s","Archiviazione interna %s / %s","Внутр. память %s / %s","Внутр. пам'ять %s / %s","Вътрешна памет %s / %s","Интерна меморија %s / %s","Εσωτ. χώρος %s / %s" ,"Armazenamento interno %s / %s","Stocare internă %s / %s" } }, + { "SD card %s (hold: format)", { 0, "SD-kaart %s (vasthouden: formatteren)","SD-Karte %s (halten: formatieren)","Carte SD %s (maintenir : formater)","Tarjeta SD %s (mantener: formatear)","Scheda SD %s (tieni: formatta)","SD-карта %s (удерж.: формат.)","SD-картка %s (утрим.: формат.)","SD карта %s (задръж: формат)","SD картица %s (држи: формат)","Κάρτα SD %s (κράτημα: διαμόρφωση)" ,"Cartão SD %s (segure: formatar)","Card SD %s (ţine: formatare)" } }, + { "SD formatted - %s (MESHCOMOD)", { 0, "SD geformatteerd - %s (MESHCOMOD)","SD formatiert - %s (MESHCOMOD)","SD formatée - %s (MESHCOMOD)","SD formateada - %s (MESHCOMOD)","SD formattata - %s (MESHCOMOD)","SD отформатирована - %s (MESHCOMOD)","SD відформатовано - %s (MESHCOMOD)","SD форматирана - %s (MESHCOMOD)","SD форматирана - %s (MESHCOMOD)","SD διαμορφώθηκε - %s (MESHCOMOD)" ,"SD formatado - %s (MESHCOMOD)","SD formatat - %s (MESHCOMOD)" } }, + { "SPIFFS %u/%u KB used", { 0, "SPIFFS %u/%u KB gebruikt","SPIFFS %u/%u KB belegt","SPIFFS %u/%u Ko utilisés","SPIFFS %u/%u KB usados","SPIFFS %u/%u KB usati","SPIFFS %u/%u КБ занято","SPIFFS %u/%u КБ використано","SPIFFS %u/%u KB използвани","SPIFFS %u/%u KB заузето","SPIFFS %u/%u KB σε χρήση" ,"SPIFFS %u/%u KB usados","SPIFFS %u/%u KB folosiţi" } }, + { "contacts (%d):", { 0, "contacten (%d):","Kontakte (%d):","contacts (%d) :","contactos (%d):","contatti (%d):","контакты (%d):","контакти (%d):","контакти (%d):","контакти (%d):","επαφές (%d):" ,"contatos (%d):","contacte (%d):" } }, + { "%d on map", { 0, "%d op kaart","%d auf Karte","%d sur la carte","%d en el mapa","%d sulla mappa","%d на карте","%d на карті","%d на картата","%d на мапи","%d στον χάρτη" ,"%d no mapa","%d pe hartă" } }, + { "Data -> SD card on reboot\n(card must be inserted)", { 0, "Gegevens -> SD-kaart bij herstart\n(kaart moet geplaatst zijn)","Daten -> SD-Karte bei Neustart\n(Karte muss eingelegt sein)","Données -> carte SD au redémarrage\n(carte requise)","Datos -> tarjeta SD al reiniciar\n(tarjeta requerida)","Dati -> scheda SD al riavvio\n(scheda richiesta)","Данные -> SD-карта при перезагрузке\n(нужна карта)","Дані -> SD-картка при перезавантаженні\n(потрібна картка)","Данни -> SD карта при рестарт\n(нужна е карта)","Подаци -> SD картица при рестарту\n(потребна картица)","Δεδομένα -> κάρτα SD στην επανεκκίνηση\n(απαιτείται κάρτα)" ,"Dados -> cartão SD ao reiniciar\n(cartão deve estar inserido)","Date -> card SD la repornire\n(cardul trebuie introdus)" } }, + { "Data -> internal on reboot", { 0, "Gegevens -> intern bij herstart","Daten -> intern bei Neustart","Données -> interne au redémarrage","Datos -> interno al reiniciar","Dati -> interno al riavvio","Данные -> внутр. при перезагрузке","Дані -> внутр. при перезавантаженні","Данни -> вътрешна при рестарт","Подаци -> интерно при рестарту","Δεδομένα -> εσωτερικά στην επανεκκίνηση" ,"Dados -> interno ao reiniciar","Date -> stocare internă la repornire" } }, + { "RangeTest sent (direct)", { 0, "RangeTest verzonden (direct)","RangeTest gesendet (direkt)","RangeTest envoyé (direct)","RangeTest enviado (directo)","RangeTest inviato (diretto)","RangeTest отправлен (прямой)","RangeTest надіслано (прямий)","RangeTest изпратен (директен)","RangeTest послат (директно)","Στάλθηκε RangeTest (άμεσο)" ,"RangeTest enviado (direto)","RangeTest trimis (direct)" } }, + { "RangeTest sent (flood)", { 0, "RangeTest verzonden (flood)","RangeTest gesendet (Flood)","RangeTest envoyé (flood)","RangeTest enviado (flood)","RangeTest inviato (flood)","RangeTest отправлен (flood)","RangeTest надіслано (flood)","RangeTest изпратен (flood)","RangeTest послат (flood)","Στάλθηκε RangeTest (flood)" ,"RangeTest enviado (flood)","RangeTest trimis (flood)" } }, + { "Telemetry req (direct)…", { 0, "Telemetrie-verzoek (direct)…","Telemetrie-Anfrage (direkt)…","Requête télémétrie (direct)…","Solicitud telemetría (directo)…","Richiesta telemetria (diretto)…","Запрос телеметрии (прямой)…","Запит телеметрії (прямий)…","Заявка за телеметрия (директен)…","Захтев телеметрије (директно)…","Αίτημα τηλεμετρίας (άμεσο)…" ,"Req. de telemetria (direto)…","Cerere telemetrie (direct)…" } }, + { "Telemetry req (flood)…", { 0, "Telemetrie-verzoek (flood)…","Telemetrie-Anfrage (Flood)…","Requête télémétrie (flood)…","Solicitud telemetría (flood)…","Richiesta telemetria (flood)…","Запрос телеметрии (flood)…","Запит телеметрії (flood)…","Заявка за телеметрия (flood)…","Захтев телеметрије (flood)…","Αίτημα τηλεμετρίας (flood)…" ,"Req. de telemetria (flood)…","Cerere telemetrie (flood)…" } }, + { "Pinged (direct) — waiting…", { 0, "Ping (direct) — wachten…","Ping (direkt) — warte…","Ping (direct) — attente…","Ping (directo) — esperando…","Ping (diretto) — attesa…","Ping (прямой) — ожидание…","Ping (прямий) — очікування…","Ping (директен) — изчакване…","Ping (директно) — чекање…","Ping (άμεσο) — αναμονή…" ,"Ping (direto) — aguardando…","Ping trimis (direct) — se aşteaptă…" } }, + { "Pinged (flood) — waiting…", { 0, "Ping (flood) — wachten…","Ping (Flood) — warte…","Ping (flood) — attente…","Ping (flood) — esperando…","Ping (flood) — attesa…","Ping (flood) — ожидание…","Ping (flood) — очікування…","Ping (flood) — изчакване…","Ping (flood) — чекање…","Ping (flood) — αναμονή…" ,"Ping (flood) — aguardando…","Ping trimis (flood) — se aşteaptă…" } }, + { "Unread %d", { 0, "Ongelezen %d","Ungelesen %d","Non lus %d","Sin leer %d","Non letti %d","Непроч. %d","Непроч. %d","Непроч. %d","Непроч. %d","Μη αναγν. %d" ,"Não lidas %d","Necitite %d" } }, + { "Unread %d\nRAM %u%% · PSRAM %u%%", { 0, "Ongelezen %d\nRAM %u%% · PSRAM %u%%","Ungelesen %d\nRAM %u%% · PSRAM %u%%","Non lus %d\nRAM %u%% · PSRAM %u%%","Sin leer %d\nRAM %u%% · PSRAM %u%%","Non letti %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Непроч. %d\nRAM %u%% · PSRAM %u%%","Μη αναγν. %d\nRAM %u%% · PSRAM %u%%" ,"Não lidas %d\nRAM %u%% · PSRAM %u%%","Necitite %d\nRAM %u%% · PSRAM %u%%" } }, + { "Analyzing terrain…\n(retry %d of 3)", { 0, "Terrein analyseren…\n(poging %d van 3)","Gelände wird analysiert…\n(Versuch %d von 3)","Analyse du terrain…\n(essai %d sur 3)","Analizando terreno…\n(intento %d de 3)","Analisi del terreno…\n(tentativo %d di 3)","Анализ рельефа…\n(попытка %d из 3)","Аналіз рельєфу…\n(спроба %d з 3)","Анализ на терена…\n(опит %d от 3)","Анализа терена…\n(покушај %d од 3)","Ανάλυση εδάφους…\n(προσπάθεια %d από 3)" ,"Analisando o terreno…\n(tentativa %d de 3)","Se analizează terenul…\n(încercarea %d din 3)" } }, // ---- Backups page (Settings -> Backups) ---- - { "Backups", { 0, "Back-ups","Backups","Sauvegardes","Copias","Backup","Резервные копии","Резервні копії","Резервни копия","Резервне копије","Αντίγραφα" ,"Backups"} }, - { "Saved backups", { 0, "Opgeslagen back-ups","Gespeicherte Backups","Sauvegardes enregistrées","Copias guardadas","Backup salvati","Сохранённые копии","Збережені копії","Запазени копия","Сачуване копије","Αποθηκευμένα αντίγραφα" ,"Backups salvos"} }, - { "Export new backup", { 0, "Nieuwe back-up","Neues Backup","Nouvelle sauvegarde","Nueva copia","Nuovo backup","Новая копия","Нова копія","Ново копие","Нова копија","Νέο αντίγραφο" ,"Exportar novo backup"} }, - { "Danger zone", { 0, "Gevarenzone","Gefahrenzone","Zone de danger","Zona de peligro","Zona pericolosa","Опасная зона","Небезпечна зона","Опасна зона","Опасна зона","Ζώνη κινδύνου" ,"Zona de perigo"} }, - { "Factory reset", { 0, "Fabrieksreset","Werksreset","Réinit. d'usine","Rest. de fábrica","Ripristino fabbrica","Сброс настроек","Скидання налашт.","Фабрични настройки","Фабричко ресет.","Επαναφορά εργοστ." ,"Restauração de fábrica"} }, - { "Erase all", { 0, "Alles wissen","Alles löschen","Tout effacer","Borrar todo","Cancella tutto","Стереть всё","Стерти все","Изтрий всичко","Обриши све","Διαγραφή όλων" ,"Apagar tudo"} }, - { "Erasing everything…", { 0, "Alles wissen…","Alles wird gelöscht…","Effacement total…","Borrando todo…","Cancellazione…","Стирание всего…","Стирання всього…","Изтриване на всичко…","Брисање свега…","Διαγραφή όλων…" ,"Apagando tudo…"} }, - { "Delete failed", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Échec suppression","Error al borrar","Eliminazione fallita","Не удалось удалить","Не вдалося видалити","Грешка при изтриване","Брисање није успело","Αποτυχία διαγραφής" ,"Falha ao excluir"} }, - { "No backups yet. Tap Export new backup above to create one.", { 0, "Nog geen back-ups. Tik hierboven op Nieuwe back-up om er een te maken.","Noch keine Backups. Tippe oben auf Neues Backup, um eines zu erstellen.","Aucune sauvegarde. Touchez Nouvelle sauvegarde ci-dessus pour en créer une.","Sin copias aún. Toca Nueva copia arriba para crear una.","Nessun backup. Tocca Nuovo backup sopra per crearne uno.","Копий пока нет. Нажмите «Новая копия» выше, чтобы создать.","Копій ще немає. Натисніть «Нова копія» вгорі, щоб створити.","Все още няма копия. Натиснете «Ново копие» горе, за да създадете.","Још нема копија. Додирните «Нова копија» изнад да направите.","Δεν υπάρχουν αντίγραφα. Πατήστε «Νέο αντίγραφο» πάνω για δημιουργία." ,"Ainda sem backups. Toque em Exportar novo backup acima para criar um."} }, - { "Erases identity, contacts, channels, messages, Wi-Fi and all settings, then reboots.", { 0, "Wist identiteit, contacten, kanalen, berichten, wifi en alle instellingen, en herstart dan.","Löscht Identität, Kontakte, Kanäle, Nachrichten, WLAN und alle Einstellungen, dann Neustart.","Efface identité, contacts, canaux, messages, Wi-Fi et tous les réglages, puis redémarre.","Borra identidad, contactos, canales, mensajes, Wi-Fi y todos los ajustes, luego reinicia.","Cancella identità, contatti, canali, messaggi, Wi-Fi e tutte le impostazioni, poi riavvia.","Стирает личность, контакты, каналы, сообщения, Wi-Fi и все настройки, затем перезагрузка.","Стирає особистість, контакти, канали, повідомлення, Wi-Fi і всі налаштування, потім перезавантаження.","Изтрива идентичност, контакти, канали, съобщения, Wi-Fi и всички настройки, после рестарт.","Брише идентитет, контакте, канале, поруке, Wi-Fi и сва подешавања, па се рестартује.","Διαγράφει ταυτότητα, επαφές, κανάλια, μηνύματα, Wi-Fi και όλες τις ρυθμίσεις, μετά επανεκκίνηση." ,"Apaga identidade, contatos, canais, mensagens, Wi-Fi e todos os ajustes, depois reinicia."} }, - { "Delete this backup file?\nThis cannot be undone.", { 0, "Dit back-upbestand verwijderen?\nDit kan niet ongedaan worden gemaakt.","Diese Backup-Datei löschen?\nDies kann nicht rückgängig gemacht werden.","Supprimer ce fichier de sauvegarde ?\nCeci est irréversible.","¿Eliminar este archivo de copia?\nNo se puede deshacer.","Eliminare questo file di backup?\nNon è reversibile.","Удалить этот файл копии?\nОтменить нельзя.","Видалити цей файл копії?\nСкасувати неможливо.","Изтриване на този файл с копие?\nНе може да се отмени.","Обрисати ову датотеку копије?\nНе може се поништити.","Διαγραφή αυτού του αρχείου;\nΔεν αναιρείται." ,"Excluir este arquivo de backup?\nIsto não pode ser desfeito."} }, - { "Factory reset?\n\nErases identity, contacts,\nchannels, messages, Wi-Fi\nand every setting. This\ncannot be undone.", { 0, "Fabrieksreset?\n\nWist identiteit, contacten, kanalen, berichten, wifi en elke instelling. Onomkeerbaar.","Werksreset?\n\nLöscht Identität, Kontakte, Kanäle, Nachrichten, WLAN und alle Einstellungen. Unwiderruflich.","Réinitialisation d'usine ?\n\nEfface identité, contacts, canaux, messages, Wi-Fi et tous les réglages. Irréversible.","¿Restablecer de fábrica?\n\nBorra identidad, contactos, canales, mensajes, Wi-Fi y todos los ajustes. Irreversible.","Ripristino di fabbrica?\n\nCancella identità, contatti, canali, messaggi, Wi-Fi e ogni impostazione. Irreversibile.","Сброс к заводским?\n\nСтирает личность, контакты, каналы, сообщения, Wi-Fi и все настройки. Необратимо.","Скинути до заводських?\n\nСтирає особистість, контакти, канали, повідомлення, Wi-Fi і всі налаштування. Незворотно.","Фабричен ресет?\n\nИзтрива идентичност, контакти, канали, съобщения, Wi-Fi и всички настройки. Необратимо.","Фабричко ресетовање?\n\nБрише идентитет, контакте, канале, поруке, Wi-Fi и сва подешавања. Неповратно.","Επαναφορά εργοστασίου;\n\nΔιαγράφει ταυτότητα, επαφές, κανάλια, μηνύματα, Wi-Fi και κάθε ρύθμιση. Μη αναστρέψιμο." ,"Restauração de fábrica?\n\nApaga identidade, contatos,\ncanais, mensagens, Wi-Fi\ne todos os ajustes. Isto\nnão pode ser desfeito."} }, + { "Backups", { 0, "Back-ups","Backups","Sauvegardes","Copias","Backup","Резервные копии","Резервні копії","Резервни копия","Резервне копије","Αντίγραφα" ,"Backups","Backupuri" } }, + { "Saved backups", { 0, "Opgeslagen back-ups","Gespeicherte Backups","Sauvegardes enregistrées","Copias guardadas","Backup salvati","Сохранённые копии","Збережені копії","Запазени копия","Сачуване копије","Αποθηκευμένα αντίγραφα" ,"Backups salvos","Backupuri salvate" } }, + { "Export new backup", { 0, "Nieuwe back-up","Neues Backup","Nouvelle sauvegarde","Nueva copia","Nuovo backup","Новая копия","Нова копія","Ново копие","Нова копија","Νέο αντίγραφο" ,"Exportar novo backup","Exportă backup nou" } }, + { "Danger zone", { 0, "Gevarenzone","Gefahrenzone","Zone de danger","Zona de peligro","Zona pericolosa","Опасная зона","Небезпечна зона","Опасна зона","Опасна зона","Ζώνη κινδύνου" ,"Zona de perigo","Zonă periculoasă" } }, + { "Factory reset", { 0, "Fabrieksreset","Werksreset","Réinit. d'usine","Rest. de fábrica","Ripristino fabbrica","Сброс настроек","Скидання налашт.","Фабрични настройки","Фабричко ресет.","Επαναφορά εργοστ." ,"Restauração de fábrica","Resetare din fabrică" } }, + { "Erase all", { 0, "Alles wissen","Alles löschen","Tout effacer","Borrar todo","Cancella tutto","Стереть всё","Стерти все","Изтрий всичко","Обриши све","Διαγραφή όλων" ,"Apagar tudo","Şterge tot" } }, + { "Erasing everything…", { 0, "Alles wissen…","Alles wird gelöscht…","Effacement total…","Borrando todo…","Cancellazione…","Стирание всего…","Стирання всього…","Изтриване на всичко…","Брисање свега…","Διαγραφή όλων…" ,"Apagando tudo…","Se şterge tot…" } }, + { "Delete failed", { 0, "Verwijderen mislukt","Löschen fehlgeschlagen","Échec suppression","Error al borrar","Eliminazione fallita","Не удалось удалить","Не вдалося видалити","Грешка при изтриване","Брисање није успело","Αποτυχία διαγραφής" ,"Falha ao excluir","Ştergere eşuată" } }, + { "No backups yet. Tap Export new backup above to create one.", { 0, "Nog geen back-ups. Tik hierboven op Nieuwe back-up om er een te maken.","Noch keine Backups. Tippe oben auf Neues Backup, um eines zu erstellen.","Aucune sauvegarde. Touchez Nouvelle sauvegarde ci-dessus pour en créer une.","Sin copias aún. Toca Nueva copia arriba para crear una.","Nessun backup. Tocca Nuovo backup sopra per crearne uno.","Копий пока нет. Нажмите «Новая копия» выше, чтобы создать.","Копій ще немає. Натисніть «Нова копія» вгорі, щоб створити.","Все още няма копия. Натиснете «Ново копие» горе, за да създадете.","Још нема копија. Додирните «Нова копија» изнад да направите.","Δεν υπάρχουν αντίγραφα. Πατήστε «Νέο αντίγραφο» πάνω για δημιουργία." ,"Ainda sem backups. Toque em Exportar novo backup acima para criar um.","Niciun backup încă. Atinge Exportă backup nou mai sus pentru a crea unul." } }, + { "Erases identity, contacts, channels, messages, Wi-Fi and all settings, then reboots.", { 0, "Wist identiteit, contacten, kanalen, berichten, wifi en alle instellingen, en herstart dan.","Löscht Identität, Kontakte, Kanäle, Nachrichten, WLAN und alle Einstellungen, dann Neustart.","Efface identité, contacts, canaux, messages, Wi-Fi et tous les réglages, puis redémarre.","Borra identidad, contactos, canales, mensajes, Wi-Fi y todos los ajustes, luego reinicia.","Cancella identità, contatti, canali, messaggi, Wi-Fi e tutte le impostazioni, poi riavvia.","Стирает личность, контакты, каналы, сообщения, Wi-Fi и все настройки, затем перезагрузка.","Стирає особистість, контакти, канали, повідомлення, Wi-Fi і всі налаштування, потім перезавантаження.","Изтрива идентичност, контакти, канали, съобщения, Wi-Fi и всички настройки, после рестарт.","Брише идентитет, контакте, канале, поруке, Wi-Fi и сва подешавања, па се рестартује.","Διαγράφει ταυτότητα, επαφές, κανάλια, μηνύματα, Wi-Fi και όλες τις ρυθμίσεις, μετά επανεκκίνηση." ,"Apaga identidade, contatos, canais, mensagens, Wi-Fi e todos os ajustes, depois reinicia.","Şterge identitatea, contactele, canalele, mesajele, Wi-Fi şi toate setările, apoi reporneşte." } }, + { "Delete this backup file?\nThis cannot be undone.", { 0, "Dit back-upbestand verwijderen?\nDit kan niet ongedaan worden gemaakt.","Diese Backup-Datei löschen?\nDies kann nicht rückgängig gemacht werden.","Supprimer ce fichier de sauvegarde ?\nCeci est irréversible.","¿Eliminar este archivo de copia?\nNo se puede deshacer.","Eliminare questo file di backup?\nNon è reversibile.","Удалить этот файл копии?\nОтменить нельзя.","Видалити цей файл копії?\nСкасувати неможливо.","Изтриване на този файл с копие?\nНе може да се отмени.","Обрисати ову датотеку копије?\nНе може се поништити.","Διαγραφή αυτού του αρχείου;\nΔεν αναιρείται." ,"Excluir este arquivo de backup?\nIsto não pode ser desfeito.","Ştergi acest fişier backup?\nNu poate fi anulat." } }, + { "Factory reset?\n\nErases identity, contacts,\nchannels, messages, Wi-Fi\nand every setting. This\ncannot be undone.", { 0, "Fabrieksreset?\n\nWist identiteit, contacten, kanalen, berichten, wifi en elke instelling. Onomkeerbaar.","Werksreset?\n\nLöscht Identität, Kontakte, Kanäle, Nachrichten, WLAN und alle Einstellungen. Unwiderruflich.","Réinitialisation d'usine ?\n\nEfface identité, contacts, canaux, messages, Wi-Fi et tous les réglages. Irréversible.","¿Restablecer de fábrica?\n\nBorra identidad, contactos, canales, mensajes, Wi-Fi y todos los ajustes. Irreversible.","Ripristino di fabbrica?\n\nCancella identità, contatti, canali, messaggi, Wi-Fi e ogni impostazione. Irreversibile.","Сброс к заводским?\n\nСтирает личность, контакты, каналы, сообщения, Wi-Fi и все настройки. Необратимо.","Скинути до заводських?\n\nСтирає особистість, контакти, канали, повідомлення, Wi-Fi і всі налаштування. Незворотно.","Фабричен ресет?\n\nИзтрива идентичност, контакти, канали, съобщения, Wi-Fi и всички настройки. Необратимо.","Фабричко ресетовање?\n\nБрише идентитет, контакте, канале, поруке, Wi-Fi и сва подешавања. Неповратно.","Επαναφορά εργοστασίου;\n\nΔιαγράφει ταυτότητα, επαφές, κανάλια, μηνύματα, Wi-Fi και κάθε ρύθμιση. Μη αναστρέψιμο." ,"Restauração de fábrica?\n\nApaga identidade, contatos,\ncanais, mensagens, Wi-Fi\ne todos os ajustes. Isto\nnão pode ser desfeito.","Resetare din fabrică?\n\nŞterge identitatea, contactele,\ncanalele, mesajele, Wi-Fi\nşi fiecare setare. Nu poate\nfi anulată." } }, // ---- audit batch 2026-07 (auto-generated translations) ---- - { " (device behind)", { 0, " (apparaat loopt achter)"," (Gerät geht nach)"," (appareil en retard)"," (dispositivo atrasado)"," (dispositivo indietro)"," (устройство отстаёт)"," (пристрій відстає)"," (устройството изостава)"," (уређај касни)"," (συσκευή πίσω)"," (dispositivo atrasado)" } }, - { "%.3f MHz · SF%u · TX %+ddBm", { 0, "%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f МГц · SF%u · TX %+ddBm","%.3f МГц · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm" } }, - { "%u types · hops %u · manual %s", { 0, "%u types · hops %u · handmatig %s","%u Typen · Hops %u · manuell %s","%u types · sauts %u · manuel %s","%u tipos · saltos %u · manual %s","%u tipi · salti %u · manuale %s","%u типов · хопы %u · вручную %s","%u типів · хопи %u · вручну %s","%u типа · хопове %u · ръчно %s","%u типова · хопови %u · ручно %s","%u τύποι · άλματα %u · χειροκίνητα %s","%u tipos · saltos %u · manual %s" } }, - { "(no default)", { 0, "(geen standaard)","(kein Standard)","(aucun par défaut)","(sin predeterminado)","(nessun predefinito)","(нет по умолчанию)","(немає типового)","(няма по подразбиране)","(нема подразумеваног)","(χωρίς προεπιλογή)","(sem padrão)" } }, - { "0123456789abcdef…", { 0, "0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…" } }, - { "1 byte (legacy)\n2 bytes\n3 bytes", { 0, "1 byte (verouderd)\n2 bytes\n3 bytes","1 Byte (Legacy)\n2 Bytes\n3 Bytes","1 octet (hérité)\n2 octets\n3 octets","1 byte (heredado)\n2 bytes\n3 bytes","1 byte (legacy)\n2 byte\n3 byte","1 байт (устар.)\n2 байта\n3 байта","1 байт (застар.)\n2 байти\n3 байти","1 байт (стар)\n2 байта\n3 байта","1 бајт (старо)\n2 бајта\n3 бајта","1 byte (παλαιό)\n2 bytes\n3 bytes","1 byte (legado)\n2 bytes\n3 bytes" } }, - { "12-hour clock", { 0, "12-uursklok","12-Stunden-Uhr","Horloge 12 h","Reloj de 12 horas","Orologio 12 ore","12-часовой формат","12-годинний формат","12-часов формат","12-часовни формат","Ρολόι 12 ωρών","Relógio de 12 horas" } }, - { "@ Mentions", { 0, "@ Vermeldingen","@ Erwähnungen","@ Mentions","@ Menciones","@ Menzioni","@ Упоминания","@ Згадки","@ Споменавания","@ Помињања","@ Αναφορές","@ Menções" } }, - { "@ mention sound", { 0, "Geluid bij @-vermelding","Ton bei @-Erwähnung","Son des mentions @","Sonido de menciones @","Suono menzioni @","Звук @-упоминаний","Звук @-згадок","Звук при @ споменаване","Звук за @ помињање","Ήχος αναφορών @","Som de menções @" } }, - { "AUTOMATIC ADVERTS", { 0, "AUTOMATISCHE ADVERTS","AUTOMATISCHE ADVERTS","ADVERTS AUTOMATIQUES","ADVERTS AUTOMÁTICOS","ADVERT AUTOMATICI","АВТОМАТИЧЕСКИЕ АДВЕРТЫ","АВТОМАТИЧНІ АДВЕРТИ","АВТОМАТИЧНИ АДВЕРТИ","АУТОМАТСКИ АДВЕРТИ","ΑΥΤΟΜΑΤΑ ADVERTS","ADVERTS AUTOMÁTICOS" } }, - { "Answer telemetry requests", { 0, "Telemetrieverzoeken beantwoorden","Telemetrieanfragen beantworten","Répondre aux demandes de télémétrie","Responder solicitudes de telemetría","Rispondi alle richieste di telemetria","Отвечать на запросы телеметрии","Відповідати на запити телеметрії","Отговаряй на заявки за телеметрия","Одговарај на захтеве телеметрије","Απάντηση σε αιτήματα τηλεμετρίας","Responder solicitações de telemetria" } }, - { "App drawer as home", { 0, "App-lade als startscherm","App-Drawer als Startseite","Tiroir d'apps comme accueil","Cajón de apps como inicio","Drawer app come home","Меню приложений как главный экран","Меню додатків як головний екран","Меню с приложения като начален екран","Фиока апликација као почетни екран","Συρτάρι εφαρμογών ως αρχική","Gaveta de apps como tela inicial" } }, - { "App icon size", { 0, "Grootte app-pictogrammen","App-Symbolgröße","Taille des icônes d'app","Tamaño de iconos de app","Dimensione icone app","Размер значков приложений","Розмір значків додатків","Размер на иконите на приложенията","Величина икона апликација","Μέγεθος εικονιδίων εφαρμογών","Tamanho dos ícones de apps" } }, - { "Applies after restart.", { 0, "Actief na herstart.","Gilt nach Neustart.","Appliqué après redémarrage.","Se aplica tras reiniciar.","Attivo dopo il riavvio.","Применяется после перезапуска.","Діє після перезапуску.","Прилага се след рестарт.","Важи након рестарта.","Ισχύει μετά την επανεκκίνηση.","Aplica-se após reiniciar." } }, - { "Apply auto-poll", { 0, "Auto-poll toepassen","Auto-Poll übernehmen","Appliquer l'auto-poll","Aplicar auto-poll","Applica auto-poll","Применить автоопрос","Застосувати автоопитування","Приложи авто-запитване","Примени ауто-упит","Εφαρμογή auto-poll","Aplicar auto-poll" } }, - { "Arrows + Enter always work. These letters are extra — tap a row, then press a key.", { 0, "Pijltjes + Enter werken altijd. Deze letters zijn extra — tik op een rij en druk dan op een toets.","Pfeile + Enter funktionieren immer. Diese Buchstaben sind extra — Zeile antippen, dann Taste drücken.","Flèches + Entrée fonctionnent toujours. Ces lettres sont en plus — touchez une ligne, puis une touche.","Flechas + Enter siempre funcionan. Estas letras son extra: toca una fila y pulsa una tecla.","Frecce + Invio funzionano sempre. Queste lettere sono extra: tocca una riga, poi premi un tasto.","Стрелки + Enter работают всегда. Эти буквы — дополнительно: коснитесь строки, затем нажмите клавишу.","Стрілки + Enter працюють завжди. Ці літери — додатково: торкніться рядка, потім натисніть клавішу.","Стрелки + Enter винаги работят. Тези букви са допълнение — докосни ред, после натисни клавиш.","Стрелице + Enter увек раде. Ова слова су додатак — додирни ред, па притисни тастер.","Βέλη + Enter λειτουργούν πάντα. Τα γράμματα είναι έξτρα — αγγίξτε μια γραμμή και πατήστε ένα πλήκτρο.","Setas + Enter sempre funcionam. Estas letras são extras — toque numa linha e pressione uma tecla." } }, - { "Auto-Join", { 0, "Auto-deelnemen","Auto-Beitritt","Rejoindre auto","Auto-unirse","Auto-join","Автовход","Автоприєднання","Авто-присъединяване","Ауто-придруживање","Αυτόματη συμμετοχή","Entrada automática" } }, - { "Auto-delete above hops", { 0, "Auto-verwijderen boven hops","Auto-Löschen über Hops","Suppression auto au-delà des sauts","Autoeliminar por encima de saltos","Auto-elimina oltre i salti","Автоудаление свыше хопов","Автовидалення понад хопи","Автоизтриване над хопове","Ауто-брисање изнад хопова","Αυτ. διαγραφή πάνω από άλματα","Autoexcluir acima de saltos" } }, - { "Auto-delete oldest", { 0, "Oudste auto-verwijderen","Älteste auto-löschen","Suppression auto des plus anciens","Autoeliminar los más antiguos","Auto-elimina i più vecchi","Автоудаление старых","Автовидалення старих","Автоизтриване на най-старите","Ауто-брисање најстаријих","Αυτ. διαγραφή παλαιότερων","Autoexcluir os mais antigos" } }, - { "Auto-delete oldest makes room when full. Hops: 0 = keep all; nodes heard via more hops are removed.", { 0, "Oudste auto-verwijderen maakt ruimte als het vol is. Hops: 0 = alles bewaren; nodes gehoord via meer hops worden verwijderd.","Älteste auto-löschen schafft Platz, wenn voll. Hops: 0 = alle behalten; über mehr Hops gehörte Nodes werden entfernt.","La suppression auto des plus anciens libère de la place quand c'est plein. Sauts : 0 = tout garder ; les nœuds entendus via plus de sauts sont supprimés.","Autoeliminar los más antiguos libera espacio al llenarse. Saltos: 0 = conservar todos; se eliminan los nodos oídos con más saltos.","Auto-elimina i più vecchi libera spazio quando è pieno. Salti: 0 = conserva tutti; i nodi sentiti con più salti vengono rimossi.","Автоудаление старых освобождает место при заполнении. Хопы: 0 = хранить все; узлы, слышимые через больше хопов, удаляются.","Автовидалення старих звільняє місце при заповненні. Хопи: 0 = зберігати всі; вузли, чутні через більше хопів, видаляються.","Автоизтриването на най-старите освобождава място при запълване. Хопове: 0 = пази всички; възли, чути през повече хопове, се премахват.","Ауто-брисање најстаријих прави место кад је пуно. Хопови: 0 = задржи све; чворови чувени преко више хопова се уклањају.","Η αυτ. διαγραφή παλαιότερων ελευθερώνει χώρο όταν γεμίσει. Άλματα: 0 = όλα· κόμβοι που ακούγονται με περισσότερα άλματα αφαιρούνται.","Autoexcluir os mais antigos libera espaço quando cheio. Saltos: 0 = manter todos; nós ouvidos por mais saltos são removidos." } }, - { "Auto-discover", { 0, "Auto-ontdekken","Auto-Erkennung","Découverte auto","Autodescubrir","Rilevamento auto","Автообнаружение","Автовиявлення","Автооткриване","Ауто-откривање","Αυτόματη ανακάλυψη","Autodescoberta" } }, - { "Auto-discover off", { 0, "Auto-ontdekken uit","Auto-Erkennung aus","Découverte auto désactivée","Autodescubrir desactivado","Rilevamento auto disattivato","Автообнаружение выкл.","Автовиявлення вимк.","Автооткриване изкл.","Ауто-откривање искљ.","Αυτ. ανακάλυψη ανενεργή","Autodescoberta desativada" } }, - { "Auto-discover on", { 0, "Auto-ontdekken aan","Auto-Erkennung an","Découverte auto activée","Autodescubrir activado","Rilevamento auto attivato","Автообнаружение вкл.","Автовиявлення увімк.","Автооткриване вкл.","Ауто-откривање укљ.","Αυτ. ανακάλυψη ενεργή","Autodescoberta ativada" } }, - { "Auto-poll every", { 0, "Auto-poll elke","Auto-Poll alle","Auto-poll toutes les","Auto-poll cada","Auto-poll ogni","Автоопрос каждые","Автоопитування кожні","Авто-запитване на всеки","Ауто-упит на сваких","Auto-poll κάθε","Auto-poll a cada" } }, - { "Auto-refresh off", { 0, "Auto-verversen uit","Auto-Aktualisierung aus","Actualisation auto désactivée","Autoactualizar desactivado","Aggiornamento auto disattivato","Автообновление выкл.","Автооновлення вимк.","Автообновяване изкл.","Ауто-освежавање искљ.","Αυτ. ανανέωση ανενεργή","Atualização auto desativada" } }, - { "Auto-refresh on", { 0, "Auto-verversen aan","Auto-Aktualisierung an","Actualisation auto activée","Autoactualizar activado","Aggiornamento auto attivato","Автообновление вкл.","Автооновлення увімк.","Автообновяване вкл.","Ауто-освежавање укљ.","Αυτ. ανανέωση ενεργή","Atualização auto ativada" } }, - { "Battery", { 0, "Batterij","Akku","Batterie","Batería","Batteria","Батарея","Батарея","Батерия","Батерија","Μπαταρία","Bateria" } }, - { "Battery & power history", { 0, "Batterij- & energiegeschiedenis","Akku- & Energieverlauf","Historique batterie et alimentation","Historial de batería y energía","Cronologia batteria e alimentazione","История батареи и питания","Історія батареї та живлення","История на батерията и захранването","Историја батерије и напајања","Ιστορικό μπαταρίας & τροφοδοσίας","Histórico de bateria e energia" } }, - { "Battery (V)", { 0, "Batterij (V)","Akku (V)","Batterie (V)","Batería (V)","Batteria (V)","Батарея (В)","Батарея (В)","Батерия (V)","Батерија (V)","Μπαταρία (V)","Bateria (V)" } }, - { "Battery saver (experimental)", { 0, "Batterijbesparing (experimenteel)","Energiesparmodus (experimentell)","Économie de batterie (expérimental)","Ahorro de batería (experimental)","Risparmio batteria (sperimentale)","Экономия заряда (экспериментально)","Економія заряду (експериментально)","Пестене на батерия (експериментално)","Уштеда батерије (експериментално)","Εξοικονόμηση μπαταρίας (πειραματικό)","Economia de bateria (experimental)" } }, - { "Battery — last 24h", { 0, "Batterij — laatste 24u","Akku — letzte 24h","Batterie — dernières 24h","Batería — últimas 24h","Batteria — ultime 24h","Батарея — последние 24 ч","Батарея — останні 24 год","Батерия — последни 24 ч","Батерија — последња 24 ч","Μπαταρία — τελευταίο 24ωρο","Bateria — últimas 24h" } }, - { "Block list full", { 0, "Blokkeerlijst vol","Blockierliste voll","Liste de blocage pleine","Lista de bloqueo llena","Lista blocchi piena","Список блокировок полон","Список блокувань заповнено","Списъкът с блокирани е пълен","Листа блокираних је пуна","Λίστα αποκλεισμού πλήρης","Lista de bloqueio cheia" } }, - { "Blocked — their messages are hidden", { 0, "Geblokkeerd — berichten verborgen","Blockiert — Nachrichten ausgeblendet","Bloqué — ses messages sont masqués","Bloqueado — sus mensajes se ocultan","Bloccato — messaggi nascosti","Заблокировано — сообщения скрыты","Заблоковано — повідомлення приховано","Блокиран — съобщенията му са скрити","Блокиран — поруке су скривене","Αποκλεισμένος — τα μηνύματα κρύβονται","Bloqueado — as mensagens ficam ocultas" } }, - { "Blocking unsupported", { 0, "Blokkeren niet ondersteund","Blockieren nicht unterstützt","Blocage non pris en charge","Bloqueo no compatible","Blocco non supportato","Блокировка не поддерживается","Блокування не підтримується","Блокирането не се поддържа","Блокирање није подржано","Ο αποκλεισμός δεν υποστηρίζεται","Bloqueio não suportado" } }, - { "Blue battery Last 6 min", { 0, "Blauw batterij Laatste 6 min","Blau Akku Letzte 6 Min","Bleu batterie 6 dernières min","Azul batería Últimos 6 min","Blu batteria Ultimi 6 min","Синий — батарея Последние 6 мин","Синій — батарея Останні 6 хв","Синьо — батерия Последни 6 мин","Плаво — батерија Последњих 6 мин","Μπλε μπαταρία Τελευταία 6 λεπ.","Azul bateria Últimos 6 min" } }, - { "Blue battery Orange temp Cyan humidity", { 0, "Blauw batterij Oranje temp Cyaan vocht","Blau Akku Orange Temp. Cyan Feuchte","Bleu batterie Orange temp. Cyan humidité","Azul batería Naranja temp. Cian humedad","Blu batteria Arancio temp. Ciano umidità","Синий — батарея Оранжевый — темп. Голубой — влажность","Синій — батарея Помаранчевий — темп. Блакитний — вологість","Синьо — батерия Оранжево — темп. Циан — влажност","Плаво — батерија Наранџасто — темп. Цијан — влажност","Μπλε μπαταρία Πορτοκαλί θερμ. Κυανό υγρασία","Azul bateria Laranja temp. Ciano umidade" } }, - { "Broker host / IP", { 0, "Broker-host / IP","Broker-Host / IP","Hôte / IP du broker","Host / IP del broker","Host / IP del broker","Хост / IP брокера","Хост / IP брокера","Хост / IP на брокера","Хост / IP брокера","Host / IP του broker","Host / IP do broker" } }, - { "Buffered receive (experimental)", { 0, "Gebufferde ontvangst (experimenteel)","Gepufferter Empfang (experimentell)","Réception tamponnée (expérimental)","Recepción con búfer (experimental)","Ricezione bufferizzata (sperimentale)","Буферизованный приём (экспериментально)","Буферизований прийом (експериментально)","Буфериран прием (експериментално)","Баферовани пријем (експериментално)","Λήψη με buffer (πειραματικό)","Recepção com buffer (experimental)" } }, - { "Built-in", { 0, "Ingebouwd","Integriert","Intégré","Integrado","Integrato","Встроенный","Вбудований","Вграден","Уграђен","Ενσωματωμένο","Integrado" } }, - { "Built-in (default)", { 0, "Ingebouwd (standaard)","Integriert (Standard)","Intégré (par défaut)","Integrado (predeterminado)","Integrato (predefinito)","Встроенный (по умолчанию)","Вбудований (типовий)","Вграден (по подразбиране)","Уграђен (подразумевано)","Ενσωματωμένο (προεπιλογή)","Integrado (padrão)" } }, - { "Cancelled", { 0, "Geannuleerd","Abgebrochen","Annulé","Cancelado","Annullato","Отменено","Скасовано","Отменено","Отказано","Ακυρώθηκε","Cancelado" } }, - { "Channel-specific regions can be set in each channel's settings.", { 0, "Kanaalspecifieke regio's stel je in bij de instellingen van elk kanaal.","Kanalspezifische Regionen lassen sich in den Einstellungen des jeweiligen Kanals festlegen.","Les régions propres à un canal se règlent dans les paramètres de chaque canal.","Las regiones por canal se configuran en los ajustes de cada canal.","Le regioni per canale si impostano nelle impostazioni di ciascun canale.","Регионы для отдельных каналов задаются в настройках каждого канала.","Регіони для окремих каналів задаються в налаштуваннях кожного каналу.","Региони за отделни канали се задават в настройките на всеки канал.","Регије по каналу подешавају се у подешавањима сваког канала.","Οι περιοχές ανά κανάλι ορίζονται στις ρυθμίσεις κάθε καναλιού.","Regiões por canal são definidas nas configurações de cada canal." } }, - { "Chat bubbles", { 0, "Chatbubbels","Sprechblasen","Bulles de discussion","Burbujas de chat","Fumetti chat","Пузыри сообщений","Бульбашки чату","Балончета в чата","Облачићи ћаскања","Φυσαλίδες συνομιλίας","Balões de chat" } }, - { "Chat icon reset to letters", { 0, "Chatpictogram teruggezet naar letters","Chat-Symbol auf Buchstaben zurückgesetzt","Icône du chat remise en lettres","Icono del chat restablecido a letras","Icona chat ripristinata alle lettere","Значок чата сброшен на буквы","Значок чату скинуто до літер","Иконата на чата е върната към букви","Икона ћаскања враћена на слова","Εικονίδιο συνομιλίας ξανά σε γράμματα","Ícone do chat redefinido para letras" } }, - { "Chat icon set", { 0, "Chatpictogram ingesteld","Chat-Symbol gesetzt","Icône du chat définie","Icono del chat establecido","Icona chat impostata","Значок чата установлен","Значок чату встановлено","Иконата на чата е зададена","Икона ћаскања постављена","Ορίστηκε εικονίδιο συνομιλίας","Ícone do chat definido" } }, - { "Clear battery history?", { 0, "Batterijgeschiedenis wissen?","Akkuverlauf löschen?","Effacer l'historique batterie ?","¿Borrar el historial de batería?","Cancellare la cronologia batteria?","Очистить историю батареи?","Очистити історію батареї?","Изчистване на историята на батерията?","Обрисати историју батерије?","Διαγραφή ιστορικού μπαταρίας;","Limpar histórico da bateria?" } }, - { "Clear telemetry history for this node?", { 0, "Telemetriegeschiedenis van deze node wissen?","Telemetrieverlauf dieses Nodes löschen?","Effacer l'historique de télémétrie de ce nœud ?","¿Borrar el historial de telemetría de este nodo?","Cancellare la cronologia telemetria di questo nodo?","Очистить историю телеметрии этого узла?","Очистити історію телеметрії цього вузла?","Изчистване на телеметрията за този възел?","Обрисати историју телеметрије овог чвора?","Διαγραφή ιστορικού τηλεμετρίας του κόμβου;","Limpar histórico de telemetria deste nó?" } }, - { "Clock: 12-hour", { 0, "Klok: 12-uurs","Uhr: 12-Stunden","Horloge : 12 h","Reloj: 12 horas","Orologio: 12 ore","Часы: 12-часовые","Годинник: 12-годинний","Часовник: 12-часов","Сат: 12-часовни","Ρολόι: 12ωρο","Relógio: 12 horas" } }, - { "Clock: 24-hour", { 0, "Klok: 24-uurs","Uhr: 24-Stunden","Horloge : 24 h","Reloj: 24 horas","Orologio: 24 ore","Часы: 24-часовые","Годинник: 24-годинний","Часовник: 24-часов","Сат: 24-часовни","Ρολόι: 24ωρο","Relógio: 24 horas" } }, - { "Compact messages", { 0, "Compacte berichten","Kompakte Nachrichten","Messages compacts","Mensajes compactos","Messaggi compatti","Компактные сообщения","Компактні повідомлення","Компактни съобщения","Компактне поруке","Συμπαγή μηνύματα","Mensagens compactas" } }, - { "Compact messages (IRC style)", { 0, "Compacte berichten (IRC-stijl)","Kompakte Nachrichten (IRC-Stil)","Messages compacts (style IRC)","Mensajes compactos (estilo IRC)","Messaggi compatti (stile IRC)","Компактные сообщения (стиль IRC)","Компактні повідомлення (стиль IRC)","Компактни съобщения (IRC стил)","Компактне поруке (IRC стил)","Συμπαγή μηνύματα (στυλ IRC)","Mensagens compactas (estilo IRC)" } }, - { "Connect", { 0, "Verbinden","Verbinden","Connecter","Conectar","Connetti","Подключить","Підключити","Свързване","Повежи","Σύνδεση","Conectar" } }, - { "Connect to Wi-Fi first, then try again.", { 0, "Verbind eerst met Wi-Fi en probeer het opnieuw.","Erst mit Wi-Fi verbinden, dann erneut versuchen.","Connectez-vous d'abord au Wi-Fi, puis réessayez.","Conéctate primero al Wi-Fi y vuelve a intentarlo.","Connettiti prima al Wi-Fi, poi riprova.","Сначала подключитесь к Wi-Fi, затем повторите.","Спочатку підключіться до Wi-Fi, потім повторіть.","Първо се свържете с Wi-Fi и опитайте отново.","Прво се повежите на Wi-Fi, па покушајте поново.","Συνδεθείτε πρώτα στο Wi-Fi και δοκιμάστε ξανά.","Conecte-se ao Wi-Fi primeiro e tente novamente." } }, - { "Connected", { 0, "Verbonden","Verbunden","Connecté","Conectado","Connesso","Подключено","Підключено","Свързано","Повезано","Συνδεδεμένο","Conectado" } }, - { "Connecting…", { 0, "Verbinden…","Verbinde…","Connexion…","Conectando…","Connessione…","Подключение…","Підключення…","Свързване…","Повезивање…","Σύνδεση…","Conectando…" } }, - { "Console not available on this build", { 0, "Console niet beschikbaar in deze build","Konsole in diesem Build nicht verfügbar","Console indisponible sur cette version","Consola no disponible en esta versión","Console non disponibile in questa build","Консоль недоступна в этой сборке","Консоль недоступна в цій збірці","Конзолата не е налична в този билд","Конзола није доступна у овој верзији","Η κονσόλα δεν είναι διαθέσιμη σε αυτό το build","Console indisponível nesta versão" } }, - { "Copy failed (no internal identity)", { 0, "Kopiëren mislukt (geen interne identiteit)","Kopieren fehlgeschlagen (keine interne Identität)","Échec de la copie (aucune identité interne)","Error al copiar (sin identidad interna)","Copia non riuscita (nessuna identità interna)","Ошибка копирования (нет внутренней идентичности)","Помилка копіювання (немає внутрішньої ідентичності)","Копирането не успя (няма вътрешна идентичност)","Копирање није успело (нема интерног идентитета)","Αποτυχία αντιγραφής (χωρίς εσωτερική ταυτότητα)","Falha na cópia (sem identidade interna)" } }, - { "Copying internal data to SD...", { 0, "Interne data naar SD kopiëren...","Interne Daten werden auf SD kopiert...","Copie des données internes vers la SD...","Copiando datos internos a la SD...","Copia dei dati interni su SD...","Копирование внутренних данных на SD...","Копіювання внутрішніх даних на SD...","Копиране на вътрешните данни към SD...","Копирање интерних података на SD...","Αντιγραφή εσωτερικών δεδομένων στην SD...","Copiando dados internos para o SD..." } }, - { "Could not save the crash report", { 0, "Kon het crashrapport niet opslaan","Absturzbericht konnte nicht gespeichert werden","Impossible d'enregistrer le rapport de plantage","No se pudo guardar el informe de fallo","Impossibile salvare il rapporto di crash","Не удалось сохранить отчёт о сбое","Не вдалося зберегти звіт про збій","Докладът за срив не се записа","Извештај о паду није сачуван","Η αναφορά σφάλματος δεν αποθηκεύτηκε","Não foi possível salvar o relatório de falha" } }, - { "Couldn't fetch terrain data.\nCheck the connection and the\nelevation server, then\ntry again.", { 0, "Kon terreindata niet ophalen.\nControleer de verbinding en de\nhoogteserver en probeer\nhet opnieuw.","Geländedaten nicht abrufbar.\nVerbindung und Höhenserver\nprüfen, dann erneut\nversuchen.","Impossible de récupérer le terrain.\nVérifiez la connexion et le\nserveur d'altitude, puis\nréessayez.","No se pudieron obtener datos del terreno.\nComprueba la conexión y el\nservidor de elevación y\nvuelve a intentarlo.","Impossibile scaricare i dati del terreno.\nControlla la connessione e il\nserver di elevazione, poi\nriprova.","Не удалось получить данные рельефа.\nПроверьте соединение и\nсервер высот, затем\nповторите.","Не вдалося отримати дані рельєфу.\nПеревірте з'єднання та\nсервер висот, потім\nспробуйте ще раз.","Данните за терена не се заредиха.\nПроверете връзката и\nсървъра за височини и\nопитайте отново.","Подаци о терену нису преузети.\nПроверите везу и\nсервер за надморску висину, па\nпокушајте поново.","Αδύνατη η λήψη δεδομένων εδάφους.\nΕλέγξτε τη σύνδεση και τον\nδιακομιστή υψομέτρου και\nδοκιμάστε ξανά.","Não foi possível obter os dados do terreno.\nVerifique a conexão e o\nservidor de elevação e\ntente novamente." } }, - { "Couldn't save the name to storage", { 0, "Kon de naam niet opslaan","Name konnte nicht gespeichert werden","Impossible d'enregistrer le nom","No se pudo guardar el nombre","Impossibile salvare il nome","Не удалось сохранить имя","Не вдалося зберегти ім'я","Името не можа да се запише","Име није сачувано","Το όνομα δεν αποθηκεύτηκε","Não foi possível salvar o nome" } }, - { "Couldn't send login", { 0, "Kon login niet versturen","Login konnte nicht gesendet werden","Impossible d'envoyer le login","No se pudo enviar el login","Impossibile inviare il login","Не удалось отправить запрос входа","Не вдалося надіслати запит входу","Заявката за вход не се изпрати","Пријава није послата","Αποτυχία αποστολής σύνδεσης","Não foi possível enviar o login" } }, - { "Crash export failed", { 0, "Crash-export mislukt","Crash-Export fehlgeschlagen","Échec de l'export du crash","Error al exportar el informe de fallo","Esportazione crash non riuscita","Не удалось экспортировать отчёт о сбое","Не вдалося експортувати звіт про збій","Експортът на доклада за срив не успя","Извоз извештаја о паду није успео","Αποτυχία εξαγωγής αναφοράς σφάλματος","Falha ao exportar o relatório" } }, - { "Custom UTC offset (hours)", { 0, "Aangepaste UTC-offset (uren)","Eigener UTC-Versatz (Stunden)","Décalage UTC personnalisé (heures)","Desfase UTC personalizado (horas)","Offset UTC personalizzato (ore)","Свой сдвиг UTC (часы)","Власний зсув UTC (години)","Собствено UTC отместване (часове)","Прилагођени UTC померај (сати)","Προσαρμοσμένη μετατόπιση UTC (ώρες)","Deslocamento UTC personalizado (horas)" } }, - { "DM sound", { 0, "DM-geluid","DM-Ton","Son des DM","Sonido de DM","Suono DM","Звук DM","Звук DM","Звук за DM","Звук за DM","Ήχος DM","Som de DM" } }, - { "Delete %d contact(s)?", { 0, "%d contact(en) verwijderen?","%d Kontakt(e) löschen?","Supprimer %d contact(s) ?","¿Eliminar %d contacto(s)?","Eliminare %d contatti?","Удалить %d контакт(ов)?","Видалити %d контакт(ів)?","Изтриване на %d контакт(а)?","Обрисати %d контакт(а)?","Διαγραφή %d επαφών;","Excluir %d contato(s)?" } }, - { "Delete this chat's entire history?\nThe chat itself stays.", { 0, "Volledige geschiedenis van deze chat verwijderen?\nDe chat zelf blijft staan.","Gesamten Verlauf dieses Chats löschen?\nDer Chat selbst bleibt.","Supprimer tout l'historique de cette discussion ?\nLa discussion est conservée.","¿Borrar todo el historial de este chat?\nEl chat en sí se conserva.","Eliminare l'intera cronologia di questa chat?\nLa chat resta.","Удалить всю историю этого чата?\nСам чат останется.","Видалити всю історію цього чату?\nСам чат залишиться.","Изтриване на цялата история на този чат?\nСамият чат остава.","Обрисати целу историју овог ћаскања?\nСамо ћаскање остаје.","Διαγραφή όλου του ιστορικού αυτής της συνομιλίας;\nΗ συνομιλία παραμένει.","Apagar todo o histórico deste chat?\nO chat em si permanece." } }, - { "Deleted %d", { 0, "%d verwijderd","%d gelöscht","%d supprimé(s)","%d eliminado(s)","%d eliminati","Удалено: %d","Видалено: %d","Изтрити: %d","Обрисано: %d","Διαγράφηκαν %d","%d excluído(s)" } }, - { "Deleted %d messages", { 0, "%d berichten verwijderd","%d Nachrichten gelöscht","%d messages supprimés","%d mensajes eliminados","%d messaggi eliminati","Удалено сообщений: %d","Видалено повідомлень: %d","Изтрити %d съобщения","Обрисано %d порука","Διαγράφηκαν %d μηνύματα","%d mensagens excluídas" } }, - { "Deleting contacts", { 0, "Contacten verwijderen","Kontakte werden gelöscht","Suppression des contacts","Eliminando contactos","Eliminazione contatti","Удаление контактов","Видалення контактів","Изтриване на контакти","Брисање контаката","Διαγραφή επαφών","Excluindo contatos" } }, - { "Detailed sensors", { 0, "Gedetailleerde sensoren","Detaillierte Sensoren","Capteurs détaillés","Sensores detallados","Sensori dettagliati","Подробные датчики","Докладні датчики","Подробни сензори","Детаљни сензори","Αναλυτικοί αισθητήρες","Sensores detalhados" } }, - { "Device clock differs from \"%s\" by %lu h%s", { 0, "Apparaatklok verschilt van \"%s\" met %lu u%s","Geräteuhr weicht von \"%s\" um %lu h ab%s","L'horloge diffère de \"%s\" de %lu h%s","El reloj difiere de \"%s\" en %lu h%s","L'orologio differisce da \"%s\" di %lu h%s","Часы устройства отличаются от \"%s\" на %lu ч%s","Годинник пристрою відрізняється від \"%s\" на %lu год%s","Часовникът се различава от \"%s\" с %lu ч%s","Сат уређаја се разликује од \"%s\" за %lu ч%s","Το ρολόι διαφέρει από \"%s\" κατά %lu ώ.%s","O relógio difere de \"%s\" em %lu h%s" } }, - { "Device clock differs from \"%s\" by %lu min%s", { 0, "Apparaatklok verschilt van \"%s\" met %lu min%s","Geräteuhr weicht von \"%s\" um %lu min ab%s","L'horloge diffère de \"%s\" de %lu min%s","El reloj difiere de \"%s\" en %lu min%s","L'orologio differisce da \"%s\" di %lu min%s","Часы устройства отличаются от \"%s\" на %lu мин%s","Годинник пристрою відрізняється від \"%s\" на %lu хв%s","Часовникът се различава от \"%s\" с %lu мин%s","Сат уређаја се разликује од \"%s\" за %lu мин%s","Το ρολόι διαφέρει από \"%s\" κατά %lu λεπ.%s","O relógio difere de \"%s\" em %lu min%s" } }, - { "Device name hidden", { 0, "Apparaatnaam verborgen","Gerätename ausgeblendet","Nom de l'appareil masqué","Nombre del dispositivo oculto","Nome dispositivo nascosto","Имя устройства скрыто","Ім'я пристрою приховано","Името на устройството е скрито","Име уређаја сакривено","Όνομα συσκευής κρυφό","Nome do dispositivo oculto" } }, - { "Device name shown", { 0, "Apparaatnaam zichtbaar","Gerätename sichtbar","Nom de l'appareil affiché","Nombre del dispositivo visible","Nome dispositivo visibile","Имя устройства показано","Ім'я пристрою показано","Името на устройството се показва","Име уређаја приказано","Όνομα συσκευής ορατό","Nome do dispositivo visível" } }, - { "Discovered settings", { 0, "Instellingen ontdekte nodes","Einstellungen entdeckte Nodes","Réglages des nœuds découverts","Ajustes de nodos descubiertos","Impostazioni nodi scoperti","Настройки обнаруженных","Налаштування виявлених","Настройки на откритите","Подешавања откривених","Ρυθμίσεις εντοπισμένων","Configurações de descobertos" } }, - { "Download mode… reflash over USB", { 0, "Downloadmodus… opnieuw flashen via USB","Download-Modus… per USB neu flashen","Mode download… reflasher via USB","Modo descarga… reflashear por USB","Modalità download… riflashare via USB","Режим загрузки… перепрошейте по USB","Режим завантаження… перепрошийте через USB","Режим download… флашнете отново през USB","Download режим… поново флешујте преко USB","Λειτουργία download… κάντε ξανά flash μέσω USB","Modo download… regrave via USB" } }, - { "Downloading map tiles…\n\nKeep Wi-Fi connected.\nTiles appear as they arrive\nand are saved for offline use.", { 0, "Kaarttegels downloaden…\n\nHoud Wi-Fi verbonden.\nTegels verschijnen zodra ze binnenkomen\nen worden opgeslagen voor offline gebruik.","Kartenkacheln werden geladen…\n\nWi-Fi verbunden halten.\nKacheln erscheinen beim Eintreffen\nund werden für offline gespeichert.","Téléchargement des tuiles…\n\nGardez le Wi-Fi connecté.\nLes tuiles apparaissent à leur arrivée\net sont enregistrées hors ligne.","Descargando teselas del mapa…\n\nMantén el Wi-Fi conectado.\nLas teselas aparecen al llegar\ny se guardan para uso sin conexión.","Scaricamento tasselli mappa…\n\nMantieni il Wi-Fi connesso.\nI tasselli appaiono all'arrivo\ne vengono salvati per l'uso offline.","Загрузка тайлов карты…\n\nНе отключайте Wi-Fi.\nТайлы появляются по мере загрузки\nи сохраняются для офлайн-режима.","Завантаження тайлів карти…\n\nНе вимикайте Wi-Fi.\nТайли з'являються під час завантаження\nі зберігаються для офлайн-режиму.","Изтегляне на плочки на картата…\n\nЗапазете Wi-Fi връзката.\nПлочките се появяват при пристигане\nи се запазват за офлайн употреба.","Преузимање плочица мапе…\n\nОдржавајте Wi-Fi везу.\nПлочице се појављују како стижу\nи чувају се за офлајн употребу.","Λήψη πλακιδίων χάρτη…\n\nΚρατήστε το Wi-Fi συνδεδεμένο.\nΤα πλακίδια εμφανίζονται καθώς φτάνουν\nκαι αποθηκεύονται για χρήση εκτός σύνδεσης.","Baixando blocos do mapa…\n\nMantenha o Wi-Fi conectado.\nOs blocos aparecem ao chegar\ne são salvos para uso offline." } }, - { "Edge scroll", { 0, "Randscrollen","Randscrollen","Défilement bord","Desplaz. borde","Scorri bordo","Прокрутка у края","Прокрутка з краю","Скрол в края","Скрол уз ивицу","Κύλιση στην άκρη","Rolagem na borda" } }, - { "Empty = plaintext to a private broker", { 0, "Leeg = platte tekst naar een privé-broker","Leer = Klartext an privaten Broker","Vide = texte clair vers un broker privé","Vacío = texto plano a un broker privado","Vuoto = testo in chiaro a un broker privato","Пусто = открытый текст на приватный брокер","Порожньо = відкритий текст на приватний брокер","Празно = чист текст към частен брокер","Празно = чист текст ка приватном брокеру","Κενό = απλό κείμενο σε ιδιωτικό broker","Vazio = texto puro para um broker privado" } }, - { "Enable MQTT bridge", { 0, "MQTT-brug inschakelen","MQTT-Brücke aktivieren","Activer le pont MQTT","Activar puente MQTT","Attiva ponte MQTT","Включить мост MQTT","Увімкнути міст MQTT","Активиране на MQTT мост","Омогући MQTT мост","Ενεργοποίηση γέφυρας MQTT","Ativar ponte MQTT" } }, - { "Enable only if your repeater re-floods just one region. Otherwise leave off — it can block cross-region login/DMs.", { 0, "Alleen inschakelen als je repeater slechts één regio herverspreidt. Laat anders uit — het kan login/DM's tussen regio's blokkeren.","Nur aktivieren, wenn dein Repeater nur eine Region neu flutet. Sonst aus lassen — es kann regionsübergreifende Logins/DMs blockieren.","Activez seulement si votre répéteur ne réinonde qu'une région. Sinon laissez désactivé — cela peut bloquer les connexions/DM inter-régions.","Actívalo solo si tu repetidor reinunda una sola región. Si no, déjalo apagado — puede bloquear login/MD entre regiones.","Attiva solo se il tuo ripetitore rifonde una sola regione. Altrimenti lascia off — può bloccare login/MD tra regioni.","Включайте, только если ваш ретранслятор перезаливает лишь один регион. Иначе оставьте выкл. — это может блокировать межрегиональный вход/ЛС.","Вмикайте лише якщо ваш ретранслятор перезаливає тільки один регіон. Інакше залиште вимк. — це може блокувати міжрегіональний вхід/ПП.","Активирайте само ако вашият ретранслатор презалива само един регион. Иначе оставете изкл. — може да блокира вход/ЛС между региони.","Омогућите само ако ваш репетитор поново поплављује само један регион. Иначе оставите искључено — може блокирати пријаву/ПП међу регионима.","Ενεργοποιήστε μόνο αν ο αναμεταδότης σας ξαναπλημμυρίζει μία μόνο περιοχή. Αλλιώς αφήστε το κλειστό — μπορεί να μπλοκάρει σύνδεση/ΠΜ μεταξύ περιοχών.","Ative apenas se o seu repetidor reinundar só uma região. Caso contrário, deixe desligado — pode bloquear login/DMs entre regiões." } }, - { "Encryption key (optional)", { 0, "Encryptiesleutel (optioneel)","Verschlüsselungsschlüssel (optional)","Clé de chiffrement (facultatif)","Clave de cifrado (opcional)","Chiave di cifratura (opzionale)","Ключ шифрования (необязательно)","Ключ шифрування (необов'язково)","Ключ за криптиране (по избор)","Кључ за шифровање (опционо)","Κλειδί κρυπτογράφησης (προαιρετικό)","Chave de criptografia (opcional)" } }, - { "Enter a network name", { 0, "Voer een netwerknaam in","Netzwerknamen eingeben","Saisir un nom de réseau","Introduce un nombre de red","Inserisci un nome di rete","Введите имя сети","Введіть назву мережі","Въведете име на мрежа","Унесите име мреже","Εισαγάγετε όνομα δικτύου","Digite um nome de rede" } }, - { "Enter adds a new line", { 0, "Enter voegt een nieuwe regel toe","Enter fügt neue Zeile ein","Entrée ajoute une ligne","Enter añade una línea","Invio aggiunge una riga","Enter добавляет строку","Enter додає новий рядок","Enter добавя нов ред","Enter додаје нови ред","Το Enter προσθέτει γραμμή","Enter adiciona uma linha" } }, - { "Enter key sends message", { 0, "Enter verstuurt bericht","Enter-Taste sendet Nachricht","La touche Entrée envoie le message","La tecla Enter envía el mensaje","Il tasto Invio invia il messaggio","Клавиша Enter отправляет сообщение","Клавіша Enter надсилає повідомлення","Клавишът Enter изпраща съобщението","Тастер Enter шаље поруку","Το πλήκτρο Enter στέλνει το μήνυμα","A tecla Enter envia a mensagem" } }, - { "Enter sends messages", { 0, "Enter verstuurt berichten","Enter sendet Nachrichten","Entrée envoie les messages","Enter envía los mensajes","Invio invia i messaggi","Enter отправляет сообщения","Enter надсилає повідомлення","Enter изпраща съобщения","Enter шаље поруке","Το Enter στέλνει μηνύματα","Enter envia as mensagens" } }, - { "Expansion Kit", { 0, "Uitbreidingskit","Erweiterungskit","Kit d'extension","Kit de expansión","Kit di espansione","Комплект расширения","Комплект розширення","Комплект за разширение","Комплет за проширење","Κιτ επέκτασης","Kit de expansão" } }, - { "Experimental - has known issues. Off = soft mouse cursor.", { 0, "Experimenteel - heeft bekende problemen. Uit = zachte muiscursor.","Experimentell – hat bekannte Probleme. Aus = weicher Mauszeiger.","Expérimental - problèmes connus. Désactivé = curseur logiciel.","Experimental - tiene problemas conocidos. Off = cursor de ratón suave.","Sperimentale - ha problemi noti. Off = cursore mouse software.","Экспериментально — есть известные проблемы. Выкл. = программный курсор.","Експериментально — є відомі проблеми. Вимк. = програмний курсор.","Експериментално - има известни проблеми. Изкл. = софтуерен курсор.","Експериментално - има познате проблеме. Искључено = софтверски курсор.","Πειραματικό - έχει γνωστά προβλήματα. Κλειστό = δρομέας λογισμικού.","Experimental - tem problemas conhecidos. Desligado = cursor de mouse suave." } }, - { "Favorites full (max %d)", { 0, "Favorieten vol (max %d)","Favoriten voll (max %d)","Favoris pleins (max %d)","Favoritos llenos (máx %d)","Preferiti pieni (max %d)","Избранное заполнено (макс %d)","Обране заповнене (макс %d)","Любимите са пълни (макс %d)","Омиљени пуни (макс %d)","Αγαπημένα γεμάτα (μέγ %d)","Favoritos cheios (máx %d)" } }, - { "Firmware:", { 0, "Firmware:","Firmware:","Firmware :","Firmware:","Firmware:","Прошивка:","Прошивка:","Фърмуер:","Фирмвер:","Firmware:","Firmware:" } }, - { "Flash on new message", { 0, "Knipperen bij nieuw bericht","Blinken bei neuer Nachricht","Clignoter à nouveau message","Parpadear con mensaje nuevo","Lampeggia a nuovo messaggio","Мигать при новом сообщении","Блимати при новому повідомленні","Мигане при ново съобщение","Блицај при новој поруци","Αναβόσβημα σε νέο μήνυμα","Piscar em nova mensagem" } }, - { "Flood every", { 0, "Flood elke","Flood alle","Flood toutes les","Flood cada","Flood ogni","Flood каждые","Flood кожні","Flood на всеки","Flood сваких","Flood κάθε","Flood a cada" } }, - { "Flood: relayed across the mesh (multi-hop).\nZero-hop: neighbours only (no relaying).", { 0, "Flood: doorgestuurd over het mesh (multi-hop).\nZero-hop: alleen buren (geen doorsturen).","Flood: über das Mesh weitergeleitet (Multi-Hop).\nZero-Hop: nur Nachbarn (keine Weiterleitung).","Flood : relayé sur le mesh (multi-saut).\nZero-hop : voisins seulement (pas de relais).","Flood: retransmitido por la malla (multisalto).\nZero-hop: solo vecinos (sin retransmisión).","Flood: inoltrato sulla mesh (multi-hop).\nZero-hop: solo vicini (nessun inoltro).","Flood: ретранслируется по сети (мульти-хоп).\nZero-hop: только соседи (без ретрансляции).","Flood: ретранслюється по мережі (мульти-хоп).\nZero-hop: лише сусіди (без ретрансляції).","Flood: препредаване през мрежата (мулти-хоп).\nZero-hop: само съседи (без препредаване).","Flood: прослеђено кроз мрежу (мулти-хоп).\nZero-hop: само суседи (без прослеђивања).","Flood: αναμετάδοση στο mesh (πολλαπλά άλματα).\nZero-hop: μόνο γείτονες (χωρίς αναμετάδοση).","Flood: retransmitido pela malha (multissalto).\nZero-hop: só vizinhos (sem retransmissão)." } }, - { "For Launcher installs: saves the latest firmware of the selected channel to the SD card (BINS folder).", { 0, "Voor Launcher-installaties: slaat de nieuwste firmware van het geselecteerde kanaal op de SD-kaart op (BINS-map).","Für Launcher-Installationen: speichert die neueste Firmware des gewählten Kanals auf der SD-Karte (BINS-Ordner).","Pour les installations Launcher : enregistre le dernier firmware du canal choisi sur la carte SD (dossier BINS).","Para instalaciones con Launcher: guarda el firmware más reciente del canal elegido en la tarjeta SD (carpeta BINS).","Per installazioni Launcher: salva l'ultimo firmware del canale scelto sulla scheda SD (cartella BINS).","Для установок через Launcher: сохраняет последнюю прошивку выбранного канала на SD-карту (папка BINS).","Для встановлень через Launcher: зберігає останню прошивку вибраного каналу на SD-карту (папка BINS).","За инсталации с Launcher: запазва най-новия фърмуер на избрания канал на SD картата (папка BINS).","За Launcher инсталације: чува најновији фирмвер изабраног канала на SD картицу (BINS фолдер).","Για εγκαταστάσεις Launcher: αποθηκεύει το πιο πρόσφατο firmware του επιλεγμένου καναλιού στην κάρτα SD (φάκελος BINS).","Para instalações via Launcher: salva o firmware mais recente do canal selecionado no cartão SD (pasta BINS)." } }, - { "Forget network", { 0, "Netwerk vergeten","Netzwerk vergessen","Oublier le réseau","Olvidar la red","Dimentica rete","Забыть сеть","Забути мережу","Забрави мрежата","Заборави мрежу","Διαγραφή δικτύου","Esquecer rede" } }, - { "Format SD as MESHCOMOD (FAT32)?\nAll data on the card will be erased.", { 0, "SD formatteren als MESHCOMOD (FAT32)?\nAlle gegevens op de kaart worden gewist.","SD als MESHCOMOD (FAT32) formatieren?\nAlle Daten auf der Karte werden gelöscht.","Formater la SD en MESHCOMOD (FAT32) ?\nToutes les données de la carte seront effacées.","¿Formatear SD como MESHCOMOD (FAT32)?\nSe borrarán todos los datos de la tarjeta.","Formattare SD come MESHCOMOD (FAT32)?\nTutti i dati sulla scheda verranno cancellati.","Форматировать SD как MESHCOMOD (FAT32)?\nВсе данные на карте будут стёрты.","Форматувати SD як MESHCOMOD (FAT32)?\nУсі дані на карті буде стерто.","Форматиране на SD като MESHCOMOD (FAT32)?\nВсички данни на картата ще бъдат изтрити.","Форматирати SD као MESHCOMOD (FAT32)?\nСви подаци на картици биће обрисани.","Διαμόρφωση SD ως MESHCOMOD (FAT32);\nΌλα τα δεδομένα στην κάρτα θα διαγραφούν.","Formatar SD como MESHCOMOD (FAT32)?\nTodos os dados do cartão serão apagados." } }, - { "GPS %s · Buzzer %s", { 0, "GPS %s · Zoemer %s","GPS %s · Summer %s","GPS %s · Buzzer %s","GPS %s · Zumbador %s","GPS %s · Cicalino %s","GPS %s · Зуммер %s","GPS %s · Зумер %s","GPS %s · Зумер %s","GPS %s · Зујалица %s","GPS %s · Βομβητής %s","GPS %s · Campainha %s" } }, - { "GPS %s · Buzzer %s\n%s", { 0, "GPS %s · Zoemer %s\n%s","GPS %s · Summer %s\n%s","GPS %s · Buzzer %s\n%s","GPS %s · Zumbador %s\n%s","GPS %s · Cicalino %s\n%s","GPS %s · Зуммер %s\n%s","GPS %s · Зумер %s\n%s","GPS %s · Зумер %s\n%s","GPS %s · Зујалица %s\n%s","GPS %s · Βομβητής %s\n%s","GPS %s · Campainha %s\n%s" } }, - { "Game over — score %d (tap to restart)", { 0, "Game over — score %d (tik om te herstarten)","Game over — Punkte %d (tippen für Neustart)","Partie terminée — score %d (touchez pour rejouer)","Fin del juego — puntos %d (toca para reiniciar)","Game over — punteggio %d (tocca per ricominciare)","Игра окончена — счёт %d (нажмите для рестарта)","Гру завершено — рахунок %d (торкніться для рестарту)","Край на играта — точки %d (докоснете за рестарт)","Крај игре — резултат %d (додирните за рестарт)","Τέλος παιχνιδιού — σκορ %d (πατήστε για επανεκκίνηση)","Fim de jogo — pontos %d (toque para reiniciar)" } }, - { "Get test builds (beta)", { 0, "Testversies ontvangen (bèta)","Test-Builds erhalten (Beta)","Recevoir les versions test (bêta)","Recibir versiones de prueba (beta)","Ricevi build di test (beta)","Получать тестовые сборки (бета)","Отримувати тестові збірки (бета)","Получаване на тестови версии (бета)","Преузми тест верзије (бета)","Λήψη δοκιμαστικών εκδόσεων (beta)","Receber versões de teste (beta)" } }, - { "Green pressure Gray altitude", { 0, "Groen druk Grijs hoogte","Grün Druck Grau Höhe","Vert pression Gris altitude","Verde presión Gris altitud","Verde pressione Grigio altitudine","Зелёный давление Серый высота","Зелений тиск Сірий висота","Зелено налягане Сиво надм. височина","Зелено притисак Сиво висина","Πράσινο πίεση Γκρι υψόμετρο","Verde pressão Cinza altitude" } }, - { "Hidden network", { 0, "Verborgen netwerk","Verstecktes Netzwerk","Réseau masqué","Red oculta","Rete nascosta","Скрытая сеть","Прихована мережа","Скрита мрежа","Скривена мрежа","Κρυφό δίκτυο","Rede oculta" } }, - { "Hide device name", { 0, "Apparaatnaam verbergen","Gerätenamen verbergen","Masquer le nom de l'appareil","Ocultar nombre del dispositivo","Nascondi nome dispositivo","Скрыть имя устройства","Приховати ім'я пристрою","Скриване на името на устройството","Сакриј име уређаја","Απόκρυψη ονόματος συσκευής","Ocultar nome do dispositivo" } }, - { "High-gain receiver (FEM LNA)", { 0, "Hoge-versterking ontvanger (FEM LNA)","Hochverstärkungs-Empfänger (FEM LNA)","Récepteur haut gain (FEM LNA)","Receptor de alta ganancia (FEM LNA)","Ricevitore ad alto guadagno (FEM LNA)","Приёмник с высоким усилением (FEM LNA)","Приймач з високим підсиленням (FEM LNA)","Приемник с високо усилване (FEM LNA)","Пријемник високог појачања (FEM LNA)","Δέκτης υψηλής ενίσχυσης (FEM LNA)","Receptor de alto ganho (FEM LNA)" } }, - { "Highly experimental. This forwards the text, sender name and timestamp of every message your node receives to an MQTT broker, where anyone able to read the broker can read them. Direct messages are private messages from other people who never agreed to be shared. Use a broker you control, set an encryption key below, and never a public broker.", { 0, "Zeer experimenteel. Dit stuurt de tekst, afzendernaam en tijdstempel van elk bericht dat je node ontvangt door naar een MQTT-broker, waar iedereen die de broker kan lezen ze kan lezen. Directe berichten zijn privéberichten van anderen die nooit met delen hebben ingestemd. Gebruik een broker die je zelf beheert, stel hieronder een encryptiesleutel in, en nooit een openbare broker.","Hochgradig experimentell. Dies leitet Text, Absendername und Zeitstempel jeder Nachricht, die dein Knoten empfängt, an einen MQTT-Broker weiter, wo jeder, der den Broker lesen kann, sie lesen kann. Direktnachrichten sind private Nachrichten anderer, die einer Weitergabe nie zugestimmt haben. Nutze einen Broker, den du selbst kontrollierst, setze unten einen Verschlüsselungsschlüssel und niemals einen öffentlichen Broker.","Très expérimental. Ceci transmet le texte, le nom de l'expéditeur et l'horodatage de chaque message reçu par votre nœud à un broker MQTT, où quiconque peut lire le broker peut les lire. Les messages directs sont privés, envoyés par des personnes qui n'ont jamais accepté d'être partagées. Utilisez un broker que vous contrôlez, définissez une clé de chiffrement ci-dessous, et jamais un broker public.","Muy experimental. Reenvía el texto, el nombre del remitente y la marca de tiempo de cada mensaje que recibe tu nodo a un broker MQTT, donde cualquiera que pueda leer el broker podrá leerlos. Los mensajes directos son privados, de otras personas que nunca aceptaron ser compartidas. Usa un broker que controles, define una clave de cifrado abajo, y nunca un broker público.","Molto sperimentale. Inoltra il testo, il nome del mittente e l'orario di ogni messaggio che il tuo nodo riceve a un broker MQTT, dove chiunque possa leggere il broker può leggerli. I messaggi diretti sono privati, da altre persone che non hanno mai accettato di essere condivise. Usa un broker che controlli, imposta una chiave di cifratura sotto, e mai un broker pubblico.","Крайне экспериментально. Пересылает текст, имя отправителя и метку времени каждого сообщения, которое принимает ваш узел, на брокер MQTT, где их сможет прочитать любой, кто имеет доступ к брокеру. Личные сообщения приватны — от людей, не соглашавшихся на их передачу. Используйте свой брокер, задайте ключ шифрования ниже и никогда не публичный брокер.","Вкрай експериментально. Пересилає текст, ім'я відправника й позначку часу кожного повідомлення, яке отримує ваш вузол, на брокер MQTT, де їх зможе прочитати будь-хто з доступом до брокера. Особисті повідомлення приватні — від людей, що не погоджувалися на їх передачу. Використовуйте власний брокер, задайте ключ шифрування нижче і ніколи не публічний брокер.","Изключително експериментално. Препраща текста, името на подателя и времевия печат на всяко съобщение, което възелът ви получава, към MQTT брокер, където всеки с достъп до брокера може да ги чете. Директните съобщения са лични, от хора, които никога не са се съгласили да бъдат споделяни. Използвайте брокер, който контролирате, задайте ключ за криптиране по-долу и никога публичен брокер.","Изузетно експериментално. Ово прослеђује текст, име пошиљаоца и временску ознаку сваке поруке коју ваш чвор прими на MQTT брокер, где их може прочитати свако ко има приступ брокеру. Директне поруке су приватне, од других људи који никада нису пристали да буду дељени. Користите брокер који контролишете, поставите кључ за шифровање испод, и никада јавни брокер.","Άκρως πειραματικό. Προωθεί το κείμενο, το όνομα αποστολέα και τη χρονική σήμανση κάθε μηνύματος που λαμβάνει ο κόμβος σας σε έναν broker MQTT, όπου οποιοσδήποτε μπορεί να διαβάσει τον broker μπορεί να τα διαβάσει. Τα άμεσα μηνύματα είναι ιδιωτικά, από άλλους που ποτέ δεν συμφώνησαν να κοινοποιηθούν. Χρησιμοποιήστε broker που ελέγχετε, ορίστε κλειδί κρυπτογράφησης παρακάτω, και ποτέ δημόσιο broker.","Altamente experimental. Isto encaminha o texto, o nome do remetente e a marca de tempo de cada mensagem que o seu nó recebe para um broker MQTT, onde qualquer um que possa ler o broker pode lê-las. Mensagens diretas são privadas, de outras pessoas que nunca concordaram em ser compartilhadas. Use um broker que você controle, defina uma chave de criptografia abaixo, e nunca um broker público." } }, - { "Hotkey saved", { 0, "Sneltoets opgeslagen","Hotkey gespeichert","Raccourci enregistré","Atajo guardado","Scorciatoia salvata","Горячая клавиша сохранена","Гарячу клавішу збережено","Клавишът е запазен","Пречица сачувана","Το πλήκτρο αποθηκεύτηκε","Atalho salvo" } }, - { "Humidity", { 0, "Vochtigheid","Luftfeuchtigkeit","Humidité","Humedad","Umidità","Влажность","Вологість","Влажност","Влажност","Υγρασία","Umidade" } }, - { "I accept the privacy risk", { 0, "Ik accepteer het privacyrisico","Ich akzeptiere das Datenschutzrisiko","J'accepte le risque pour la vie privée","Acepto el riesgo de privacidad","Accetto il rischio per la privacy","Я принимаю риск для конфиденциальности","Я приймаю ризик для приватності","Приемам риска за поверителността","Прихватам ризик по приватност","Αποδέχομαι τον κίνδυνο απορρήτου","Aceito o risco de privacidade" } }, - { "Idle power-save", { 0, "Energiebesparing bij inactiviteit","Energiesparen bei Inaktivität","Économie d'énergie au repos","Ahorro de energía en reposo","Risparmio energetico in inattività","Энергосбережение при простое","Енергозбереження при простої","Пестене на енергия в покой","Уштеда енергије у мировању","Εξοικονόμηση σε αδράνεια","Economia de energia ocioso" } }, - { "Idle sleep disabled", { 0, "Slaap bij inactiviteit uit","Ruheschlaf deaktiviert","Veille au repos désactivée","Suspensión en reposo desactivada","Sospensione in inattività disatt.","Сон при простое отключён","Сон при простої вимкнено","Заспиване в покой изключено","Спавање у мировању искључено","Αναστολή σε αδράνεια απενεργ.","Suspensão ociosa desativada" } }, - { "Idle sleep enabled", { 0, "Slaap bij inactiviteit aan","Ruheschlaf aktiviert","Veille au repos activée","Suspensión en reposo activada","Sospensione in inattività attiva","Сон при простое включён","Сон при простої увімкнено","Заспиване в покой включено","Спавање у мировању укључено","Αναστολή σε αδράνεια ενεργή","Suspensão ociosa ativada" } }, - { "Import backup", { 0, "Back-up importeren","Backup importieren","Importer la sauvegarde","Importar copia de seguridad","Importa backup","Импорт резервной копии","Імпорт резервної копії","Импортиране на резервно копие","Увези резервну копију","Εισαγωγή αντιγράφου","Importar backup" } }, - { "Install a previous version", { 0, "Een vorige versie installeren","Vorherige Version installieren","Installer une version précédente","Instalar una versión anterior","Installa una versione precedente","Установить предыдущую версию","Встановити попередню версію","Инсталиране на предишна версия","Инсталирај претходну верзију","Εγκατάσταση προηγούμενης έκδοσης","Instalar uma versão anterior" } }, - { "Install an earlier version", { 0, "Een eerdere versie installeren","Frühere Version installieren","Installer une version antérieure","Instalar una versión anterior","Installa una versione precedente","Установить более раннюю версию","Встановити ранішу версію","Инсталиране на по-ранна версия","Инсталирај ранију верзију","Εγκατάσταση παλαιότερης έκδοσης","Instalar uma versão anterior" } }, - { "Join network", { 0, "Verbinden met netwerk","Netzwerk beitreten","Rejoindre le réseau","Unirse a la red","Unisciti alla rete","Подключиться к сети","Приєднатися до мережі","Присъединяване към мрежа","Придружи се мрежи","Σύνδεση σε δίκτυο","Entrar na rede" } }, - { "Key already in use", { 0, "Sleutel al in gebruik","Schlüssel bereits vergeben","Clé déjà utilisée","Clave ya en uso","Chiave già in uso","Ключ уже используется","Ключ уже використовується","Ключът вече се използва","Кључ је већ у употреби","Το κλειδί χρησιμοποιείται ήδη","Chave já em uso" } }, - { "Keyboard backlight", { 0, "Toetsenbordverlichting","Tastaturbeleuchtung","Rétroéclairage clavier","Retroiluminación del teclado","Retroilluminazione tastiera","Подсветка клавиатуры","Підсвітка клавіатури","Подсветка на клавиатурата","Осветљење тастатуре","Οπίσθιος φωτισμός πληκτρολογίου","Luz do teclado" } }, - { "Keyboard nav: WASDZ on", { 0, "Toetsenbordnav.: WASDZ aan","Tastaturnav.: WASDZ an","Nav. clavier : WASDZ activé","Nav. teclado: WASDZ act.","Nav. tastiera: WASDZ on","Навигация клавиатурой: WASDZ вкл.","Навігація клавіатурою: WASDZ увімк.","Клавиатурна навиг.: WASDZ вкл.","Навиг. тастатуром: WASDZ укљ.","Πλοήγηση πληκτρ.: WASDZ ενεργό","Nav. teclado: WASDZ ligado" } }, - { "Keyboard nav: off", { 0, "Toetsenbordnav.: uit","Tastaturnav.: aus","Nav. clavier : désactivé","Nav. teclado: desact.","Nav. tastiera: off","Навигация клавиатурой: выкл.","Навігація клавіатурою: вимк.","Клавиатурна навиг.: изкл.","Навиг. тастатуром: искљ.","Πλοήγηση πληκτρ.: κλειστό","Nav. teclado: desligado" } }, - { "Keyboard navigation", { 0, "Toetsenbordnavigatie","Tastaturnavigation","Navigation clavier","Navegación por teclado","Navigazione da tastiera","Навигация клавиатурой","Навігація клавіатурою","Навигация с клавиатура","Навигација тастатуром","Πλοήγηση με πληκτρολόγιο","Navegação por teclado" } }, - { "Leave empty if not required", { 0, "Leeg laten indien niet vereist","Leer lassen, wenn nicht nötig","Laisser vide si non requis","Dejar vacío si no es necesario","Lascia vuoto se non richiesto","Оставьте пустым, если не нужно","Залиште порожнім, якщо не потрібно","Оставете празно, ако не е нужно","Оставите празно ако није потребно","Αφήστε κενό αν δεν απαιτείται","Deixe vazio se não for necessário" } }, - { "Let's set up your device.\n\nYou'll pick a name and choose your LoRa region. Takes about a minute.", { 0, "Laten we je apparaat instellen.\n\nJe kiest een naam en je LoRa-regio. Duurt ongeveer een minuut.","Richten wir dein Gerät ein.\n\nDu wählst einen Namen und deine LoRa-Region. Dauert etwa eine Minute.","Configurons votre appareil.\n\nVous choisirez un nom et votre région LoRa. Cela prend environ une minute.","Configuremos tu dispositivo.\n\nElegirás un nombre y tu región LoRa. Tarda alrededor de un minuto.","Configuriamo il tuo dispositivo.\n\nSceglierai un nome e la tua regione LoRa. Richiede circa un minuto.","Настроим ваше устройство.\n\nВы выберете имя и регион LoRa. Займёт около минуты.","Налаштуймо ваш пристрій.\n\nВи виберете ім'я та регіон LoRa. Займе близько хвилини.","Нека настроим устройството ви.\n\nЩе изберете име и вашия LoRa регион. Отнема около минута.","Хајде да подесимо ваш уређај.\n\nИзабраћете име и ваш LoRa регион. Траје око минут.","Ας ρυθμίσουμε τη συσκευή σας.\n\nΘα επιλέξετε όνομα και την περιοχή LoRa. Διαρκεί περίπου ένα λεπτό.","Vamos configurar seu dispositivo.\n\nVocê escolherá um nome e sua região LoRa. Leva cerca de um minuto." } }, - { "Letters only", { 0, "Alleen letters","Nur Buchstaben","Lettres uniquement","Solo letras","Solo lettere","Только буквы","Лише літери","Само букви","Само слова","Μόνο γράμματα","Somente letras" } }, - { "Loading map tiles…\n\nKeep Wi-Fi connected.\nTiles appear as they arrive.", { 0, "Kaarttegels laden…\n\nHoud Wi-Fi verbonden.\nTegels verschijnen zodra ze binnenkomen.","Kartenkacheln werden geladen…\n\nWi-Fi verbunden halten.\nKacheln erscheinen beim Eintreffen.","Chargement des tuiles…\n\nGardez le Wi-Fi connecté.\nLes tuiles apparaissent à leur arrivée.","Cargando teselas del mapa…\n\nMantén el Wi-Fi conectado.\nLas teselas aparecen al llegar.","Caricamento tasselli mappa…\n\nMantieni il Wi-Fi connesso.\nI tasselli appaiono all'arrivo.","Загрузка тайлов карты…\n\nНе отключайте Wi-Fi.\nТайлы появляются по мере загрузки.","Завантаження тайлів карти…\n\nНе вимикайте Wi-Fi.\nТайли з'являються під час завантаження.","Зареждане на плочки на картата…\n\nЗапазете Wi-Fi връзката.\nПлочките се появяват при пристигане.","Учитавање плочица мапе…\n\nОдржавајте Wi-Fi везу.\nПлочице се појављују како стижу.","Φόρτωση πλακιδίων χάρτη…\n\nΚρατήστε το Wi-Fi συνδεδεμένο.\nΤα πλακίδια εμφανίζονται καθώς φτάνουν.","Carregando blocos do mapa…\n\nMantenha o Wi-Fi conectado.\nOs blocos aparecem ao chegar." } }, - { "Local every", { 0, "Lokaal elke","Lokal alle","Local toutes les","Local cada","Locale ogni","Локально каждые","Локально кожні","Локално на всеки","Локално сваких","Τοπικά κάθε","Local a cada" } }, - { "Local sensors", { 0, "Lokale sensoren","Lokale Sensoren","Capteurs locaux","Sensores locales","Sensori locali","Локальные датчики","Локальні датчики","Локални сензори","Локални сензори","Τοπικοί αισθητήρες","Sensores locais" } }, - { "Lock when screen off", { 0, "Vergrendelen bij scherm uit","Sperren bei Bildschirm aus","Verrouiller écran éteint","Bloquear con pantalla apagada","Blocca a schermo spento","Блокировать при выкл. экране","Блокувати при вимк. екрані","Заключване при изключен екран","Закључај кад је екран искључен","Κλείδωμα με κλειστή οθόνη","Bloquear com tela desligada" } }, - { "Locks when screen off\n(hold the d-pad to unlock)", { 0, "Vergrendelt bij scherm uit\n(houd de d-pad vast om te ontgrendelen)","Sperrt bei Bildschirm aus\n(D-Pad halten zum Entsperren)","Verrouille écran éteint\n(maintenez la croix pour déverrouiller)","Se bloquea con pantalla apagada\n(mantén el d-pad para desbloquear)","Si blocca a schermo spento\n(tieni il d-pad per sbloccare)","Блокируется при выкл. экране\n(удерживайте d-pad для разблокировки)","Блокується при вимк. екрані\n(утримуйте d-pad для розблокування)","Заключва се при изключен екран\n(задръжте d-pad за отключване)","Закључава се кад је екран искључен\n(држите d-pad да откључате)","Κλειδώνει με κλειστή οθόνη\n(κρατήστε το d-pad για ξεκλείδωμα)","Bloqueia com tela desligada\n(segure o d-pad para desbloquear)" } }, - { "Locks when screen off\n(hold the trackball to unlock)", { 0, "Vergrendelt bij scherm uit\n(houd de trackball vast om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Trackball halten zum Entsperren)","Verrouille écran éteint\n(maintenez le trackball pour déverrouiller)","Se bloquea con pantalla apagada\n(mantén el trackball para desbloquear)","Si blocca a schermo spento\n(tieni il trackball per sbloccare)","Блокируется при выкл. экране\n(удерживайте трекбол для разблокировки)","Блокується при вимк. екрані\n(утримуйте трекбол для розблокування)","Заключва се при изключен екран\n(задръжте тракбола за отключване)","Закључава се кад је екран искључен\n(држите трекбол да откључате)","Κλειδώνει με κλειστή οθόνη\n(κρατήστε το trackball για ξεκλείδωμα)","Bloqueia com tela desligada\n(segure o trackball para desbloquear)" } }, - { "Locks when screen off\n(press Volume Down to unlock)", { 0, "Vergrendelt bij scherm uit\n(druk op Volume Omlaag om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Lautstärke leiser drücken zum Entsperren)","Verrouille écran éteint\n(appuyez sur Volume Bas pour déverrouiller)","Se bloquea con pantalla apagada\n(pulsa Bajar volumen para desbloquear)","Si blocca a schermo spento\n(premi Volume Giù per sbloccare)","Блокируется при выкл. экране\n(нажмите Громкость вниз для разблокировки)","Блокується при вимк. екрані\n(натисніть Гучність вниз для розблокування)","Заключва се при изключен екран\n(натиснете Сила надолу за отключване)","Закључава се кад је екран искључен\n(притисните Јачина доле да откључате)","Κλειδώνει με κλειστή οθόνη\n(πατήστε Μείωση έντασης για ξεκλείδωμα)","Bloqueia com tela desligada\n(pressione Volume para baixo para desbloquear)" } }, - { "Locks when screen off\n(press the button to unlock)", { 0, "Vergrendelt bij scherm uit\n(druk op de knop om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Taste drücken zum Entsperren)","Verrouille écran éteint\n(appuyez sur le bouton pour déverrouiller)","Se bloquea con pantalla apagada\n(pulsa el botón para desbloquear)","Si blocca a schermo spento\n(premi il pulsante per sbloccare)","Блокируется при выкл. экране\n(нажмите кнопку для разблокировки)","Блокується при вимк. екрані\n(натисніть кнопку для розблокування)","Заключва се при изключен екран\n(натиснете бутона за отключване)","Закључава се кад је екран искључен\n(притисните дугме да откључате)","Κλειδώνει με κλειστή οθόνη\n(πατήστε το κουμπί για ξεκλείδωμα)","Bloqueia com tela desligada\n(pressione o botão para desbloquear)" } }, - { "Logging in again...", { 0, "Opnieuw inloggen...","Erneute Anmeldung...","Reconnexion...","Iniciando sesión de nuevo...","Nuovo accesso...","Повторный вход...","Повторний вхід...","Повторно влизане...","Поновно пријављивање...","Επανασύνδεση...","Entrando novamente..." } }, - { "Map tiles: microSD\n\n/maps/osm/z/x/y.png\n(or /tiles/z/x/y.jpg)\n\nMap appears when a tile\nfor this area is found.", { 0, "Kaarttegels: microSD\n\n/maps/osm/z/x/y.png\n(of /tiles/z/x/y.jpg)\n\nKaart verschijnt zodra een tegel\nvoor dit gebied is gevonden.","Kartenkacheln: microSD\n\n/maps/osm/z/x/y.png\n(oder /tiles/z/x/y.jpg)\n\nKarte erscheint, sobald eine Kachel\nfür dieses Gebiet gefunden wird.","Tuiles de carte : microSD\n\n/maps/osm/z/x/y.png\n(ou /tiles/z/x/y.jpg)\n\nLa carte apparaît quand une tuile\npour cette zone est trouvée.","Teselas del mapa: microSD\n\n/maps/osm/z/x/y.png\n(o /tiles/z/x/y.jpg)\n\nEl mapa aparece cuando se encuentra\nuna tesela para esta zona.","Tasselli mappa: microSD\n\n/maps/osm/z/x/y.png\n(o /tiles/z/x/y.jpg)\n\nLa mappa appare quando si trova\nun tassello per questa zona.","Тайлы карты: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nКарта появляется, когда найден\nтайл для этой области.","Тайли карти: microSD\n\n/maps/osm/z/x/y.png\n(або /tiles/z/x/y.jpg)\n\nКарта з'являється, коли знайдено\nтайл для цієї області.","Плочки на картата: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nКартата се появява, когато се намери\nплочка за тази област.","Плочице мапе: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nМапа се појављује када се пронађе\nплочица за ову област.","Πλακίδια χάρτη: microSD\n\n/maps/osm/z/x/y.png\n(ή /tiles/z/x/y.jpg)\n\nΟ χάρτης εμφανίζεται όταν βρεθεί\nπλακίδιο για αυτήν την περιοχή.","Blocos do mapa: microSD\n\n/maps/osm/z/x/y.png\n(ou /tiles/z/x/y.jpg)\n\nO mapa aparece quando um bloco\npara esta área é encontrado." } }, - { "Map — no tile pack on SPIFFS yet.\n\nUpload a tile pack to /tiles///.jpg with the host-side generator.", { 0, "Kaart — nog geen tegelpakket op SPIFFS.\n\nUpload een tegelpakket naar /tiles///.jpg met de host-generator.","Karte — noch kein Kachelpaket auf SPIFFS.\n\nLade ein Kachelpaket nach /tiles///.jpg mit dem Host-Generator hoch.","Carte — pas encore de pack de tuiles sur SPIFFS.\n\nTéléversez un pack vers /tiles///.jpg avec le générateur côté hôte.","Mapa — aún no hay paquete de teselas en SPIFFS.\n\nSube un paquete a /tiles///.jpg con el generador del host.","Mappa — nessun pacchetto tasselli su SPIFFS.\n\nCarica un pacchetto in /tiles///.jpg con il generatore lato host.","Карта — на SPIFFS ещё нет пакета тайлов.\n\nЗагрузите пакет в /tiles///.jpg генератором на хосте.","Карта — на SPIFFS ще немає пакета тайлів.\n\nЗавантажте пакет у /tiles///.jpg генератором на хості.","Карта — все още няма пакет с плочки на SPIFFS.\n\nКачете пакет в /tiles///.jpg с генератора от хоста.","Мапа — још нема пакета плочица на SPIFFS.\n\nОтпремите пакет у /tiles///.jpg генератором на хосту.","Χάρτης — δεν υπάρχει ακόμη πακέτο πλακιδίων στο SPIFFS.\n\nΑνεβάστε πακέτο στο /tiles///.jpg με τη γεννήτρια πλευράς host.","Mapa — ainda sem pacote de blocos no SPIFFS.\n\nEnvie um pacote para /tiles///.jpg com o gerador no host." } }, - { "Mesh health: a flood advert is rebroadcast by every repeater in range, so frequent floods add airtime for the whole network. Keep them infrequent (a day or more). Your neighbours still see you from zero-hop adverts and your message replies, so flood mainly when distant nodes need to rediscover you. Leave both Off to advertise only when you tap the buttons above.", { 0, "Mesh-gezondheid: een flood-advert wordt door elke repeater in bereik opnieuw uitgezonden, dus frequente floods verhogen de airtime voor het hele netwerk. Houd ze zeldzaam (een dag of meer). Je buren zien je nog steeds via zero-hop adverts en je berichtantwoorden, dus flood vooral wanneer verre nodes je opnieuw moeten ontdekken. Laat beide uit om alleen te adverteren als je op de knoppen hierboven tikt.","Mesh-Gesundheit: ein Flood-Advert wird von jedem Repeater in Reichweite erneut gesendet, häufige Floods erhöhen also die Sendezeit für das ganze Netz. Halte sie selten (ein Tag oder mehr). Deine Nachbarn sehen dich weiterhin über Zero-Hop-Adverts und deine Antworten, also floode vor allem, wenn ferne Knoten dich wiederfinden müssen. Lass beide aus, um nur bei Tippen der Tasten oben zu adverten.","Santé du mesh : un advert flood est réémis par chaque répéteur à portée, donc les floods fréquents ajoutent du temps d'antenne pour tout le réseau. Gardez-les rares (un jour ou plus). Vos voisins vous voient encore via les adverts zero-hop et vos réponses, alors floodez surtout quand des nœuds lointains doivent vous redécouvrir. Laissez les deux désactivés pour n'adverter qu'en touchant les boutons ci-dessus.","Salud de la malla: un advert flood lo reemite cada repetidor a su alcance, así que los floods frecuentes suman tiempo de aire para toda la red. Manténlos poco frecuentes (un día o más). Tus vecinos aún te ven por los adverts zero-hop y tus respuestas, así que haz flood sobre todo cuando nodos lejanos deban redescubrirte. Deja ambos apagados para anunciarte solo al pulsar los botones de arriba.","Salute della mesh: un advert flood viene ritrasmesso da ogni ripetitore a portata, quindi flood frequenti aumentano il tempo d'antenna per l'intera rete. Tienili rari (un giorno o più). I tuoi vicini ti vedono ancora dagli advert zero-hop e dalle tue risposte, quindi fai flood soprattutto quando nodi lontani devono riscoprirti. Lascia entrambi off per annunciarti solo toccando i pulsanti sopra.","Здоровье сети: flood-advert повторно рассылает каждый ретранслятор в зоне, поэтому частые flood увеличивают эфирное время всей сети. Делайте их редкими (раз в день и реже). Соседи всё равно видят вас по zero-hop адвертам и вашим ответам, так что делайте flood в основном, когда далёким узлам нужно вас заново найти. Оставьте оба выкл., чтобы объявляться только по нажатию кнопок выше.","Здоров'я мережі: flood-advert повторно розсилає кожен ретранслятор у зоні, тож часті flood збільшують ефірний час усієї мережі. Робіть їх рідкісними (раз на день чи рідше). Сусіди все одно бачать вас за zero-hop адвертами й вашими відповідями, тож робіть flood переважно коли далеким вузлам треба знову вас знайти. Залиште обидва вимк., щоб оголошуватися лише за натисканням кнопок вище.","Здраве на мрежата: flood advert се препредава от всеки ретранслатор в обхват, затова честите flood добавят ефирно време за цялата мрежа. Пазете ги редки (ден или повече). Съседите ви пак ви виждат по zero-hop адверти и отговорите ви, затова правете flood основно когато далечни възли трябва да ви открият отново. Оставете и двете изкл., за да се обявявате само при натискане на бутоните горе.","Здравље мреже: flood advert поново емитује сваки репетитор у домету, па чести flood-ови додају емисионо време целој мрежи. Држите их ретким (дан или више). Суседи вас и даље виде преко zero-hop адверта и ваших одговора, зато радите flood углавном када удаљени чворови треба поново да вас открију. Оставите оба искључена да се оглашавате само притиском на дугмад изнад.","Υγεία mesh: ένα flood advert αναμεταδίδεται από κάθε αναμεταδότη σε εμβέλεια, οπότε τα συχνά flood προσθέτουν χρόνο εκπομπής για όλο το δίκτυο. Κρατήστε τα σπάνια (μία μέρα ή περισσότερο). Οι γείτονες σας βλέπουν ακόμη από zero-hop adverts και τις απαντήσεις σας, γι' αυτό κάντε flood κυρίως όταν μακρινοί κόμβοι πρέπει να σας ξαναβρούν. Αφήστε και τα δύο κλειστά για να διαφημίζεστε μόνο πατώντας τα κουμπιά πάνω.","Saúde da malha: um advert flood é retransmitido por cada repetidor ao alcance, então floods frequentes somam tempo de antena para toda a rede. Mantenha-os raros (um dia ou mais). Seus vizinhos ainda o veem pelos adverts zero-hop e suas respostas, então faça flood principalmente quando nós distantes precisarem redescobri-lo. Deixe ambos desligados para anunciar só ao tocar os botões acima." } }, - { "Message LED", { 0, "Bericht-LED","Nachrichten-LED","LED de message","LED de mensaje","LED messaggi","Светодиод сообщений","Світлодіод повідомлень","LED за съобщения","LED за поруке","LED μηνυμάτων","LED de mensagem" } }, - { "Message LED off", { 0, "Bericht-LED uit","Nachrichten-LED aus","LED de message éteint","LED de mensaje apagado","LED messaggi off","Светодиод сообщений выкл.","Світлодіод повідомлень вимк.","LED за съобщения изкл.","LED за поруке искључен","LED μηνυμάτων κλειστό","LED de mensagem desligado" } }, - { "Message LED on", { 0, "Bericht-LED aan","Nachrichten-LED an","LED de message allumé","LED de mensaje encendido","LED messaggi on","Светодиод сообщений вкл.","Світлодіод повідомлень увімк.","LED за съобщения вкл.","LED за поруке укључен","LED μηνυμάτων ανοιχτό","LED de mensagem ligado" } }, - { "Message deleted", { 0, "Bericht verwijderd","Nachricht gelöscht","Message supprimé","Mensaje eliminado","Messaggio eliminato","Сообщение удалено","Повідомлення видалено","Съобщението е изтрито","Порука обрисана","Το μήνυμα διαγράφηκε","Mensagem excluída" } }, - { "Message didn't get through.\nResend it?", { 0, "Bericht is niet aangekomen.\nOpnieuw versturen?","Nachricht kam nicht an.\nErneut senden?","Le message n'est pas passé.\nRenvoyer ?","El mensaje no llegó.\n¿Reenviarlo?","Il messaggio non è arrivato.\nInviarlo di nuovo?","Сообщение не дошло.\nОтправить снова?","Повідомлення не дійшло.\nНадіслати знову?","Съобщението не стигна.\nДа го изпратя ли пак?","Порука није стигла.\nПослати поново?","Το μήνυμα δεν έφτασε.\nΝα σταλεί ξανά;","A mensagem não chegou.\nReenviar?" } }, - { "Message sound", { 0, "Berichtgeluid","Nachrichtenton","Son des messages","Sonido de mensaje","Suono messaggi","Звук сообщений","Звук повідомлень","Звук на съобщения","Звук порука","Ήχος μηνυμάτων","Som de mensagem" } }, + { " (device behind)", { 0, " (apparaat loopt achter)"," (Gerät geht nach)"," (appareil en retard)"," (dispositivo atrasado)"," (dispositivo indietro)"," (устройство отстаёт)"," (пристрій відстає)"," (устройството изостава)"," (уређај касни)"," (συσκευή πίσω)"," (dispositivo atrasado)"," (dispozitiv în urmă)" } }, + { "%.3f MHz · SF%u · TX %+ddBm", { 0, "%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f МГц · SF%u · TX %+ddBm","%.3f МГц · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm","%.3f MHz · SF%u · TX %+ddBm" } }, + { "%u types · hops %u · manual %s", { 0, "%u types · hops %u · handmatig %s","%u Typen · Hops %u · manuell %s","%u types · sauts %u · manuel %s","%u tipos · saltos %u · manual %s","%u tipi · salti %u · manuale %s","%u типов · хопы %u · вручную %s","%u типів · хопи %u · вручну %s","%u типа · хопове %u · ръчно %s","%u типова · хопови %u · ручно %s","%u τύποι · άλματα %u · χειροκίνητα %s","%u tipos · saltos %u · manual %s","%u tipuri · salturi %u · manual %s" } }, + { "(no default)", { 0, "(geen standaard)","(kein Standard)","(aucun par défaut)","(sin predeterminado)","(nessun predefinito)","(нет по умолчанию)","(немає типового)","(няма по подразбиране)","(нема подразумеваног)","(χωρίς προεπιλογή)","(sem padrão)","(fără implicit)" } }, + { "0123456789abcdef…", { 0, "0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…","0123456789abcdef…" } }, + { "1 byte (legacy)\n2 bytes\n3 bytes", { 0, "1 byte (verouderd)\n2 bytes\n3 bytes","1 Byte (Legacy)\n2 Bytes\n3 Bytes","1 octet (hérité)\n2 octets\n3 octets","1 byte (heredado)\n2 bytes\n3 bytes","1 byte (legacy)\n2 byte\n3 byte","1 байт (устар.)\n2 байта\n3 байта","1 байт (застар.)\n2 байти\n3 байти","1 байт (стар)\n2 байта\n3 байта","1 бајт (старо)\n2 бајта\n3 бајта","1 byte (παλαιό)\n2 bytes\n3 bytes","1 byte (legado)\n2 bytes\n3 bytes","1 octet (vechi)\n2 octeţi\n3 octeţi" } }, + { "12-hour clock", { 0, "12-uursklok","12-Stunden-Uhr","Horloge 12 h","Reloj de 12 horas","Orologio 12 ore","12-часовой формат","12-годинний формат","12-часов формат","12-часовни формат","Ρολόι 12 ωρών","Relógio de 12 horas","Ceas de 12 ore" } }, + { "@ Mentions", { 0, "@ Vermeldingen","@ Erwähnungen","@ Mentions","@ Menciones","@ Menzioni","@ Упоминания","@ Згадки","@ Споменавания","@ Помињања","@ Αναφορές","@ Menções","@ Menţiuni" } }, + { "@ mention sound", { 0, "Geluid bij @-vermelding","Ton bei @-Erwähnung","Son des mentions @","Sonido de menciones @","Suono menzioni @","Звук @-упоминаний","Звук @-згадок","Звук при @ споменаване","Звук за @ помињање","Ήχος αναφορών @","Som de menções @","Sunet menţiune @" } }, + { "AUTOMATIC ADVERTS", { 0, "AUTOMATISCHE ADVERTS","AUTOMATISCHE ADVERTS","ADVERTS AUTOMATIQUES","ADVERTS AUTOMÁTICOS","ADVERT AUTOMATICI","АВТОМАТИЧЕСКИЕ АДВЕРТЫ","АВТОМАТИЧНІ АДВЕРТИ","АВТОМАТИЧНИ АДВЕРТИ","АУТОМАТСКИ АДВЕРТИ","ΑΥΤΟΜΑΤΑ ADVERTS","ADVERTS AUTOMÁTICOS","ADVERTURI AUTOMATE" } }, + { "Answer telemetry requests", { 0, "Telemetrieverzoeken beantwoorden","Telemetrieanfragen beantworten","Répondre aux demandes de télémétrie","Responder solicitudes de telemetría","Rispondi alle richieste di telemetria","Отвечать на запросы телеметрии","Відповідати на запити телеметрії","Отговаряй на заявки за телеметрия","Одговарај на захтеве телеметрије","Απάντηση σε αιτήματα τηλεμετρίας","Responder solicitações de telemetria","Răspunde cererilor de telemetrie" } }, + { "App drawer as home", { 0, "App-lade als startscherm","App-Drawer als Startseite","Tiroir d'apps comme accueil","Cajón de apps como inicio","Drawer app come home","Меню приложений как главный экран","Меню додатків як головний екран","Меню с приложения като начален екран","Фиока апликација као почетни екран","Συρτάρι εφαρμογών ως αρχική","Gaveta de apps como tela inicial","Sertarul de aplicaţii ca ecran principal" } }, + { "App icon size", { 0, "Grootte app-pictogrammen","App-Symbolgröße","Taille des icônes d'app","Tamaño de iconos de app","Dimensione icone app","Размер значков приложений","Розмір значків додатків","Размер на иконите на приложенията","Величина икона апликација","Μέγεθος εικονιδίων εφαρμογών","Tamanho dos ícones de apps","Dimensiune pictograme aplicaţii" } }, + { "Applies after restart.", { 0, "Actief na herstart.","Gilt nach Neustart.","Appliqué après redémarrage.","Se aplica tras reiniciar.","Attivo dopo il riavvio.","Применяется после перезапуска.","Діє після перезапуску.","Прилага се след рестарт.","Важи након рестарта.","Ισχύει μετά την επανεκκίνηση.","Aplica-se após reiniciar.","Se aplică după repornire." } }, + { "Apply auto-poll", { 0, "Auto-poll toepassen","Auto-Poll übernehmen","Appliquer l'auto-poll","Aplicar auto-poll","Applica auto-poll","Применить автоопрос","Застосувати автоопитування","Приложи авто-запитване","Примени ауто-упит","Εφαρμογή auto-poll","Aplicar auto-poll","Aplică auto-interogarea" } }, + { "Arrows + Enter always work. These letters are extra — tap a row, then press a key.", { 0, "Pijltjes + Enter werken altijd. Deze letters zijn extra — tik op een rij en druk dan op een toets.","Pfeile + Enter funktionieren immer. Diese Buchstaben sind extra — Zeile antippen, dann Taste drücken.","Flèches + Entrée fonctionnent toujours. Ces lettres sont en plus — touchez une ligne, puis une touche.","Flechas + Enter siempre funcionan. Estas letras son extra: toca una fila y pulsa una tecla.","Frecce + Invio funzionano sempre. Queste lettere sono extra: tocca una riga, poi premi un tasto.","Стрелки + Enter работают всегда. Эти буквы — дополнительно: коснитесь строки, затем нажмите клавишу.","Стрілки + Enter працюють завжди. Ці літери — додатково: торкніться рядка, потім натисніть клавішу.","Стрелки + Enter винаги работят. Тези букви са допълнение — докосни ред, после натисни клавиш.","Стрелице + Enter увек раде. Ова слова су додатак — додирни ред, па притисни тастер.","Βέλη + Enter λειτουργούν πάντα. Τα γράμματα είναι έξτρα — αγγίξτε μια γραμμή και πατήστε ένα πλήκτρο.","Setas + Enter sempre funcionam. Estas letras são extras — toque numa linha e pressione uma tecla.","Săgeţile + Enter merg mereu. Aceste litere sunt în plus — atinge un rând, apoi apasă o tastă." } }, + { "Auto-Join", { 0, "Auto-deelnemen","Auto-Beitritt","Rejoindre auto","Auto-unirse","Auto-join","Автовход","Автоприєднання","Авто-присъединяване","Ауто-придруживање","Αυτόματη συμμετοχή","Entrada automática","Auto-intrare" } }, + { "Auto-delete above hops", { 0, "Auto-verwijderen boven hops","Auto-Löschen über Hops","Suppression auto au-delà des sauts","Autoeliminar por encima de saltos","Auto-elimina oltre i salti","Автоудаление свыше хопов","Автовидалення понад хопи","Автоизтриване над хопове","Ауто-брисање изнад хопова","Αυτ. διαγραφή πάνω από άλματα","Autoexcluir acima de saltos","Auto-ştergere peste nr. de salturi" } }, + { "Auto-delete oldest", { 0, "Oudste auto-verwijderen","Älteste auto-löschen","Suppression auto des plus anciens","Autoeliminar los más antiguos","Auto-elimina i più vecchi","Автоудаление старых","Автовидалення старих","Автоизтриване на най-старите","Ауто-брисање најстаријих","Αυτ. διαγραφή παλαιότερων","Autoexcluir os mais antigos","Auto-ştergere cele mai vechi" } }, + { "Auto-delete oldest makes room when full. Hops: 0 = keep all; nodes heard via more hops are removed.", { 0, "Oudste auto-verwijderen maakt ruimte als het vol is. Hops: 0 = alles bewaren; nodes gehoord via meer hops worden verwijderd.","Älteste auto-löschen schafft Platz, wenn voll. Hops: 0 = alle behalten; über mehr Hops gehörte Nodes werden entfernt.","La suppression auto des plus anciens libère de la place quand c'est plein. Sauts : 0 = tout garder ; les nœuds entendus via plus de sauts sont supprimés.","Autoeliminar los más antiguos libera espacio al llenarse. Saltos: 0 = conservar todos; se eliminan los nodos oídos con más saltos.","Auto-elimina i più vecchi libera spazio quando è pieno. Salti: 0 = conserva tutti; i nodi sentiti con più salti vengono rimossi.","Автоудаление старых освобождает место при заполнении. Хопы: 0 = хранить все; узлы, слышимые через больше хопов, удаляются.","Автовидалення старих звільняє місце при заповненні. Хопи: 0 = зберігати всі; вузли, чутні через більше хопів, видаляються.","Автоизтриването на най-старите освобождава място при запълване. Хопове: 0 = пази всички; възли, чути през повече хопове, се премахват.","Ауто-брисање најстаријих прави место кад је пуно. Хопови: 0 = задржи све; чворови чувени преко више хопова се уклањају.","Η αυτ. διαγραφή παλαιότερων ελευθερώνει χώρο όταν γεμίσει. Άλματα: 0 = όλα· κόμβοι που ακούγονται με περισσότερα άλματα αφαιρούνται.","Autoexcluir os mais antigos libera espaço quando cheio. Saltos: 0 = manter todos; nós ouvidos por mais saltos são removidos.","Auto-ştergerea celor mai vechi face loc când e plin. Salturi: 0 = păstrează tot; nodurile auzite prin mai multe salturi sunt eliminate." } }, + { "Auto-discover", { 0, "Auto-ontdekken","Auto-Erkennung","Découverte auto","Autodescubrir","Rilevamento auto","Автообнаружение","Автовиявлення","Автооткриване","Ауто-откривање","Αυτόματη ανακάλυψη","Autodescoberta","Auto-descoperire" } }, + { "Auto-discover off", { 0, "Auto-ontdekken uit","Auto-Erkennung aus","Découverte auto désactivée","Autodescubrir desactivado","Rilevamento auto disattivato","Автообнаружение выкл.","Автовиявлення вимк.","Автооткриване изкл.","Ауто-откривање искљ.","Αυτ. ανακάλυψη ανενεργή","Autodescoberta desativada","Auto-descoperire oprită" } }, + { "Auto-discover on", { 0, "Auto-ontdekken aan","Auto-Erkennung an","Découverte auto activée","Autodescubrir activado","Rilevamento auto attivato","Автообнаружение вкл.","Автовиявлення увімк.","Автооткриване вкл.","Ауто-откривање укљ.","Αυτ. ανακάλυψη ενεργή","Autodescoberta ativada","Auto-descoperire pornită" } }, + { "Auto-poll every", { 0, "Auto-poll elke","Auto-Poll alle","Auto-poll toutes les","Auto-poll cada","Auto-poll ogni","Автоопрос каждые","Автоопитування кожні","Авто-запитване на всеки","Ауто-упит на сваких","Auto-poll κάθε","Auto-poll a cada","Auto-interogare la fiecare" } }, + { "Auto-refresh off", { 0, "Auto-verversen uit","Auto-Aktualisierung aus","Actualisation auto désactivée","Autoactualizar desactivado","Aggiornamento auto disattivato","Автообновление выкл.","Автооновлення вимк.","Автообновяване изкл.","Ауто-освежавање искљ.","Αυτ. ανανέωση ανενεργή","Atualização auto desativada","Auto-reîmprospătare oprită" } }, + { "Auto-refresh on", { 0, "Auto-verversen aan","Auto-Aktualisierung an","Actualisation auto activée","Autoactualizar activado","Aggiornamento auto attivato","Автообновление вкл.","Автооновлення увімк.","Автообновяване вкл.","Ауто-освежавање укљ.","Αυτ. ανανέωση ενεργή","Atualização auto ativada","Auto-reîmprospătare pornită" } }, + { "Battery", { 0, "Batterij","Akku","Batterie","Batería","Batteria","Батарея","Батарея","Батерия","Батерија","Μπαταρία","Bateria","Baterie" } }, + { "Battery & power history", { 0, "Batterij- & energiegeschiedenis","Akku- & Energieverlauf","Historique batterie et alimentation","Historial de batería y energía","Cronologia batteria e alimentazione","История батареи и питания","Історія батареї та живлення","История на батерията и захранването","Историја батерије и напајања","Ιστορικό μπαταρίας & τροφοδοσίας","Histórico de bateria e energia","Istoric baterie şi alimentare" } }, + { "Battery (V)", { 0, "Batterij (V)","Akku (V)","Batterie (V)","Batería (V)","Batteria (V)","Батарея (В)","Батарея (В)","Батерия (V)","Батерија (V)","Μπαταρία (V)","Bateria (V)","Baterie (V)" } }, + { "Battery saver (experimental)", { 0, "Batterijbesparing (experimenteel)","Energiesparmodus (experimentell)","Économie de batterie (expérimental)","Ahorro de batería (experimental)","Risparmio batteria (sperimentale)","Экономия заряда (экспериментально)","Економія заряду (експериментально)","Пестене на батерия (експериментално)","Уштеда батерије (експериментално)","Εξοικονόμηση μπαταρίας (πειραματικό)","Economia de bateria (experimental)","Economisire baterie (experimental)" } }, + { "Battery — last 24h", { 0, "Batterij — laatste 24u","Akku — letzte 24h","Batterie — dernières 24h","Batería — últimas 24h","Batteria — ultime 24h","Батарея — последние 24 ч","Батарея — останні 24 год","Батерия — последни 24 ч","Батерија — последња 24 ч","Μπαταρία — τελευταίο 24ωρο","Bateria — últimas 24h","Baterie — ultimele 24h" } }, + { "Block list full", { 0, "Blokkeerlijst vol","Blockierliste voll","Liste de blocage pleine","Lista de bloqueo llena","Lista blocchi piena","Список блокировок полон","Список блокувань заповнено","Списъкът с блокирани е пълен","Листа блокираних је пуна","Λίστα αποκλεισμού πλήρης","Lista de bloqueio cheia","Listă de blocare plină" } }, + { "Blocked — their messages are hidden", { 0, "Geblokkeerd — berichten verborgen","Blockiert — Nachrichten ausgeblendet","Bloqué — ses messages sont masqués","Bloqueado — sus mensajes se ocultan","Bloccato — messaggi nascosti","Заблокировано — сообщения скрыты","Заблоковано — повідомлення приховано","Блокиран — съобщенията му са скрити","Блокиран — поруке су скривене","Αποκλεισμένος — τα μηνύματα κρύβονται","Bloqueado — as mensagens ficam ocultas","Blocat — mesajele lui sunt ascunse" } }, + { "Blocking unsupported", { 0, "Blokkeren niet ondersteund","Blockieren nicht unterstützt","Blocage non pris en charge","Bloqueo no compatible","Blocco non supportato","Блокировка не поддерживается","Блокування не підтримується","Блокирането не се поддържа","Блокирање није подржано","Ο αποκλεισμός δεν υποστηρίζεται","Bloqueio não suportado","Blocarea nu este acceptată" } }, + { "Blue battery Last 6 min", { 0, "Blauw batterij Laatste 6 min","Blau Akku Letzte 6 Min","Bleu batterie 6 dernières min","Azul batería Últimos 6 min","Blu batteria Ultimi 6 min","Синий — батарея Последние 6 мин","Синій — батарея Останні 6 хв","Синьо — батерия Последни 6 мин","Плаво — батерија Последњих 6 мин","Μπλε μπαταρία Τελευταία 6 λεπ.","Azul bateria Últimos 6 min","Albastru baterie Ultimele 6 min" } }, + { "Blue battery Orange temp Cyan humidity", { 0, "Blauw batterij Oranje temp Cyaan vocht","Blau Akku Orange Temp. Cyan Feuchte","Bleu batterie Orange temp. Cyan humidité","Azul batería Naranja temp. Cian humedad","Blu batteria Arancio temp. Ciano umidità","Синий — батарея Оранжевый — темп. Голубой — влажность","Синій — батарея Помаранчевий — темп. Блакитний — вологість","Синьо — батерия Оранжево — темп. Циан — влажност","Плаво — батерија Наранџасто — темп. Цијан — влажност","Μπλε μπαταρία Πορτοκαλί θερμ. Κυανό υγρασία","Azul bateria Laranja temp. Ciano umidade","Albastru baterie Portocaliu temp Cyan umiditate" } }, + { "Broker host / IP", { 0, "Broker-host / IP","Broker-Host / IP","Hôte / IP du broker","Host / IP del broker","Host / IP del broker","Хост / IP брокера","Хост / IP брокера","Хост / IP на брокера","Хост / IP брокера","Host / IP του broker","Host / IP do broker","Gazdă broker / IP" } }, + { "Buffered receive (experimental)", { 0, "Gebufferde ontvangst (experimenteel)","Gepufferter Empfang (experimentell)","Réception tamponnée (expérimental)","Recepción con búfer (experimental)","Ricezione bufferizzata (sperimentale)","Буферизованный приём (экспериментально)","Буферизований прийом (експериментально)","Буфериран прием (експериментално)","Баферовани пријем (експериментално)","Λήψη με buffer (πειραματικό)","Recepção com buffer (experimental)","Recepţie cu buffer (experimental)" } }, + { "Built-in", { 0, "Ingebouwd","Integriert","Intégré","Integrado","Integrato","Встроенный","Вбудований","Вграден","Уграђен","Ενσωματωμένο","Integrado","Încorporat" } }, + { "Built-in (default)", { 0, "Ingebouwd (standaard)","Integriert (Standard)","Intégré (par défaut)","Integrado (predeterminado)","Integrato (predefinito)","Встроенный (по умолчанию)","Вбудований (типовий)","Вграден (по подразбиране)","Уграђен (подразумевано)","Ενσωματωμένο (προεπιλογή)","Integrado (padrão)","Încorporat (implicit)" } }, + { "Cancelled", { 0, "Geannuleerd","Abgebrochen","Annulé","Cancelado","Annullato","Отменено","Скасовано","Отменено","Отказано","Ακυρώθηκε","Cancelado","Anulat" } }, + { "Channel-specific regions can be set in each channel's settings.", { 0, "Kanaalspecifieke regio's stel je in bij de instellingen van elk kanaal.","Kanalspezifische Regionen lassen sich in den Einstellungen des jeweiligen Kanals festlegen.","Les régions propres à un canal se règlent dans les paramètres de chaque canal.","Las regiones por canal se configuran en los ajustes de cada canal.","Le regioni per canale si impostano nelle impostazioni di ciascun canale.","Регионы для отдельных каналов задаются в настройках каждого канала.","Регіони для окремих каналів задаються в налаштуваннях кожного каналу.","Региони за отделни канали се задават в настройките на всеки канал.","Регије по каналу подешавају се у подешавањима сваког канала.","Οι περιοχές ανά κανάλι ορίζονται στις ρυθμίσεις κάθε καναλιού.","Regiões por canal são definidas nas configurações de cada canal.","Regiunile per canal se setează în setările fiecărui canal." } }, + { "Chat bubbles", { 0, "Chatbubbels","Sprechblasen","Bulles de discussion","Burbujas de chat","Fumetti chat","Пузыри сообщений","Бульбашки чату","Балончета в чата","Облачићи ћаскања","Φυσαλίδες συνομιλίας","Balões de chat","Baloane de chat" } }, + { "Chat icon reset to letters", { 0, "Chatpictogram teruggezet naar letters","Chat-Symbol auf Buchstaben zurückgesetzt","Icône du chat remise en lettres","Icono del chat restablecido a letras","Icona chat ripristinata alle lettere","Значок чата сброшен на буквы","Значок чату скинуто до літер","Иконата на чата е върната към букви","Икона ћаскања враћена на слова","Εικονίδιο συνομιλίας ξανά σε γράμματα","Ícone do chat redefinido para letras","Pictogramă chat resetată la litere" } }, + { "Chat icon set", { 0, "Chatpictogram ingesteld","Chat-Symbol gesetzt","Icône du chat définie","Icono del chat establecido","Icona chat impostata","Значок чата установлен","Значок чату встановлено","Иконата на чата е зададена","Икона ћаскања постављена","Ορίστηκε εικονίδιο συνομιλίας","Ícone do chat definido","Pictogramă chat setată" } }, + { "Clear battery history?", { 0, "Batterijgeschiedenis wissen?","Akkuverlauf löschen?","Effacer l'historique batterie ?","¿Borrar el historial de batería?","Cancellare la cronologia batteria?","Очистить историю батареи?","Очистити історію батареї?","Изчистване на историята на батерията?","Обрисати историју батерије?","Διαγραφή ιστορικού μπαταρίας;","Limpar histórico da bateria?","Goleşti istoricul bateriei?" } }, + { "Clear telemetry history for this node?", { 0, "Telemetriegeschiedenis van deze node wissen?","Telemetrieverlauf dieses Nodes löschen?","Effacer l'historique de télémétrie de ce nœud ?","¿Borrar el historial de telemetría de este nodo?","Cancellare la cronologia telemetria di questo nodo?","Очистить историю телеметрии этого узла?","Очистити історію телеметрії цього вузла?","Изчистване на телеметрията за този възел?","Обрисати историју телеметрије овог чвора?","Διαγραφή ιστορικού τηλεμετρίας του κόμβου;","Limpar histórico de telemetria deste nó?","Goleşti istoricul de telemetrie al acestui nod?" } }, + { "Clock: 12-hour", { 0, "Klok: 12-uurs","Uhr: 12-Stunden","Horloge : 12 h","Reloj: 12 horas","Orologio: 12 ore","Часы: 12-часовые","Годинник: 12-годинний","Часовник: 12-часов","Сат: 12-часовни","Ρολόι: 12ωρο","Relógio: 12 horas","Ceas: 12 ore" } }, + { "Clock: 24-hour", { 0, "Klok: 24-uurs","Uhr: 24-Stunden","Horloge : 24 h","Reloj: 24 horas","Orologio: 24 ore","Часы: 24-часовые","Годинник: 24-годинний","Часовник: 24-часов","Сат: 24-часовни","Ρολόι: 24ωρο","Relógio: 24 horas","Ceas: 24 ore" } }, + { "Compact messages", { 0, "Compacte berichten","Kompakte Nachrichten","Messages compacts","Mensajes compactos","Messaggi compatti","Компактные сообщения","Компактні повідомлення","Компактни съобщения","Компактне поруке","Συμπαγή μηνύματα","Mensagens compactas","Mesaje compacte" } }, + { "Compact messages (IRC style)", { 0, "Compacte berichten (IRC-stijl)","Kompakte Nachrichten (IRC-Stil)","Messages compacts (style IRC)","Mensajes compactos (estilo IRC)","Messaggi compatti (stile IRC)","Компактные сообщения (стиль IRC)","Компактні повідомлення (стиль IRC)","Компактни съобщения (IRC стил)","Компактне поруке (IRC стил)","Συμπαγή μηνύματα (στυλ IRC)","Mensagens compactas (estilo IRC)","Mesaje compacte (stil IRC)" } }, + { "Connect", { 0, "Verbinden","Verbinden","Connecter","Conectar","Connetti","Подключить","Підключити","Свързване","Повежи","Σύνδεση","Conectar","Conectează" } }, + { "Connect to Wi-Fi first, then try again.", { 0, "Verbind eerst met Wi-Fi en probeer het opnieuw.","Erst mit Wi-Fi verbinden, dann erneut versuchen.","Connectez-vous d'abord au Wi-Fi, puis réessayez.","Conéctate primero al Wi-Fi y vuelve a intentarlo.","Connettiti prima al Wi-Fi, poi riprova.","Сначала подключитесь к Wi-Fi, затем повторите.","Спочатку підключіться до Wi-Fi, потім повторіть.","Първо се свържете с Wi-Fi и опитайте отново.","Прво се повежите на Wi-Fi, па покушајте поново.","Συνδεθείτε πρώτα στο Wi-Fi και δοκιμάστε ξανά.","Conecte-se ao Wi-Fi primeiro e tente novamente.","Conectează-te mai întâi la Wi-Fi, apoi reîncearcă." } }, + { "Connected", { 0, "Verbonden","Verbunden","Connecté","Conectado","Connesso","Подключено","Підключено","Свързано","Повезано","Συνδεδεμένο","Conectado","Conectat" } }, + { "Connecting…", { 0, "Verbinden…","Verbinde…","Connexion…","Conectando…","Connessione…","Подключение…","Підключення…","Свързване…","Повезивање…","Σύνδεση…","Conectando…","Se conectează…" } }, + { "Console not available on this build", { 0, "Console niet beschikbaar in deze build","Konsole in diesem Build nicht verfügbar","Console indisponible sur cette version","Consola no disponible en esta versión","Console non disponibile in questa build","Консоль недоступна в этой сборке","Консоль недоступна в цій збірці","Конзолата не е налична в този билд","Конзола није доступна у овој верзији","Η κονσόλα δεν είναι διαθέσιμη σε αυτό το build","Console indisponível nesta versão","Consola nu e disponibilă în acest build" } }, + { "Copy failed (no internal identity)", { 0, "Kopiëren mislukt (geen interne identiteit)","Kopieren fehlgeschlagen (keine interne Identität)","Échec de la copie (aucune identité interne)","Error al copiar (sin identidad interna)","Copia non riuscita (nessuna identità interna)","Ошибка копирования (нет внутренней идентичности)","Помилка копіювання (немає внутрішньої ідентичності)","Копирането не успя (няма вътрешна идентичност)","Копирање није успело (нема интерног идентитета)","Αποτυχία αντιγραφής (χωρίς εσωτερική ταυτότητα)","Falha na cópia (sem identidade interna)","Copiere eşuată (fără identitate internă)" } }, + { "Copying internal data to SD...", { 0, "Interne data naar SD kopiëren...","Interne Daten werden auf SD kopiert...","Copie des données internes vers la SD...","Copiando datos internos a la SD...","Copia dei dati interni su SD...","Копирование внутренних данных на SD...","Копіювання внутрішніх даних на SD...","Копиране на вътрешните данни към SD...","Копирање интерних података на SD...","Αντιγραφή εσωτερικών δεδομένων στην SD...","Copiando dados internos para o SD...","Se copiază datele interne pe SD..." } }, + { "Could not save the crash report", { 0, "Kon het crashrapport niet opslaan","Absturzbericht konnte nicht gespeichert werden","Impossible d'enregistrer le rapport de plantage","No se pudo guardar el informe de fallo","Impossibile salvare il rapporto di crash","Не удалось сохранить отчёт о сбое","Не вдалося зберегти звіт про збій","Докладът за срив не се записа","Извештај о паду није сачуван","Η αναφορά σφάλματος δεν αποθηκεύτηκε","Não foi possível salvar o relatório de falha","Raportul de eroare nu a putut fi salvat" } }, + { "Couldn't fetch terrain data.\nCheck the connection and the\nelevation server, then\ntry again.", { 0, "Kon terreindata niet ophalen.\nControleer de verbinding en de\nhoogteserver en probeer\nhet opnieuw.","Geländedaten nicht abrufbar.\nVerbindung und Höhenserver\nprüfen, dann erneut\nversuchen.","Impossible de récupérer le terrain.\nVérifiez la connexion et le\nserveur d'altitude, puis\nréessayez.","No se pudieron obtener datos del terreno.\nComprueba la conexión y el\nservidor de elevación y\nvuelve a intentarlo.","Impossibile scaricare i dati del terreno.\nControlla la connessione e il\nserver di elevazione, poi\nriprova.","Не удалось получить данные рельефа.\nПроверьте соединение и\nсервер высот, затем\nповторите.","Не вдалося отримати дані рельєфу.\nПеревірте з'єднання та\nсервер висот, потім\nспробуйте ще раз.","Данните за терена не се заредиха.\nПроверете връзката и\nсървъра за височини и\nопитайте отново.","Подаци о терену нису преузети.\nПроверите везу и\nсервер за надморску висину, па\nпокушајте поново.","Αδύνατη η λήψη δεδομένων εδάφους.\nΕλέγξτε τη σύνδεση και τον\nδιακομιστή υψομέτρου και\nδοκιμάστε ξανά.","Não foi possível obter os dados do terreno.\nVerifique a conexão e o\nservidor de elevação e\ntente novamente.","Datele de teren nu au putut fi\nobţinute. Verifică conexiunea\nşi serverul de elevaţie, apoi\nreîncearcă." } }, + { "Couldn't save the name to storage", { 0, "Kon de naam niet opslaan","Name konnte nicht gespeichert werden","Impossible d'enregistrer le nom","No se pudo guardar el nombre","Impossibile salvare il nome","Не удалось сохранить имя","Не вдалося зберегти ім'я","Името не можа да се запише","Име није сачувано","Το όνομα δεν αποθηκεύτηκε","Não foi possível salvar o nome","Numele nu a putut fi salvat în stocare" } }, + { "Couldn't send login", { 0, "Kon login niet versturen","Login konnte nicht gesendet werden","Impossible d'envoyer le login","No se pudo enviar el login","Impossibile inviare il login","Не удалось отправить запрос входа","Не вдалося надіслати запит входу","Заявката за вход не се изпрати","Пријава није послата","Αποτυχία αποστολής σύνδεσης","Não foi possível enviar o login","Autentificarea nu a putut fi trimisă" } }, + { "Crash export failed", { 0, "Crash-export mislukt","Crash-Export fehlgeschlagen","Échec de l'export du crash","Error al exportar el informe de fallo","Esportazione crash non riuscita","Не удалось экспортировать отчёт о сбое","Не вдалося експортувати звіт про збій","Експортът на доклада за срив не успя","Извоз извештаја о паду није успео","Αποτυχία εξαγωγής αναφοράς σφάλματος","Falha ao exportar o relatório","Exportul raportului de eroare a eşuat" } }, + { "Custom UTC offset (hours)", { 0, "Aangepaste UTC-offset (uren)","Eigener UTC-Versatz (Stunden)","Décalage UTC personnalisé (heures)","Desfase UTC personalizado (horas)","Offset UTC personalizzato (ore)","Свой сдвиг UTC (часы)","Власний зсув UTC (години)","Собствено UTC отместване (часове)","Прилагођени UTC померај (сати)","Προσαρμοσμένη μετατόπιση UTC (ώρες)","Deslocamento UTC personalizado (horas)","Decalaj UTC personalizat (ore)" } }, + { "DM sound", { 0, "DM-geluid","DM-Ton","Son des DM","Sonido de DM","Suono DM","Звук DM","Звук DM","Звук за DM","Звук за DM","Ήχος DM","Som de DM","Sunet DM" } }, + { "Delete %d contact(s)?", { 0, "%d contact(en) verwijderen?","%d Kontakt(e) löschen?","Supprimer %d contact(s) ?","¿Eliminar %d contacto(s)?","Eliminare %d contatti?","Удалить %d контакт(ов)?","Видалити %d контакт(ів)?","Изтриване на %d контакт(а)?","Обрисати %d контакт(а)?","Διαγραφή %d επαφών;","Excluir %d contato(s)?","Ştergi %d contact(e)?" } }, + { "Delete this chat's entire history?\nThe chat itself stays.", { 0, "Volledige geschiedenis van deze chat verwijderen?\nDe chat zelf blijft staan.","Gesamten Verlauf dieses Chats löschen?\nDer Chat selbst bleibt.","Supprimer tout l'historique de cette discussion ?\nLa discussion est conservée.","¿Borrar todo el historial de este chat?\nEl chat en sí se conserva.","Eliminare l'intera cronologia di questa chat?\nLa chat resta.","Удалить всю историю этого чата?\nСам чат останется.","Видалити всю історію цього чату?\nСам чат залишиться.","Изтриване на цялата история на този чат?\nСамият чат остава.","Обрисати целу историју овог ћаскања?\nСамо ћаскање остаје.","Διαγραφή όλου του ιστορικού αυτής της συνομιλίας;\nΗ συνομιλία παραμένει.","Apagar todo o histórico deste chat?\nO chat em si permanece.","Ştergi tot istoricul acestui chat?\nChatul în sine rămâne." } }, + { "Deleted %d", { 0, "%d verwijderd","%d gelöscht","%d supprimé(s)","%d eliminado(s)","%d eliminati","Удалено: %d","Видалено: %d","Изтрити: %d","Обрисано: %d","Διαγράφηκαν %d","%d excluído(s)","Şterse %d" } }, + { "Deleted %d messages", { 0, "%d berichten verwijderd","%d Nachrichten gelöscht","%d messages supprimés","%d mensajes eliminados","%d messaggi eliminati","Удалено сообщений: %d","Видалено повідомлень: %d","Изтрити %d съобщения","Обрисано %d порука","Διαγράφηκαν %d μηνύματα","%d mensagens excluídas","%d mesaje şterse" } }, + { "Deleting contacts", { 0, "Contacten verwijderen","Kontakte werden gelöscht","Suppression des contacts","Eliminando contactos","Eliminazione contatti","Удаление контактов","Видалення контактів","Изтриване на контакти","Брисање контаката","Διαγραφή επαφών","Excluindo contatos","Se şterg contactele" } }, + { "Detailed sensors", { 0, "Gedetailleerde sensoren","Detaillierte Sensoren","Capteurs détaillés","Sensores detallados","Sensori dettagliati","Подробные датчики","Докладні датчики","Подробни сензори","Детаљни сензори","Αναλυτικοί αισθητήρες","Sensores detalhados","Senzori detaliaţi" } }, + { "Device clock differs from \"%s\" by %lu h%s", { 0, "Apparaatklok verschilt van \"%s\" met %lu u%s","Geräteuhr weicht von \"%s\" um %lu h ab%s","L'horloge diffère de \"%s\" de %lu h%s","El reloj difiere de \"%s\" en %lu h%s","L'orologio differisce da \"%s\" di %lu h%s","Часы устройства отличаются от \"%s\" на %lu ч%s","Годинник пристрою відрізняється від \"%s\" на %lu год%s","Часовникът се различава от \"%s\" с %lu ч%s","Сат уређаја се разликује од \"%s\" за %lu ч%s","Το ρολόι διαφέρει από \"%s\" κατά %lu ώ.%s","O relógio difere de \"%s\" em %lu h%s","Ceasul dispozitivului diferă de \"%s\" cu %lu h%s" } }, + { "Device clock differs from \"%s\" by %lu min%s", { 0, "Apparaatklok verschilt van \"%s\" met %lu min%s","Geräteuhr weicht von \"%s\" um %lu min ab%s","L'horloge diffère de \"%s\" de %lu min%s","El reloj difiere de \"%s\" en %lu min%s","L'orologio differisce da \"%s\" di %lu min%s","Часы устройства отличаются от \"%s\" на %lu мин%s","Годинник пристрою відрізняється від \"%s\" на %lu хв%s","Часовникът се различава от \"%s\" с %lu мин%s","Сат уређаја се разликује од \"%s\" за %lu мин%s","Το ρολόι διαφέρει από \"%s\" κατά %lu λεπ.%s","O relógio difere de \"%s\" em %lu min%s","Ceasul dispozitivului diferă de \"%s\" cu %lu min%s" } }, + { "Device name hidden", { 0, "Apparaatnaam verborgen","Gerätename ausgeblendet","Nom de l'appareil masqué","Nombre del dispositivo oculto","Nome dispositivo nascosto","Имя устройства скрыто","Ім'я пристрою приховано","Името на устройството е скрито","Име уређаја сакривено","Όνομα συσκευής κρυφό","Nome do dispositivo oculto","Nume dispozitiv ascuns" } }, + { "Device name shown", { 0, "Apparaatnaam zichtbaar","Gerätename sichtbar","Nom de l'appareil affiché","Nombre del dispositivo visible","Nome dispositivo visibile","Имя устройства показано","Ім'я пристрою показано","Името на устройството се показва","Име уређаја приказано","Όνομα συσκευής ορατό","Nome do dispositivo visível","Nume dispozitiv afişat" } }, + { "Discovered settings", { 0, "Instellingen ontdekte nodes","Einstellungen entdeckte Nodes","Réglages des nœuds découverts","Ajustes de nodos descubiertos","Impostazioni nodi scoperti","Настройки обнаруженных","Налаштування виявлених","Настройки на откритите","Подешавања откривених","Ρυθμίσεις εντοπισμένων","Configurações de descobertos","Setări noduri descoperite" } }, + { "Download mode… reflash over USB", { 0, "Downloadmodus… opnieuw flashen via USB","Download-Modus… per USB neu flashen","Mode download… reflasher via USB","Modo descarga… reflashear por USB","Modalità download… riflashare via USB","Режим загрузки… перепрошейте по USB","Режим завантаження… перепрошийте через USB","Режим download… флашнете отново през USB","Download режим… поново флешујте преко USB","Λειτουργία download… κάντε ξανά flash μέσω USB","Modo download… regrave via USB","Mod descărcare… rescrie prin USB" } }, + { "Downloading map tiles…\n\nKeep Wi-Fi connected.\nTiles appear as they arrive\nand are saved for offline use.", { 0, "Kaarttegels downloaden…\n\nHoud Wi-Fi verbonden.\nTegels verschijnen zodra ze binnenkomen\nen worden opgeslagen voor offline gebruik.","Kartenkacheln werden geladen…\n\nWi-Fi verbunden halten.\nKacheln erscheinen beim Eintreffen\nund werden für offline gespeichert.","Téléchargement des tuiles…\n\nGardez le Wi-Fi connecté.\nLes tuiles apparaissent à leur arrivée\net sont enregistrées hors ligne.","Descargando teselas del mapa…\n\nMantén el Wi-Fi conectado.\nLas teselas aparecen al llegar\ny se guardan para uso sin conexión.","Scaricamento tasselli mappa…\n\nMantieni il Wi-Fi connesso.\nI tasselli appaiono all'arrivo\ne vengono salvati per l'uso offline.","Загрузка тайлов карты…\n\nНе отключайте Wi-Fi.\nТайлы появляются по мере загрузки\nи сохраняются для офлайн-режима.","Завантаження тайлів карти…\n\nНе вимикайте Wi-Fi.\nТайли з'являються під час завантаження\nі зберігаються для офлайн-режиму.","Изтегляне на плочки на картата…\n\nЗапазете Wi-Fi връзката.\nПлочките се появяват при пристигане\nи се запазват за офлайн употреба.","Преузимање плочица мапе…\n\nОдржавајте Wi-Fi везу.\nПлочице се појављују како стижу\nи чувају се за офлајн употребу.","Λήψη πλακιδίων χάρτη…\n\nΚρατήστε το Wi-Fi συνδεδεμένο.\nΤα πλακίδια εμφανίζονται καθώς φτάνουν\nκαι αποθηκεύονται για χρήση εκτός σύνδεσης.","Baixando blocos do mapa…\n\nMantenha o Wi-Fi conectado.\nOs blocos aparecem ao chegar\ne são salvos para uso offline.","Se descarcă dalele hărţii…\n\nPăstrează Wi-Fi conectat.\nDalele apar pe măsură ce sosesc\nşi se salvează pentru offline." } }, + { "Edge scroll", { 0, "Randscrollen","Randscrollen","Défilement bord","Desplaz. borde","Scorri bordo","Прокрутка у края","Прокрутка з краю","Скрол в края","Скрол уз ивицу","Κύλιση στην άκρη","Rolagem na borda","Derulare la margine" } }, + { "Empty = plaintext to a private broker", { 0, "Leeg = platte tekst naar een privé-broker","Leer = Klartext an privaten Broker","Vide = texte clair vers un broker privé","Vacío = texto plano a un broker privado","Vuoto = testo in chiaro a un broker privato","Пусто = открытый текст на приватный брокер","Порожньо = відкритий текст на приватний брокер","Празно = чист текст към частен брокер","Празно = чист текст ка приватном брокеру","Κενό = απλό κείμενο σε ιδιωτικό broker","Vazio = texto puro para um broker privado","Gol = text simplu către un broker privat" } }, + { "Enable MQTT bridge", { 0, "MQTT-brug inschakelen","MQTT-Brücke aktivieren","Activer le pont MQTT","Activar puente MQTT","Attiva ponte MQTT","Включить мост MQTT","Увімкнути міст MQTT","Активиране на MQTT мост","Омогући MQTT мост","Ενεργοποίηση γέφυρας MQTT","Ativar ponte MQTT","Activează puntea MQTT" } }, + { "Enable only if your repeater re-floods just one region. Otherwise leave off — it can block cross-region login/DMs.", { 0, "Alleen inschakelen als je repeater slechts één regio herverspreidt. Laat anders uit — het kan login/DM's tussen regio's blokkeren.","Nur aktivieren, wenn dein Repeater nur eine Region neu flutet. Sonst aus lassen — es kann regionsübergreifende Logins/DMs blockieren.","Activez seulement si votre répéteur ne réinonde qu'une région. Sinon laissez désactivé — cela peut bloquer les connexions/DM inter-régions.","Actívalo solo si tu repetidor reinunda una sola región. Si no, déjalo apagado — puede bloquear login/MD entre regiones.","Attiva solo se il tuo ripetitore rifonde una sola regione. Altrimenti lascia off — può bloccare login/MD tra regioni.","Включайте, только если ваш ретранслятор перезаливает лишь один регион. Иначе оставьте выкл. — это может блокировать межрегиональный вход/ЛС.","Вмикайте лише якщо ваш ретранслятор перезаливає тільки один регіон. Інакше залиште вимк. — це може блокувати міжрегіональний вхід/ПП.","Активирайте само ако вашият ретранслатор презалива само един регион. Иначе оставете изкл. — може да блокира вход/ЛС между региони.","Омогућите само ако ваш репетитор поново поплављује само један регион. Иначе оставите искључено — може блокирати пријаву/ПП међу регионима.","Ενεργοποιήστε μόνο αν ο αναμεταδότης σας ξαναπλημμυρίζει μία μόνο περιοχή. Αλλιώς αφήστε το κλειστό — μπορεί να μπλοκάρει σύνδεση/ΠΜ μεταξύ περιοχών.","Ative apenas se o seu repetidor reinundar só uma região. Caso contrário, deixe desligado — pode bloquear login/DMs entre regiões.","Activează doar dacă repetorul tău redifuzează o singură regiune. Altfel lasă oprit — poate bloca autentificarea/DM-urile între regiuni." } }, + { "Encryption key (optional)", { 0, "Encryptiesleutel (optioneel)","Verschlüsselungsschlüssel (optional)","Clé de chiffrement (facultatif)","Clave de cifrado (opcional)","Chiave di cifratura (opzionale)","Ключ шифрования (необязательно)","Ключ шифрування (необов'язково)","Ключ за криптиране (по избор)","Кључ за шифровање (опционо)","Κλειδί κρυπτογράφησης (προαιρετικό)","Chave de criptografia (opcional)","Cheie de criptare (opţională)" } }, + { "Enter a network name", { 0, "Voer een netwerknaam in","Netzwerknamen eingeben","Saisir un nom de réseau","Introduce un nombre de red","Inserisci un nome di rete","Введите имя сети","Введіть назву мережі","Въведете име на мрежа","Унесите име мреже","Εισαγάγετε όνομα δικτύου","Digite um nome de rede","Introdu un nume de reţea" } }, + { "Enter adds a new line", { 0, "Enter voegt een nieuwe regel toe","Enter fügt neue Zeile ein","Entrée ajoute une ligne","Enter añade una línea","Invio aggiunge una riga","Enter добавляет строку","Enter додає новий рядок","Enter добавя нов ред","Enter додаје нови ред","Το Enter προσθέτει γραμμή","Enter adiciona uma linha","Enter adaugă un rând nou" } }, + { "Enter key sends message", { 0, "Enter verstuurt bericht","Enter-Taste sendet Nachricht","La touche Entrée envoie le message","La tecla Enter envía el mensaje","Il tasto Invio invia il messaggio","Клавиша Enter отправляет сообщение","Клавіша Enter надсилає повідомлення","Клавишът Enter изпраща съобщението","Тастер Enter шаље поруку","Το πλήκτρο Enter στέλνει το μήνυμα","A tecla Enter envia a mensagem","Tasta Enter trimite mesajul" } }, + { "Enter sends messages", { 0, "Enter verstuurt berichten","Enter sendet Nachrichten","Entrée envoie les messages","Enter envía los mensajes","Invio invia i messaggi","Enter отправляет сообщения","Enter надсилає повідомлення","Enter изпраща съобщения","Enter шаље поруке","Το Enter στέλνει μηνύματα","Enter envia as mensagens","Enter trimite mesajele" } }, + { "Expansion Kit", { 0, "Uitbreidingskit","Erweiterungskit","Kit d'extension","Kit de expansión","Kit di espansione","Комплект расширения","Комплект розширення","Комплект за разширение","Комплет за проширење","Κιτ επέκτασης","Kit de expansão","Kit de extensie" } }, + { "Experimental - has known issues. Off = soft mouse cursor.", { 0, "Experimenteel - heeft bekende problemen. Uit = zachte muiscursor.","Experimentell – hat bekannte Probleme. Aus = weicher Mauszeiger.","Expérimental - problèmes connus. Désactivé = curseur logiciel.","Experimental - tiene problemas conocidos. Off = cursor de ratón suave.","Sperimentale - ha problemi noti. Off = cursore mouse software.","Экспериментально — есть известные проблемы. Выкл. = программный курсор.","Експериментально — є відомі проблеми. Вимк. = програмний курсор.","Експериментално - има известни проблеми. Изкл. = софтуерен курсор.","Експериментално - има познате проблеме. Искључено = софтверски курсор.","Πειραματικό - έχει γνωστά προβλήματα. Κλειστό = δρομέας λογισμικού.","Experimental - tem problemas conhecidos. Desligado = cursor de mouse suave.","Experimental - are probleme cunoscute. Oprit = cursor de mouse virtual." } }, + { "Favorites full (max %d)", { 0, "Favorieten vol (max %d)","Favoriten voll (max %d)","Favoris pleins (max %d)","Favoritos llenos (máx %d)","Preferiti pieni (max %d)","Избранное заполнено (макс %d)","Обране заповнене (макс %d)","Любимите са пълни (макс %d)","Омиљени пуни (макс %d)","Αγαπημένα γεμάτα (μέγ %d)","Favoritos cheios (máx %d)","Favorite pline (max %d)" } }, + { "Firmware:", { 0, "Firmware:","Firmware:","Firmware :","Firmware:","Firmware:","Прошивка:","Прошивка:","Фърмуер:","Фирмвер:","Firmware:","Firmware:","Firmware:" } }, + { "Flash on new message", { 0, "Knipperen bij nieuw bericht","Blinken bei neuer Nachricht","Clignoter à nouveau message","Parpadear con mensaje nuevo","Lampeggia a nuovo messaggio","Мигать при новом сообщении","Блимати при новому повідомленні","Мигане при ново съобщение","Блицај при новој поруци","Αναβόσβημα σε νέο μήνυμα","Piscar em nova mensagem","Iluminează la mesaj nou" } }, + { "Flood every", { 0, "Flood elke","Flood alle","Flood toutes les","Flood cada","Flood ogni","Flood каждые","Flood кожні","Flood на всеки","Flood сваких","Flood κάθε","Flood a cada","Flood la fiecare" } }, + { "Flood: relayed across the mesh (multi-hop).\nZero-hop: neighbours only (no relaying).", { 0, "Flood: doorgestuurd over het mesh (multi-hop).\nZero-hop: alleen buren (geen doorsturen).","Flood: über das Mesh weitergeleitet (Multi-Hop).\nZero-Hop: nur Nachbarn (keine Weiterleitung).","Flood : relayé sur le mesh (multi-saut).\nZero-hop : voisins seulement (pas de relais).","Flood: retransmitido por la malla (multisalto).\nZero-hop: solo vecinos (sin retransmisión).","Flood: inoltrato sulla mesh (multi-hop).\nZero-hop: solo vicini (nessun inoltro).","Flood: ретранслируется по сети (мульти-хоп).\nZero-hop: только соседи (без ретрансляции).","Flood: ретранслюється по мережі (мульти-хоп).\nZero-hop: лише сусіди (без ретрансляції).","Flood: препредаване през мрежата (мулти-хоп).\nZero-hop: само съседи (без препредаване).","Flood: прослеђено кроз мрежу (мулти-хоп).\nZero-hop: само суседи (без прослеђивања).","Flood: αναμετάδοση στο mesh (πολλαπλά άλματα).\nZero-hop: μόνο γείτονες (χωρίς αναμετάδοση).","Flood: retransmitido pela malha (multissalto).\nZero-hop: só vizinhos (sem retransmissão).","Flood: retransmis prin mesh (multi-salt).\nZero-hop: doar vecinii (fără retransmitere)." } }, + { "For Launcher installs: saves the latest firmware of the selected channel to the SD card (BINS folder).", { 0, "Voor Launcher-installaties: slaat de nieuwste firmware van het geselecteerde kanaal op de SD-kaart op (BINS-map).","Für Launcher-Installationen: speichert die neueste Firmware des gewählten Kanals auf der SD-Karte (BINS-Ordner).","Pour les installations Launcher : enregistre le dernier firmware du canal choisi sur la carte SD (dossier BINS).","Para instalaciones con Launcher: guarda el firmware más reciente del canal elegido en la tarjeta SD (carpeta BINS).","Per installazioni Launcher: salva l'ultimo firmware del canale scelto sulla scheda SD (cartella BINS).","Для установок через Launcher: сохраняет последнюю прошивку выбранного канала на SD-карту (папка BINS).","Для встановлень через Launcher: зберігає останню прошивку вибраного каналу на SD-карту (папка BINS).","За инсталации с Launcher: запазва най-новия фърмуер на избрания канал на SD картата (папка BINS).","За Launcher инсталације: чува најновији фирмвер изабраног канала на SD картицу (BINS фолдер).","Για εγκαταστάσεις Launcher: αποθηκεύει το πιο πρόσφατο firmware του επιλεγμένου καναλιού στην κάρτα SD (φάκελος BINS).","Para instalações via Launcher: salva o firmware mais recente do canal selecionado no cartão SD (pasta BINS).","Pentru instalări prin Launcher: salvează cel mai recent firmware al canalului selectat pe cardul SD (folderul BINS)." } }, + { "Forget network", { 0, "Netwerk vergeten","Netzwerk vergessen","Oublier le réseau","Olvidar la red","Dimentica rete","Забыть сеть","Забути мережу","Забрави мрежата","Заборави мрежу","Διαγραφή δικτύου","Esquecer rede","Uită reţeaua" } }, + { "Format SD as MESHCOMOD (FAT32)?\nAll data on the card will be erased.", { 0, "SD formatteren als MESHCOMOD (FAT32)?\nAlle gegevens op de kaart worden gewist.","SD als MESHCOMOD (FAT32) formatieren?\nAlle Daten auf der Karte werden gelöscht.","Formater la SD en MESHCOMOD (FAT32) ?\nToutes les données de la carte seront effacées.","¿Formatear SD como MESHCOMOD (FAT32)?\nSe borrarán todos los datos de la tarjeta.","Formattare SD come MESHCOMOD (FAT32)?\nTutti i dati sulla scheda verranno cancellati.","Форматировать SD как MESHCOMOD (FAT32)?\nВсе данные на карте будут стёрты.","Форматувати SD як MESHCOMOD (FAT32)?\nУсі дані на карті буде стерто.","Форматиране на SD като MESHCOMOD (FAT32)?\nВсички данни на картата ще бъдат изтрити.","Форматирати SD као MESHCOMOD (FAT32)?\nСви подаци на картици биће обрисани.","Διαμόρφωση SD ως MESHCOMOD (FAT32);\nΌλα τα δεδομένα στην κάρτα θα διαγραφούν.","Formatar SD como MESHCOMOD (FAT32)?\nTodos os dados do cartão serão apagados.","Formatezi SD ca MESHCOMOD (FAT32)?\nToate datele de pe card vor fi şterse." } }, + { "GPS %s · Buzzer %s", { 0, "GPS %s · Zoemer %s","GPS %s · Summer %s","GPS %s · Buzzer %s","GPS %s · Zumbador %s","GPS %s · Cicalino %s","GPS %s · Зуммер %s","GPS %s · Зумер %s","GPS %s · Зумер %s","GPS %s · Зујалица %s","GPS %s · Βομβητής %s","GPS %s · Campainha %s","GPS %s · Buzzer %s" } }, + { "GPS %s · Buzzer %s\n%s", { 0, "GPS %s · Zoemer %s\n%s","GPS %s · Summer %s\n%s","GPS %s · Buzzer %s\n%s","GPS %s · Zumbador %s\n%s","GPS %s · Cicalino %s\n%s","GPS %s · Зуммер %s\n%s","GPS %s · Зумер %s\n%s","GPS %s · Зумер %s\n%s","GPS %s · Зујалица %s\n%s","GPS %s · Βομβητής %s\n%s","GPS %s · Campainha %s\n%s","GPS %s · Buzzer %s\n%s" } }, + { "Game over — score %d (tap to restart)", { 0, "Game over — score %d (tik om te herstarten)","Game over — Punkte %d (tippen für Neustart)","Partie terminée — score %d (touchez pour rejouer)","Fin del juego — puntos %d (toca para reiniciar)","Game over — punteggio %d (tocca per ricominciare)","Игра окончена — счёт %d (нажмите для рестарта)","Гру завершено — рахунок %d (торкніться для рестарту)","Край на играта — точки %d (докоснете за рестарт)","Крај игре — резултат %d (додирните за рестарт)","Τέλος παιχνιδιού — σκορ %d (πατήστε για επανεκκίνηση)","Fim de jogo — pontos %d (toque para reiniciar)","Joc terminat — scor %d (atinge pentru restart)" } }, + { "Get test builds (beta)", { 0, "Testversies ontvangen (bèta)","Test-Builds erhalten (Beta)","Recevoir les versions test (bêta)","Recibir versiones de prueba (beta)","Ricevi build di test (beta)","Получать тестовые сборки (бета)","Отримувати тестові збірки (бета)","Получаване на тестови версии (бета)","Преузми тест верзије (бета)","Λήψη δοκιμαστικών εκδόσεων (beta)","Receber versões de teste (beta)","Primeşte versiuni de test (beta)" } }, + { "Green pressure Gray altitude", { 0, "Groen druk Grijs hoogte","Grün Druck Grau Höhe","Vert pression Gris altitude","Verde presión Gris altitud","Verde pressione Grigio altitudine","Зелёный давление Серый высота","Зелений тиск Сірий висота","Зелено налягане Сиво надм. височина","Зелено притисак Сиво висина","Πράσινο πίεση Γκρι υψόμετρο","Verde pressão Cinza altitude","Verde presiune Gri altitudine" } }, + { "Hidden network", { 0, "Verborgen netwerk","Verstecktes Netzwerk","Réseau masqué","Red oculta","Rete nascosta","Скрытая сеть","Прихована мережа","Скрита мрежа","Скривена мрежа","Κρυφό δίκτυο","Rede oculta","Reţea ascunsă" } }, + { "Hide device name", { 0, "Apparaatnaam verbergen","Gerätenamen verbergen","Masquer le nom de l'appareil","Ocultar nombre del dispositivo","Nascondi nome dispositivo","Скрыть имя устройства","Приховати ім'я пристрою","Скриване на името на устройството","Сакриј име уређаја","Απόκρυψη ονόματος συσκευής","Ocultar nome do dispositivo","Ascunde numele dispozitivului" } }, + { "High-gain receiver (FEM LNA)", { 0, "Hoge-versterking ontvanger (FEM LNA)","Hochverstärkungs-Empfänger (FEM LNA)","Récepteur haut gain (FEM LNA)","Receptor de alta ganancia (FEM LNA)","Ricevitore ad alto guadagno (FEM LNA)","Приёмник с высоким усилением (FEM LNA)","Приймач з високим підсиленням (FEM LNA)","Приемник с високо усилване (FEM LNA)","Пријемник високог појачања (FEM LNA)","Δέκτης υψηλής ενίσχυσης (FEM LNA)","Receptor de alto ganho (FEM LNA)","Receptor cu amplificare mare (FEM LNA)" } }, + { "Highly experimental. This forwards the text, sender name and timestamp of every message your node receives to an MQTT broker, where anyone able to read the broker can read them. Direct messages are private messages from other people who never agreed to be shared. Use a broker you control, set an encryption key below, and never a public broker.", { 0, "Zeer experimenteel. Dit stuurt de tekst, afzendernaam en tijdstempel van elk bericht dat je node ontvangt door naar een MQTT-broker, waar iedereen die de broker kan lezen ze kan lezen. Directe berichten zijn privéberichten van anderen die nooit met delen hebben ingestemd. Gebruik een broker die je zelf beheert, stel hieronder een encryptiesleutel in, en nooit een openbare broker.","Hochgradig experimentell. Dies leitet Text, Absendername und Zeitstempel jeder Nachricht, die dein Knoten empfängt, an einen MQTT-Broker weiter, wo jeder, der den Broker lesen kann, sie lesen kann. Direktnachrichten sind private Nachrichten anderer, die einer Weitergabe nie zugestimmt haben. Nutze einen Broker, den du selbst kontrollierst, setze unten einen Verschlüsselungsschlüssel und niemals einen öffentlichen Broker.","Très expérimental. Ceci transmet le texte, le nom de l'expéditeur et l'horodatage de chaque message reçu par votre nœud à un broker MQTT, où quiconque peut lire le broker peut les lire. Les messages directs sont privés, envoyés par des personnes qui n'ont jamais accepté d'être partagées. Utilisez un broker que vous contrôlez, définissez une clé de chiffrement ci-dessous, et jamais un broker public.","Muy experimental. Reenvía el texto, el nombre del remitente y la marca de tiempo de cada mensaje que recibe tu nodo a un broker MQTT, donde cualquiera que pueda leer el broker podrá leerlos. Los mensajes directos son privados, de otras personas que nunca aceptaron ser compartidas. Usa un broker que controles, define una clave de cifrado abajo, y nunca un broker público.","Molto sperimentale. Inoltra il testo, il nome del mittente e l'orario di ogni messaggio che il tuo nodo riceve a un broker MQTT, dove chiunque possa leggere il broker può leggerli. I messaggi diretti sono privati, da altre persone che non hanno mai accettato di essere condivise. Usa un broker che controlli, imposta una chiave di cifratura sotto, e mai un broker pubblico.","Крайне экспериментально. Пересылает текст, имя отправителя и метку времени каждого сообщения, которое принимает ваш узел, на брокер MQTT, где их сможет прочитать любой, кто имеет доступ к брокеру. Личные сообщения приватны — от людей, не соглашавшихся на их передачу. Используйте свой брокер, задайте ключ шифрования ниже и никогда не публичный брокер.","Вкрай експериментально. Пересилає текст, ім'я відправника й позначку часу кожного повідомлення, яке отримує ваш вузол, на брокер MQTT, де їх зможе прочитати будь-хто з доступом до брокера. Особисті повідомлення приватні — від людей, що не погоджувалися на їх передачу. Використовуйте власний брокер, задайте ключ шифрування нижче і ніколи не публічний брокер.","Изключително експериментално. Препраща текста, името на подателя и времевия печат на всяко съобщение, което възелът ви получава, към MQTT брокер, където всеки с достъп до брокера може да ги чете. Директните съобщения са лични, от хора, които никога не са се съгласили да бъдат споделяни. Използвайте брокер, който контролирате, задайте ключ за криптиране по-долу и никога публичен брокер.","Изузетно експериментално. Ово прослеђује текст, име пошиљаоца и временску ознаку сваке поруке коју ваш чвор прими на MQTT брокер, где их може прочитати свако ко има приступ брокеру. Директне поруке су приватне, од других људи који никада нису пристали да буду дељени. Користите брокер који контролишете, поставите кључ за шифровање испод, и никада јавни брокер.","Άκρως πειραματικό. Προωθεί το κείμενο, το όνομα αποστολέα και τη χρονική σήμανση κάθε μηνύματος που λαμβάνει ο κόμβος σας σε έναν broker MQTT, όπου οποιοσδήποτε μπορεί να διαβάσει τον broker μπορεί να τα διαβάσει. Τα άμεσα μηνύματα είναι ιδιωτικά, από άλλους που ποτέ δεν συμφώνησαν να κοινοποιηθούν. Χρησιμοποιήστε broker που ελέγχετε, ορίστε κλειδί κρυπτογράφησης παρακάτω, και ποτέ δημόσιο broker.","Altamente experimental. Isto encaminha o texto, o nome do remetente e a marca de tempo de cada mensagem que o seu nó recebe para um broker MQTT, onde qualquer um que possa ler o broker pode lê-las. Mensagens diretas são privadas, de outras pessoas que nunca concordaram em ser compartilhadas. Use um broker que você controle, defina uma chave de criptografia abaixo, e nunca um broker público.","Extrem de experimental. Trimite textul, numele expeditorului şi ora fiecărui mesaj primit de nodul tău către un broker MQTT, unde oricine poate citi brokerul le poate citi şi el. Mesajele directe sunt mesaje private de la persoane care nu au acceptat să fie partajate. Foloseşte un broker pe care îl controlezi, setează o cheie de criptare mai jos şi nu folosi niciodată un broker public." } }, + { "Hotkey saved", { 0, "Sneltoets opgeslagen","Hotkey gespeichert","Raccourci enregistré","Atajo guardado","Scorciatoia salvata","Горячая клавиша сохранена","Гарячу клавішу збережено","Клавишът е запазен","Пречица сачувана","Το πλήκτρο αποθηκεύτηκε","Atalho salvo","Tastă rapidă salvată" } }, + { "Humidity", { 0, "Vochtigheid","Luftfeuchtigkeit","Humidité","Humedad","Umidità","Влажность","Вологість","Влажност","Влажност","Υγρασία","Umidade","Umiditate" } }, + { "I accept the privacy risk", { 0, "Ik accepteer het privacyrisico","Ich akzeptiere das Datenschutzrisiko","J'accepte le risque pour la vie privée","Acepto el riesgo de privacidad","Accetto il rischio per la privacy","Я принимаю риск для конфиденциальности","Я приймаю ризик для приватності","Приемам риска за поверителността","Прихватам ризик по приватност","Αποδέχομαι τον κίνδυνο απορρήτου","Aceito o risco de privacidade","Accept riscul de confidenţialitate" } }, + { "Idle power-save", { 0, "Energiebesparing bij inactiviteit","Energiesparen bei Inaktivität","Économie d'énergie au repos","Ahorro de energía en reposo","Risparmio energetico in inattività","Энергосбережение при простое","Енергозбереження при простої","Пестене на енергия в покой","Уштеда енергије у мировању","Εξοικονόμηση σε αδράνεια","Economia de energia ocioso","Economie de energie în repaus" } }, + { "Idle sleep disabled", { 0, "Slaap bij inactiviteit uit","Ruheschlaf deaktiviert","Veille au repos désactivée","Suspensión en reposo desactivada","Sospensione in inattività disatt.","Сон при простое отключён","Сон при простої вимкнено","Заспиване в покой изключено","Спавање у мировању искључено","Αναστολή σε αδράνεια απενεργ.","Suspensão ociosa desativada","Somn în repaus dezactivat" } }, + { "Idle sleep enabled", { 0, "Slaap bij inactiviteit aan","Ruheschlaf aktiviert","Veille au repos activée","Suspensión en reposo activada","Sospensione in inattività attiva","Сон при простое включён","Сон при простої увімкнено","Заспиване в покой включено","Спавање у мировању укључено","Αναστολή σε αδράνεια ενεργή","Suspensão ociosa ativada","Somn în repaus activat" } }, + { "Import backup", { 0, "Back-up importeren","Backup importieren","Importer la sauvegarde","Importar copia de seguridad","Importa backup","Импорт резервной копии","Імпорт резервної копії","Импортиране на резервно копие","Увези резервну копију","Εισαγωγή αντιγράφου","Importar backup","Importă backup" } }, + { "Install a previous version", { 0, "Een vorige versie installeren","Vorherige Version installieren","Installer une version précédente","Instalar una versión anterior","Installa una versione precedente","Установить предыдущую версию","Встановити попередню версію","Инсталиране на предишна версия","Инсталирај претходну верзију","Εγκατάσταση προηγούμενης έκδοσης","Instalar uma versão anterior","Instalează o versiune anterioară" } }, + { "Install an earlier version", { 0, "Een eerdere versie installeren","Frühere Version installieren","Installer une version antérieure","Instalar una versión anterior","Installa una versione precedente","Установить более раннюю версию","Встановити ранішу версію","Инсталиране на по-ранна версия","Инсталирај ранију верзију","Εγκατάσταση παλαιότερης έκδοσης","Instalar uma versão anterior","Instalează o versiune mai veche" } }, + { "Join network", { 0, "Verbinden met netwerk","Netzwerk beitreten","Rejoindre le réseau","Unirse a la red","Unisciti alla rete","Подключиться к сети","Приєднатися до мережі","Присъединяване към мрежа","Придружи се мрежи","Σύνδεση σε δίκτυο","Entrar na rede","Intră în reţea" } }, + { "Key already in use", { 0, "Sleutel al in gebruik","Schlüssel bereits vergeben","Clé déjà utilisée","Clave ya en uso","Chiave già in uso","Ключ уже используется","Ключ уже використовується","Ключът вече се използва","Кључ је већ у употреби","Το κλειδί χρησιμοποιείται ήδη","Chave já em uso","Tasta e deja folosită" } }, + { "Keyboard backlight", { 0, "Toetsenbordverlichting","Tastaturbeleuchtung","Rétroéclairage clavier","Retroiluminación del teclado","Retroilluminazione tastiera","Подсветка клавиатуры","Підсвітка клавіатури","Подсветка на клавиатурата","Осветљење тастатуре","Οπίσθιος φωτισμός πληκτρολογίου","Luz do teclado","Iluminare tastatură" } }, + { "Keyboard nav: WASDZ on", { 0, "Toetsenbordnav.: WASDZ aan","Tastaturnav.: WASDZ an","Nav. clavier : WASDZ activé","Nav. teclado: WASDZ act.","Nav. tastiera: WASDZ on","Навигация клавиатурой: WASDZ вкл.","Навігація клавіатурою: WASDZ увімк.","Клавиатурна навиг.: WASDZ вкл.","Навиг. тастатуром: WASDZ укљ.","Πλοήγηση πληκτρ.: WASDZ ενεργό","Nav. teclado: WASDZ ligado","Navigare tastatură: WASDZ pornit" } }, + { "Keyboard nav: off", { 0, "Toetsenbordnav.: uit","Tastaturnav.: aus","Nav. clavier : désactivé","Nav. teclado: desact.","Nav. tastiera: off","Навигация клавиатурой: выкл.","Навігація клавіатурою: вимк.","Клавиатурна навиг.: изкл.","Навиг. тастатуром: искљ.","Πλοήγηση πληκτρ.: κλειστό","Nav. teclado: desligado","Navigare tastatură: oprită" } }, + { "Keyboard navigation", { 0, "Toetsenbordnavigatie","Tastaturnavigation","Navigation clavier","Navegación por teclado","Navigazione da tastiera","Навигация клавиатурой","Навігація клавіатурою","Навигация с клавиатура","Навигација тастатуром","Πλοήγηση με πληκτρολόγιο","Navegação por teclado","Navigare cu tastatura" } }, + { "Leave empty if not required", { 0, "Leeg laten indien niet vereist","Leer lassen, wenn nicht nötig","Laisser vide si non requis","Dejar vacío si no es necesario","Lascia vuoto se non richiesto","Оставьте пустым, если не нужно","Залиште порожнім, якщо не потрібно","Оставете празно, ако не е нужно","Оставите празно ако није потребно","Αφήστε κενό αν δεν απαιτείται","Deixe vazio se não for necessário","Lasă gol dacă nu e necesar" } }, + { "Let's set up your device.\n\nYou'll pick a name and choose your LoRa region. Takes about a minute.", { 0, "Laten we je apparaat instellen.\n\nJe kiest een naam en je LoRa-regio. Duurt ongeveer een minuut.","Richten wir dein Gerät ein.\n\nDu wählst einen Namen und deine LoRa-Region. Dauert etwa eine Minute.","Configurons votre appareil.\n\nVous choisirez un nom et votre région LoRa. Cela prend environ une minute.","Configuremos tu dispositivo.\n\nElegirás un nombre y tu región LoRa. Tarda alrededor de un minuto.","Configuriamo il tuo dispositivo.\n\nSceglierai un nome e la tua regione LoRa. Richiede circa un minuto.","Настроим ваше устройство.\n\nВы выберете имя и регион LoRa. Займёт около минуты.","Налаштуймо ваш пристрій.\n\nВи виберете ім'я та регіон LoRa. Займе близько хвилини.","Нека настроим устройството ви.\n\nЩе изберете име и вашия LoRa регион. Отнема около минута.","Хајде да подесимо ваш уређај.\n\nИзабраћете име и ваш LoRa регион. Траје око минут.","Ας ρυθμίσουμε τη συσκευή σας.\n\nΘα επιλέξετε όνομα και την περιοχή LoRa. Διαρκεί περίπου ένα λεπτό.","Vamos configurar seu dispositivo.\n\nVocê escolherá um nome e sua região LoRa. Leva cerca de um minuto.","Să-ţi configurăm dispozitivul.\n\nÎţi alegi un nume şi regiunea LoRa. Durează cam un minut." } }, + { "Letters only", { 0, "Alleen letters","Nur Buchstaben","Lettres uniquement","Solo letras","Solo lettere","Только буквы","Лише літери","Само букви","Само слова","Μόνο γράμματα","Somente letras","Doar litere" } }, + { "Loading map tiles…\n\nKeep Wi-Fi connected.\nTiles appear as they arrive.", { 0, "Kaarttegels laden…\n\nHoud Wi-Fi verbonden.\nTegels verschijnen zodra ze binnenkomen.","Kartenkacheln werden geladen…\n\nWi-Fi verbunden halten.\nKacheln erscheinen beim Eintreffen.","Chargement des tuiles…\n\nGardez le Wi-Fi connecté.\nLes tuiles apparaissent à leur arrivée.","Cargando teselas del mapa…\n\nMantén el Wi-Fi conectado.\nLas teselas aparecen al llegar.","Caricamento tasselli mappa…\n\nMantieni il Wi-Fi connesso.\nI tasselli appaiono all'arrivo.","Загрузка тайлов карты…\n\nНе отключайте Wi-Fi.\nТайлы появляются по мере загрузки.","Завантаження тайлів карти…\n\nНе вимикайте Wi-Fi.\nТайли з'являються під час завантаження.","Зареждане на плочки на картата…\n\nЗапазете Wi-Fi връзката.\nПлочките се появяват при пристигане.","Учитавање плочица мапе…\n\nОдржавајте Wi-Fi везу.\nПлочице се појављују како стижу.","Φόρτωση πλακιδίων χάρτη…\n\nΚρατήστε το Wi-Fi συνδεδεμένο.\nΤα πλακίδια εμφανίζονται καθώς φτάνουν.","Carregando blocos do mapa…\n\nMantenha o Wi-Fi conectado.\nOs blocos aparecem ao chegar.","Se încarcă dalele hărţii…\n\nPăstrează Wi-Fi conectat.\nDalele apar pe măsură ce sosesc." } }, + { "Local every", { 0, "Lokaal elke","Lokal alle","Local toutes les","Local cada","Locale ogni","Локально каждые","Локально кожні","Локално на всеки","Локално сваких","Τοπικά κάθε","Local a cada","Local la fiecare" } }, + { "Local sensors", { 0, "Lokale sensoren","Lokale Sensoren","Capteurs locaux","Sensores locales","Sensori locali","Локальные датчики","Локальні датчики","Локални сензори","Локални сензори","Τοπικοί αισθητήρες","Sensores locais","Senzori locali" } }, + { "Lock when screen off", { 0, "Vergrendelen bij scherm uit","Sperren bei Bildschirm aus","Verrouiller écran éteint","Bloquear con pantalla apagada","Blocca a schermo spento","Блокировать при выкл. экране","Блокувати при вимк. екрані","Заключване при изключен екран","Закључај кад је екран искључен","Κλείδωμα με κλειστή οθόνη","Bloquear com tela desligada","Blochează când ecranul e stins" } }, + { "Locks when screen off\n(hold the d-pad to unlock)", { 0, "Vergrendelt bij scherm uit\n(houd de d-pad vast om te ontgrendelen)","Sperrt bei Bildschirm aus\n(D-Pad halten zum Entsperren)","Verrouille écran éteint\n(maintenez la croix pour déverrouiller)","Se bloquea con pantalla apagada\n(mantén el d-pad para desbloquear)","Si blocca a schermo spento\n(tieni il d-pad per sbloccare)","Блокируется при выкл. экране\n(удерживайте d-pad для разблокировки)","Блокується при вимк. екрані\n(утримуйте d-pad для розблокування)","Заключва се при изключен екран\n(задръжте d-pad за отключване)","Закључава се кад је екран искључен\n(држите d-pad да откључате)","Κλειδώνει με κλειστή οθόνη\n(κρατήστε το d-pad για ξεκλείδωμα)","Bloqueia com tela desligada\n(segure o d-pad para desbloquear)","Se blochează când ecranul e stins\n(ţine d-pad pentru deblocare)" } }, + { "Locks when screen off\n(hold the trackball to unlock)", { 0, "Vergrendelt bij scherm uit\n(houd de trackball vast om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Trackball halten zum Entsperren)","Verrouille écran éteint\n(maintenez le trackball pour déverrouiller)","Se bloquea con pantalla apagada\n(mantén el trackball para desbloquear)","Si blocca a schermo spento\n(tieni il trackball per sbloccare)","Блокируется при выкл. экране\n(удерживайте трекбол для разблокировки)","Блокується при вимк. екрані\n(утримуйте трекбол для розблокування)","Заключва се при изключен екран\n(задръжте тракбола за отключване)","Закључава се кад је екран искључен\n(држите трекбол да откључате)","Κλειδώνει με κλειστή οθόνη\n(κρατήστε το trackball για ξεκλείδωμα)","Bloqueia com tela desligada\n(segure o trackball para desbloquear)","Se blochează când ecranul e stins\n(ţine trackball-ul pentru deblocare)" } }, + { "Locks when screen off\n(press Volume Down to unlock)", { 0, "Vergrendelt bij scherm uit\n(druk op Volume Omlaag om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Lautstärke leiser drücken zum Entsperren)","Verrouille écran éteint\n(appuyez sur Volume Bas pour déverrouiller)","Se bloquea con pantalla apagada\n(pulsa Bajar volumen para desbloquear)","Si blocca a schermo spento\n(premi Volume Giù per sbloccare)","Блокируется при выкл. экране\n(нажмите Громкость вниз для разблокировки)","Блокується при вимк. екрані\n(натисніть Гучність вниз для розблокування)","Заключва се при изключен екран\n(натиснете Сила надолу за отключване)","Закључава се кад је екран искључен\n(притисните Јачина доле да откључате)","Κλειδώνει με κλειστή οθόνη\n(πατήστε Μείωση έντασης για ξεκλείδωμα)","Bloqueia com tela desligada\n(pressione Volume para baixo para desbloquear)","Se blochează când ecranul e stins\n(apasă Volum jos pentru deblocare)" } }, + { "Locks when screen off\n(press the button to unlock)", { 0, "Vergrendelt bij scherm uit\n(druk op de knop om te ontgrendelen)","Sperrt bei Bildschirm aus\n(Taste drücken zum Entsperren)","Verrouille écran éteint\n(appuyez sur le bouton pour déverrouiller)","Se bloquea con pantalla apagada\n(pulsa el botón para desbloquear)","Si blocca a schermo spento\n(premi il pulsante per sbloccare)","Блокируется при выкл. экране\n(нажмите кнопку для разблокировки)","Блокується при вимк. екрані\n(натисніть кнопку для розблокування)","Заключва се при изключен екран\n(натиснете бутона за отключване)","Закључава се кад је екран искључен\n(притисните дугме да откључате)","Κλειδώνει με κλειστή οθόνη\n(πατήστε το κουμπί για ξεκλείδωμα)","Bloqueia com tela desligada\n(pressione o botão para desbloquear)","Se blochează când ecranul e stins\n(apasă butonul pentru deblocare)" } }, + { "Logging in again...", { 0, "Opnieuw inloggen...","Erneute Anmeldung...","Reconnexion...","Iniciando sesión de nuevo...","Nuovo accesso...","Повторный вход...","Повторний вхід...","Повторно влизане...","Поновно пријављивање...","Επανασύνδεση...","Entrando novamente...","Se autentifică din nou..." } }, + { "Map tiles: microSD\n\n/maps/osm/z/x/y.png\n(or /tiles/z/x/y.jpg)\n\nMap appears when a tile\nfor this area is found.", { 0, "Kaarttegels: microSD\n\n/maps/osm/z/x/y.png\n(of /tiles/z/x/y.jpg)\n\nKaart verschijnt zodra een tegel\nvoor dit gebied is gevonden.","Kartenkacheln: microSD\n\n/maps/osm/z/x/y.png\n(oder /tiles/z/x/y.jpg)\n\nKarte erscheint, sobald eine Kachel\nfür dieses Gebiet gefunden wird.","Tuiles de carte : microSD\n\n/maps/osm/z/x/y.png\n(ou /tiles/z/x/y.jpg)\n\nLa carte apparaît quand une tuile\npour cette zone est trouvée.","Teselas del mapa: microSD\n\n/maps/osm/z/x/y.png\n(o /tiles/z/x/y.jpg)\n\nEl mapa aparece cuando se encuentra\nuna tesela para esta zona.","Tasselli mappa: microSD\n\n/maps/osm/z/x/y.png\n(o /tiles/z/x/y.jpg)\n\nLa mappa appare quando si trova\nun tassello per questa zona.","Тайлы карты: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nКарта появляется, когда найден\nтайл для этой области.","Тайли карти: microSD\n\n/maps/osm/z/x/y.png\n(або /tiles/z/x/y.jpg)\n\nКарта з'являється, коли знайдено\nтайл для цієї області.","Плочки на картата: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nКартата се появява, когато се намери\nплочка за тази област.","Плочице мапе: microSD\n\n/maps/osm/z/x/y.png\n(или /tiles/z/x/y.jpg)\n\nМапа се појављује када се пронађе\nплочица за ову област.","Πλακίδια χάρτη: microSD\n\n/maps/osm/z/x/y.png\n(ή /tiles/z/x/y.jpg)\n\nΟ χάρτης εμφανίζεται όταν βρεθεί\nπλακίδιο για αυτήν την περιοχή.","Blocos do mapa: microSD\n\n/maps/osm/z/x/y.png\n(ou /tiles/z/x/y.jpg)\n\nO mapa aparece quando um bloco\npara esta área é encontrado.","Dale hartă: microSD\n\n/maps/osm/z/x/y.png\n(sau /tiles/z/x/y.jpg)\n\nHarta apare când se găseşte\no dală pentru această zonă." } }, + { "Map — no tile pack on SPIFFS yet.\n\nUpload a tile pack to /tiles///.jpg with the host-side generator.", { 0, "Kaart — nog geen tegelpakket op SPIFFS.\n\nUpload een tegelpakket naar /tiles///.jpg met de host-generator.","Karte — noch kein Kachelpaket auf SPIFFS.\n\nLade ein Kachelpaket nach /tiles///.jpg mit dem Host-Generator hoch.","Carte — pas encore de pack de tuiles sur SPIFFS.\n\nTéléversez un pack vers /tiles///.jpg avec le générateur côté hôte.","Mapa — aún no hay paquete de teselas en SPIFFS.\n\nSube un paquete a /tiles///.jpg con el generador del host.","Mappa — nessun pacchetto tasselli su SPIFFS.\n\nCarica un pacchetto in /tiles///.jpg con il generatore lato host.","Карта — на SPIFFS ещё нет пакета тайлов.\n\nЗагрузите пакет в /tiles///.jpg генератором на хосте.","Карта — на SPIFFS ще немає пакета тайлів.\n\nЗавантажте пакет у /tiles///.jpg генератором на хості.","Карта — все още няма пакет с плочки на SPIFFS.\n\nКачете пакет в /tiles///.jpg с генератора от хоста.","Мапа — још нема пакета плочица на SPIFFS.\n\nОтпремите пакет у /tiles///.jpg генератором на хосту.","Χάρτης — δεν υπάρχει ακόμη πακέτο πλακιδίων στο SPIFFS.\n\nΑνεβάστε πακέτο στο /tiles///.jpg με τη γεννήτρια πλευράς host.","Mapa — ainda sem pacote de blocos no SPIFFS.\n\nEnvie um pacote para /tiles///.jpg com o gerador no host.","Hartă — încă niciun pachet de dale pe SPIFFS.\n\nÎncarcă un pachet de dale în /tiles///.jpg cu generatorul de pe gazdă." } }, + { "Mesh health: a flood advert is rebroadcast by every repeater in range, so frequent floods add airtime for the whole network. Keep them infrequent (a day or more). Your neighbours still see you from zero-hop adverts and your message replies, so flood mainly when distant nodes need to rediscover you. Leave both Off to advertise only when you tap the buttons above.", { 0, "Mesh-gezondheid: een flood-advert wordt door elke repeater in bereik opnieuw uitgezonden, dus frequente floods verhogen de airtime voor het hele netwerk. Houd ze zeldzaam (een dag of meer). Je buren zien je nog steeds via zero-hop adverts en je berichtantwoorden, dus flood vooral wanneer verre nodes je opnieuw moeten ontdekken. Laat beide uit om alleen te adverteren als je op de knoppen hierboven tikt.","Mesh-Gesundheit: ein Flood-Advert wird von jedem Repeater in Reichweite erneut gesendet, häufige Floods erhöhen also die Sendezeit für das ganze Netz. Halte sie selten (ein Tag oder mehr). Deine Nachbarn sehen dich weiterhin über Zero-Hop-Adverts und deine Antworten, also floode vor allem, wenn ferne Knoten dich wiederfinden müssen. Lass beide aus, um nur bei Tippen der Tasten oben zu adverten.","Santé du mesh : un advert flood est réémis par chaque répéteur à portée, donc les floods fréquents ajoutent du temps d'antenne pour tout le réseau. Gardez-les rares (un jour ou plus). Vos voisins vous voient encore via les adverts zero-hop et vos réponses, alors floodez surtout quand des nœuds lointains doivent vous redécouvrir. Laissez les deux désactivés pour n'adverter qu'en touchant les boutons ci-dessus.","Salud de la malla: un advert flood lo reemite cada repetidor a su alcance, así que los floods frecuentes suman tiempo de aire para toda la red. Manténlos poco frecuentes (un día o más). Tus vecinos aún te ven por los adverts zero-hop y tus respuestas, así que haz flood sobre todo cuando nodos lejanos deban redescubrirte. Deja ambos apagados para anunciarte solo al pulsar los botones de arriba.","Salute della mesh: un advert flood viene ritrasmesso da ogni ripetitore a portata, quindi flood frequenti aumentano il tempo d'antenna per l'intera rete. Tienili rari (un giorno o più). I tuoi vicini ti vedono ancora dagli advert zero-hop e dalle tue risposte, quindi fai flood soprattutto quando nodi lontani devono riscoprirti. Lascia entrambi off per annunciarti solo toccando i pulsanti sopra.","Здоровье сети: flood-advert повторно рассылает каждый ретранслятор в зоне, поэтому частые flood увеличивают эфирное время всей сети. Делайте их редкими (раз в день и реже). Соседи всё равно видят вас по zero-hop адвертам и вашим ответам, так что делайте flood в основном, когда далёким узлам нужно вас заново найти. Оставьте оба выкл., чтобы объявляться только по нажатию кнопок выше.","Здоров'я мережі: flood-advert повторно розсилає кожен ретранслятор у зоні, тож часті flood збільшують ефірний час усієї мережі. Робіть їх рідкісними (раз на день чи рідше). Сусіди все одно бачать вас за zero-hop адвертами й вашими відповідями, тож робіть flood переважно коли далеким вузлам треба знову вас знайти. Залиште обидва вимк., щоб оголошуватися лише за натисканням кнопок вище.","Здраве на мрежата: flood advert се препредава от всеки ретранслатор в обхват, затова честите flood добавят ефирно време за цялата мрежа. Пазете ги редки (ден или повече). Съседите ви пак ви виждат по zero-hop адверти и отговорите ви, затова правете flood основно когато далечни възли трябва да ви открият отново. Оставете и двете изкл., за да се обявявате само при натискане на бутоните горе.","Здравље мреже: flood advert поново емитује сваки репетитор у домету, па чести flood-ови додају емисионо време целој мрежи. Држите их ретким (дан или више). Суседи вас и даље виде преко zero-hop адверта и ваших одговора, зато радите flood углавном када удаљени чворови треба поново да вас открију. Оставите оба искључена да се оглашавате само притиском на дугмад изнад.","Υγεία mesh: ένα flood advert αναμεταδίδεται από κάθε αναμεταδότη σε εμβέλεια, οπότε τα συχνά flood προσθέτουν χρόνο εκπομπής για όλο το δίκτυο. Κρατήστε τα σπάνια (μία μέρα ή περισσότερο). Οι γείτονες σας βλέπουν ακόμη από zero-hop adverts και τις απαντήσεις σας, γι' αυτό κάντε flood κυρίως όταν μακρινοί κόμβοι πρέπει να σας ξαναβρούν. Αφήστε και τα δύο κλειστά για να διαφημίζεστε μόνο πατώντας τα κουμπιά πάνω.","Saúde da malha: um advert flood é retransmitido por cada repetidor ao alcance, então floods frequentes somam tempo de antena para toda a rede. Mantenha-os raros (um dia ou mais). Seus vizinhos ainda o veem pelos adverts zero-hop e suas respostas, então faça flood principalmente quando nós distantes precisarem redescobri-lo. Deixe ambos desligados para anunciar só ao tocar os botões acima.","Sănătatea mesh-ului: un advert flood e redifuzat de fiecare repetor din rază, aşa că floodurile dese adaugă timp de emisie pentru toată reţeaua. Păstrează-le rare (o zi sau mai mult). Vecinii te văd oricum din adverturile zero-hop şi din răspunsurile tale, deci foloseşte flood mai ales când nodurile îndepărtate trebuie să te redescopere. Lasă ambele Oprite ca să te anunţi doar când atingi butoanele de mai sus." } }, + { "Message LED", { 0, "Bericht-LED","Nachrichten-LED","LED de message","LED de mensaje","LED messaggi","Светодиод сообщений","Світлодіод повідомлень","LED за съобщения","LED за поруке","LED μηνυμάτων","LED de mensagem","LED mesaje" } }, + { "Message LED off", { 0, "Bericht-LED uit","Nachrichten-LED aus","LED de message éteint","LED de mensaje apagado","LED messaggi off","Светодиод сообщений выкл.","Світлодіод повідомлень вимк.","LED за съобщения изкл.","LED за поруке искључен","LED μηνυμάτων κλειστό","LED de mensagem desligado","LED mesaje oprit" } }, + { "Message LED on", { 0, "Bericht-LED aan","Nachrichten-LED an","LED de message allumé","LED de mensaje encendido","LED messaggi on","Светодиод сообщений вкл.","Світлодіод повідомлень увімк.","LED за съобщения вкл.","LED за поруке укључен","LED μηνυμάτων ανοιχτό","LED de mensagem ligado","LED mesaje pornit" } }, + { "Message deleted", { 0, "Bericht verwijderd","Nachricht gelöscht","Message supprimé","Mensaje eliminado","Messaggio eliminato","Сообщение удалено","Повідомлення видалено","Съобщението е изтрито","Порука обрисана","Το μήνυμα διαγράφηκε","Mensagem excluída","Mesaj şters" } }, + { "Message didn't get through.\nResend it?", { 0, "Bericht is niet aangekomen.\nOpnieuw versturen?","Nachricht kam nicht an.\nErneut senden?","Le message n'est pas passé.\nRenvoyer ?","El mensaje no llegó.\n¿Reenviarlo?","Il messaggio non è arrivato.\nInviarlo di nuovo?","Сообщение не дошло.\nОтправить снова?","Повідомлення не дійшло.\nНадіслати знову?","Съобщението не стигна.\nДа го изпратя ли пак?","Порука није стигла.\nПослати поново?","Το μήνυμα δεν έφτασε.\nΝα σταλεί ξανά;","A mensagem não chegou.\nReenviar?","Mesajul nu a ajuns.\nÎl retrimiţi?" } }, + { "Message sound", { 0, "Berichtgeluid","Nachrichtenton","Son des messages","Sonido de mensaje","Suono messaggi","Звук сообщений","Звук повідомлень","Звук на съобщения","Звук порука","Ήχος μηνυμάτων","Som de mensagem","Sunet mesaj" } }, { "Do Not Disturb", { 0 } }, { "Start time", { 0 } }, { "End time", { 0 } }, - { "Multi-ack %s · Repeat %s · RX boost %s", { 0, "Multi-ack %s · Herhalen %s · RX-boost %s","Multi-Ack %s · Wdh. %s · RX-Boost %s","Multi-ack %s · Répéter %s · RX boost %s","Multi-ack %s · Repetir %s · RX boost %s","Multi-ack %s · Ripeti %s · RX boost %s","Мульти-ack %s · Повтор %s · RX boost %s","Мульти-ack %s · Повтор %s · RX boost %s","Мулти-ack %s · Повтор %s · RX boost %s","Мулти-ack %s · Понови %s · RX boost %s","Multi-ack %s · Επανάλ. %s · RX boost %s","Multi-ack %s · Repetir %s · RX boost %s" } }, - { "Mute @", { 0, "Demp @","@ stumm","Muet @","Silenciar @","Muto @","Без звука @","Без звуку @","Заглуши @","Утишај @","Σίγαση @","Silenciar @" } }, - { "Mute msgs", { 0, "Berichten dempen","Nachr. stumm","Muet msgs","Silenciar msjs","Muto msg","Заглушить сообщ.","Вимкнути звук повід.","Заглуши съобщ.","Утишај поруке","Σίγαση μην.","Silenciar msgs" } }, - { "Mute notification sound", { 0, "Meldingsgeluid dempen","Benachrichtigungston stumm","Couper le son des notifications","Silenciar sonido de notificación","Silenzia suono notifiche","Отключить звук уведомлений","Вимкнути звук сповіщень","Заглуши звука на известията","Утишај звук обавештења","Σίγαση ήχου ειδοποιήσεων","Silenciar som de notificação" } }, - { "My position (no GPS fix)", { 0, "Mijn positie (geen GPS-fix)","Meine Position (kein GPS-Fix)","Ma position (pas de fix GPS)","Mi posición (sin fijación GPS)","La mia posizione (nessun fix GPS)","Моё положение (нет GPS-фикса)","Моє положення (немає GPS-фікса)","Моята позиция (няма GPS фиксация)","Моја позиција (нема GPS фиксације)","Η θέση μου (χωρίς στίγμα GPS)","Minha posição (sem fixação GPS)" } }, - { "Navigation keys", { 0, "Navigatietoetsen","Navigationstasten","Touches de navigation","Teclas de navegación","Tasti di navigazione","Клавиши навигации","Клавіші навігації","Навигационни клавиши","Тастери за навигацију","Πλήκτρα πλοήγησης","Teclas de navegação" } }, - { "Navigation keys — tap a row, then press a key", { 0, "Navigatietoetsen — tik een rij, druk dan een toets","Navigationstasten — Zeile antippen, dann Taste drücken","Touches de navigation — touchez une ligne, puis une touche","Teclas de navegación — toca una fila, luego pulsa una tecla","Tasti di navigazione — tocca una riga, poi premi un tasto","Клавиши навигации — коснитесь строки, затем нажмите клавишу","Клавіші навігації — торкніться рядка, потім натисніть клавішу","Навигационни клавиши — докоснете ред, после натиснете клавиш","Тастери за навигацију — додирните ред, па притисните тастер","Πλήκτρα πλοήγησης — πατήστε μια γραμμή, μετά ένα πλήκτρο","Teclas de navegação — toque uma linha, depois pressione uma tecla" } }, - { "Night mode (invert)", { 0, "Nachtmodus (omkeren)","Nachtmodus (invertieren)","Mode nuit (inverser)","Modo noche (invertir)","Modalità notte (inverti)","Ночной режим (инверсия)","Нічний режим (інверсія)","Нощен режим (инвертиране)","Ноћни режим (инвертуј)","Νυχτερινή λειτουργία (αντιστροφή)","Modo noturno (inverter)" } }, - { "No @mentions yet", { 0, "Nog geen @vermeldingen","Noch keine @Erwähnungen","Aucune @mention pour l'instant","Aún no hay @menciones","Ancora nessuna @menzione","Пока нет @упоминаний","Поки що немає @згадок","Още няма @споменавания","Још нема @помена","Καμία @αναφορά ακόμη","Ainda sem @menções" } }, - { "No GPS module on this device.", { 0, "Geen GPS-module op dit apparaat.","Kein GPS-Modul auf diesem Gerät.","Aucun module GPS sur cet appareil.","Sin módulo GPS en este dispositivo.","Nessun modulo GPS su questo dispositivo.","На этом устройстве нет модуля GPS.","На цьому пристрої немає модуля GPS.","Няма GPS модул на това устройство.","Нема GPS модула на овом уређају.","Χωρίς μονάδα GPS σε αυτή τη συσκευή.","Sem módulo GPS neste dispositivo." } }, - { "No SD card", { 0, "Geen SD-kaart","Keine SD-Karte","Pas de carte SD","Sin tarjeta SD","Nessuna scheda SD","Нет SD-карты","Немає SD-карти","Няма SD карта","Нема SD картице","Χωρίς κάρτα SD","Sem cartão SD" } }, - { "No SD card (or unreadable format)", { 0, "Geen SD-kaart (of onleesbaar formaat)","Keine SD-Karte (oder unlesbares Format)","Pas de carte SD (ou format illisible)","Sin tarjeta SD (o formato ilegible)","Nessuna scheda SD (o formato illeggibile)","Нет SD-карты (или нечитаемый формат)","Немає SD-карти (або нечитабельний формат)","Няма SD карта (или нечетим формат)","Нема SD картице (или нечитљив формат)","Χωρίς κάρτα SD (ή μη αναγνώσιμη μορφή)","Sem cartão SD (ou formato ilegível)" } }, - { "No battery history yet.\n\nLogged every 5 minutes.", { 0, "Nog geen batterijgeschiedenis.\n\nElke 5 minuten gelogd.","Noch kein Akkuverlauf.\n\nAlle 5 Minuten protokolliert.","Pas encore d'historique de batterie.\n\nEnregistré toutes les 5 minutes.","Aún no hay historial de batería.\n\nRegistrado cada 5 minutos.","Ancora nessuno storico batteria.\n\nRegistrato ogni 5 minuti.","Истории батареи пока нет.\n\nЗапись каждые 5 минут.","Історії батареї ще немає.\n\nЗапис кожні 5 хвилин.","Все още няма история на батерията.\n\nЗапис на всеки 5 минути.","Још нема историје батерије.\n\nБележи се сваких 5 минута.","Δεν υπάρχει ιστορικό μπαταρίας.\n\nΚαταγραφή κάθε 5 λεπτά.","Ainda sem histórico da bateria.\n\nRegistrado a cada 5 minutos." } }, - { "No discovered nodes yet.\n\nAnything heard over the air that isn't in your contacts will show up here. When auto-add is off, you can manually add nodes to your contacts.", { 0, "Nog geen ontdekte nodes.\n\nAlles wat via de ether wordt gehoord en niet in je contacten staat, verschijnt hier. Als automatisch toevoegen uit staat, kun je nodes handmatig aan je contacten toevoegen.","Noch keine entdeckten Nodes.\n\nAlles über Funk Gehörte, das nicht in deinen Kontakten steht, erscheint hier. Wenn Auto-Hinzufügen aus ist, kannst du Nodes manuell zu deinen Kontakten hinzufügen.","Aucun nœud découvert pour l'instant.\n\nTout ce qui est entendu par radio et qui n'est pas dans vos contacts apparaît ici. Si l'ajout auto est désactivé, vous pouvez ajouter des nœuds manuellement.","Aún no hay nodos descubiertos.\n\nTodo lo que se oiga por radio y no esté en tus contactos aparecerá aquí. Si el auto-añadir está desactivado, puedes añadir nodos manualmente a tus contactos.","Ancora nessun nodo scoperto.\n\nTutto ciò che si sente via radio e non è nei tuoi contatti apparirà qui. Se l'aggiunta automatica è disattivata, puoi aggiungere nodi manualmente.","Обнаруженных узлов пока нет.\n\nВсё услышанное в эфире, чего нет в контактах, появится здесь. Когда автодобавление выключено, узлы можно добавить в контакты вручную.","Виявлених вузлів ще немає.\n\nУсе почуте в ефірі, чого немає в контактах, з'явиться тут. Коли автододавання вимкнено, вузли можна додати до контактів вручну.","Все още няма открити възли.\n\nВсичко чуто в ефира, което не е в контактите ви, ще се появи тук. Когато авто-добавянето е изключено, можете да добавяте възли ръчно.","Још нема откривених чворова.\n\nСве чуто у етру што није у контактима појавиће се овде. Када је аутоматско додавање искључено, чворове можете додати ручно.","Δεν βρέθηκαν κόμβοι ακόμη.\n\nΟτιδήποτε ακούγεται στον αέρα και δεν είναι στις επαφές σας θα εμφανιστεί εδώ. Όταν η αυτόματη προσθήκη είναι κλειστή, μπορείτε να προσθέσετε κόμβους χειροκίνητα.","Ainda sem nós descobertos.\n\nTudo ouvido pelo rádio que não esteja nos seus contatos aparecerá aqui. Quando a adição automática está desligada, você pode adicionar nós manualmente." } }, - { "No earlier versions yet", { 0, "Nog geen eerdere versies","Noch keine früheren Versionen","Aucune version antérieure","Aún no hay versiones anteriores","Ancora nessuna versione precedente","Ранних версий пока нет","Ранніх версій ще немає","Все още няма по-ранни версии","Још нема ранијих верзија","Δεν υπάρχουν προηγούμενες εκδόσεις","Ainda sem versões anteriores" } }, - { "No local expansion data", { 0, "Geen lokale uitbreidingsdata","Keine lokalen Erweiterungsdaten","Aucune donnée d'extension locale","Sin datos de expansión local","Nessun dato di espansione locale","Нет локальных данных расширения","Немає локальних даних розширення","Няма локални данни за разширение","Нема локалних података о проширењу","Δεν υπάρχουν τοπικά δεδομένα επέκτασης","Sem dados de expansão local" } }, - { "No location for this contact", { 0, "Geen locatie voor dit contact","Kein Standort für diesen Kontakt","Aucune position pour ce contact","Sin ubicación para este contacto","Nessuna posizione per questo contatto","Нет местоположения для этого контакта","Немає розташування для цього контакту","Няма местоположение за този контакт","Нема локације за овај контакт","Καμία τοποθεσία για αυτήν την επαφή","Sem localização para este contato" } }, - { "No map tiles here.\n\nConnect to Wi-Fi (Settings → Wi-Fi)\nto download this area.", { 0, "Hier geen kaarttegels.\n\nMaak verbinding met Wi-Fi (Instellingen → Wi-Fi)\nom dit gebied te downloaden.","Hier keine Kartenkacheln.\n\nMit Wi-Fi verbinden (Einstellungen → Wi-Fi),\num dieses Gebiet zu laden.","Aucune tuile ici.\n\nConnectez-vous au Wi-Fi (Réglages → Wi-Fi)\npour télécharger cette zone.","No hay teselas aquí.\n\nConéctate a Wi-Fi (Ajustes → Wi-Fi)\npara descargar esta zona.","Nessuna tessera qui.\n\nConnettiti al Wi-Fi (Impostazioni → Wi-Fi)\nper scaricare quest'area.","Здесь нет тайлов карты.\n\nПодключитесь к Wi-Fi (Настройки → Wi-Fi),\nчтобы загрузить этот район.","Тут немає плиток карти.\n\nПідключіться до Wi-Fi (Налаштування → Wi-Fi),\nщоб завантажити цю ділянку.","Тук няма плочки на картата.\n\nСвържете се с Wi-Fi (Настройки → Wi-Fi),\nза да изтеглите този район.","Овде нема плочица карте.\n\nПовежите се на Wi-Fi (Подешавања → Wi-Fi)\nда преузмете ову област.","Δεν υπάρχουν πλακίδια εδώ.\n\nΣυνδεθείτε στο Wi-Fi (Ρυθμίσεις → Wi-Fi)\nγια λήψη αυτής της περιοχής.","Sem blocos de mapa aqui.\n\nConecte-se ao Wi-Fi (Ajustes → Wi-Fi)\npara baixar esta área." } }, - { "No mapped repeaters in this route", { 0, "Geen repeaters op de kaart in deze route","Keine kartierten Repeater auf dieser Route","Aucun répéteur cartographié sur cet itinéraire","Sin repetidores mapeados en esta ruta","Nessun ripetitore mappato in questa rotta","Нет нанесённых ретрансляторов на этом маршруте","Немає нанесених ретрансляторів на цьому маршруті","Няма картографирани ретранслатори по този маршрут","Нема мапираних репетитора на овој рути","Δεν υπάρχουν χαρτογραφημένοι αναμεταδότες σε αυτή τη διαδρομή","Sem repetidores mapeados nesta rota" } }, - { "No onboard GPS", { 0, "Geen ingebouwde GPS","Kein integriertes GPS","Pas de GPS intégré","Sin GPS integrado","Nessun GPS integrato","Нет встроенного GPS","Немає вбудованого GPS","Няма вграден GPS","Нема уграђеног GPS","Χωρίς ενσωματωμένο GPS","Sem GPS integrado" } }, - { "No other networks found", { 0, "Geen andere netwerken gevonden","Keine anderen Netzwerke gefunden","Aucun autre réseau trouvé","No se encontraron otras redes","Nessun'altra rete trovata","Другие сети не найдены","Інших мереж не знайдено","Не са открити други мрежи","Нема других мрежа","Δεν βρέθηκαν άλλα δίκτυα","Nenhuma outra rede encontrada" } }, - { "No reply. Check password, server,\nor device clock (device: %s)", { 0, "Geen antwoord. Controleer wachtwoord, server,\nof apparaatklok (apparaat: %s)","Keine Antwort. Prüfe Passwort, Server\noder Geräteuhr (Gerät: %s)","Pas de réponse. Vérifiez mot de passe, serveur\nou horloge de l'appareil (appareil : %s)","Sin respuesta. Revisa contraseña, servidor\no reloj del dispositivo (dispositivo: %s)","Nessuna risposta. Controlla password, server\no orologio del dispositivo (dispositivo: %s)","Нет ответа. Проверьте пароль, сервер\nили часы устройства (устройство: %s)","Немає відповіді. Перевірте пароль, сервер\nабо годинник пристрою (пристрій: %s)","Няма отговор. Проверете паролата, сървъра\nили часовника на устройството (устройство: %s)","Нема одговора. Проверите лозинку, сервер\nили сат уређаја (уређај: %s)","Καμία απάντηση. Ελέγξτε κωδικό, διακομιστή\nή ρολόι συσκευής (συσκευή: %s)","Sem resposta. Verifique senha, servidor\nou relógio do dispositivo (dispositivo: %s)" } }, - { "No saved map tiles here.\n\nConnect to Wi-Fi (Settings → Wi-Fi)\nto download this area.\nSaved tiles stay available offline.", { 0, "Hier geen opgeslagen kaarttegels.\n\nMaak verbinding met Wi-Fi (Instellingen → Wi-Fi)\nom dit gebied te downloaden.\nOpgeslagen tegels blijven offline beschikbaar.","Hier keine gespeicherten Kacheln.\n\nMit Wi-Fi verbinden (Einstellungen → Wi-Fi),\num dieses Gebiet zu laden.\nGespeicherte Kacheln bleiben offline verfügbar.","Aucune tuile enregistrée ici.\n\nConnectez-vous au Wi-Fi (Réglages → Wi-Fi)\npour télécharger cette zone.\nLes tuiles enregistrées restent hors ligne.","No hay teselas guardadas aquí.\n\nConéctate a Wi-Fi (Ajustes → Wi-Fi)\npara descargar esta zona.\nLas teselas guardadas quedan disponibles sin conexión.","Nessuna tessera salvata qui.\n\nConnettiti al Wi-Fi (Impostazioni → Wi-Fi)\nper scaricare quest'area.\nLe tessere salvate restano disponibili offline.","Здесь нет сохранённых тайлов.\n\nПодключитесь к Wi-Fi (Настройки → Wi-Fi),\nчтобы загрузить этот район.\nСохранённые тайлы остаются доступны офлайн.","Тут немає збережених плиток.\n\nПідключіться до Wi-Fi (Налаштування → Wi-Fi),\nщоб завантажити цю ділянку.\nЗбережені плитки лишаються доступні офлайн.","Тук няма запазени плочки.\n\nСвържете се с Wi-Fi (Настройки → Wi-Fi),\nза да изтеглите този район.\nЗапазените плочки остават достъпни офлайн.","Овде нема сачуваних плочица.\n\nПовежите се на Wi-Fi (Подешавања → Wi-Fi)\nда преузмете ову област.\nСачуване плочице остају доступне ван мреже.","Δεν υπάρχουν αποθηκευμένα πλακίδια εδώ.\n\nΣυνδεθείτε στο Wi-Fi (Ρυθμίσεις → Wi-Fi)\nγια λήψη αυτής της περιοχής.\nΤα αποθηκευμένα πλακίδια μένουν διαθέσιμα εκτός σύνδεσης.","Sem blocos salvos aqui.\n\nConecte-se ao Wi-Fi (Ajustes → Wi-Fi)\npara baixar esta área.\nOs blocos salvos ficam disponíveis offline." } }, - { "No telemetry in this window.\nRequest one, or widen the range below.", { 0, "Geen telemetrie in dit venster.\nVraag er een aan, of verbreed het bereik hieronder.","Keine Telemetrie in diesem Fenster.\nFordere welche an oder erweitere unten den Bereich.","Aucune télémétrie dans cette fenêtre.\nDemandez-en une ou élargissez la plage ci-dessous.","Sin telemetría en esta ventana.\nSolicita una o amplía el rango abajo.","Nessuna telemetria in questa finestra.\nRichiedine una o amplia l'intervallo sotto.","Нет телеметрии в этом окне.\nЗапросите её или расширьте диапазон ниже.","Немає телеметрії в цьому вікні.\nЗапросіть її або розширте діапазон нижче.","Няма телеметрия в този прозорец.\nПоискайте или разширете обхвата по-долу.","Нема телеметрије у овом прозору.\nЗатражите је или проширите опсег испод.","Δεν υπάρχει τηλεμετρία σε αυτό το παράθυρο.\nΖητήστε μία ή διευρύνετε το εύρος παρακάτω.","Sem telemetria nesta janela.\nSolicite uma ou amplie o intervalo abaixo." } }, - { "No version known yet. Wi-Fi and a completed update check are needed first.", { 0, "Nog geen versie bekend. Eerst zijn Wi-Fi en een voltooide updatecontrole nodig.","Noch keine Version bekannt. Zuerst sind Wi-Fi und eine abgeschlossene Update-Prüfung nötig.","Aucune version connue. Le Wi-Fi et une vérification de mise à jour terminée sont d'abord requis.","Aún no se conoce versión. Primero se necesitan Wi-Fi y una comprobación de actualización completada.","Nessuna versione nota. Servono prima il Wi-Fi e un controllo aggiornamenti completato.","Версия пока неизвестна. Сначала нужны Wi-Fi и завершённая проверка обновлений.","Версія поки невідома. Спершу потрібні Wi-Fi та завершена перевірка оновлень.","Все още няма известна версия. Първо са нужни Wi-Fi и завършена проверка за обновления.","Верзија још није позната. Прво су потребни Wi-Fi и завршена провера ажурирања.","Καμία γνωστή έκδοση ακόμη. Χρειάζονται πρώτα Wi-Fi και ολοκληρωμένος έλεγχος ενημέρωσης.","Ainda sem versão conhecida. Primeiro são necessários Wi-Fi e uma verificação de atualização concluída." } }, - { "Node\nRegion\nRadio\nSignal\nContacts\nChannels\nBattery\nUptime", { 0, "Node\nRegio\nRadio\nSignaal\nContacten\nKanalen\nBatterij\nBedrijfstijd","Node\nRegion\nRadio\nSignal\nKontakte\nKanäle\nAkku\nLaufzeit","Nœud\nRégion\nRadio\nSignal\nContacts\nCanaux\nBatterie\nDisponibilité","Nodo\nRegión\nRadio\nSeñal\nContactos\nCanales\nBatería\nActividad","Nodo\nRegione\nRadio\nSegnale\nContatti\nCanali\nBatteria\nAttività","Узел\nРегион\nРадио\nСигнал\nКонтакты\nКаналы\nБатарея\nВремя работы","Вузол\nРегіон\nРадіо\nСигнал\nКонтакти\nКанали\nБатарея\nЧас роботи","Възел\nРегион\nРадио\nСигнал\nКонтакти\nКанали\nБатерия\nВреме на работа","Чвор\nРегион\nРадио\nСигнал\nКонтакти\nКанали\nБатерија\nВреме рада","Κόμβος\nΠεριοχή\nΡαδιό\nΣήμα\nΕπαφές\nΚανάλια\nΜπαταρία\nΧρόνος λειτουργίας","Nó\nRegião\nRádio\nSinal\nContatos\nCanais\nBateria\nAtividade" } }, - { "Normal (100%)\nLarge (150%)\nHuge (200%)", { 0, "Normaal (100%)\nGroot (150%)\nEnorm (200%)","Normal (100%)\nGroß (150%)\nRiesig (200%)","Normal (100%)\nGrand (150%)\nÉnorme (200%)","Normal (100%)\nGrande (150%)\nEnorme (200%)","Normale (100%)\nGrande (150%)\nEnorme (200%)","Обычный (100%)\nБольшой (150%)\nОгромный (200%)","Звичайний (100%)\nВеликий (150%)\nВеличезний (200%)","Нормален (100%)\nГолям (150%)\nОгромен (200%)","Нормалан (100%)\nВелик (150%)\nОгроман (200%)","Κανονικό (100%)\nΜεγάλο (150%)\nΤεράστιο (200%)","Normal (100%)\nGrande (150%)\nEnorme (200%)" } }, - { "Not enough free memory for Bluetooth. Turn Wi-Fi off first.", { 0, "Niet genoeg vrij geheugen voor Bluetooth. Schakel eerst Wi-Fi uit.","Nicht genug freier Speicher für Bluetooth. Schalte zuerst Wi-Fi aus.","Mémoire insuffisante pour le Bluetooth. Désactivez d'abord le Wi-Fi.","No hay memoria suficiente para Bluetooth. Desactiva primero el Wi-Fi.","Memoria insufficiente per il Bluetooth. Disattiva prima il Wi-Fi.","Недостаточно памяти для Bluetooth. Сначала выключите Wi-Fi.","Недостатньо пам'яті для Bluetooth. Спершу вимкніть Wi-Fi.","Недостатъчно памет за Bluetooth. Първо изключете Wi-Fi.","Нема довољно меморије за Bluetooth. Прво искључите Wi-Fi.","Ανεπαρκής μνήμη για Bluetooth. Απενεργοποιήστε πρώτα το Wi-Fi.","Memória insuficiente para Bluetooth. Desligue o Wi-Fi primeiro." } }, - { "Not enough free memory for Wi-Fi. Turn Bluetooth off first.", { 0, "Niet genoeg vrij geheugen voor Wi-Fi. Schakel eerst Bluetooth uit.","Nicht genug freier Speicher für Wi-Fi. Schalte zuerst Bluetooth aus.","Mémoire insuffisante pour le Wi-Fi. Désactivez d'abord le Bluetooth.","No hay memoria suficiente para Wi-Fi. Desactiva primero el Bluetooth.","Memoria insufficiente per il Wi-Fi. Disattiva prima il Bluetooth.","Недостаточно памяти для Wi-Fi. Сначала выключите Bluetooth.","Недостатньо пам'яті для Wi-Fi. Спершу вимкніть Bluetooth.","Недостатъчно памет за Wi-Fi. Първо изключете Bluetooth.","Нема довољно меморије за Wi-Fi. Прво искључите Bluetooth.","Ανεπαρκής μνήμη για Wi-Fi. Απενεργοποιήστε πρώτα το Bluetooth.","Memória insuficiente para Wi-Fi. Desligue o Bluetooth primeiro." } }, - { "Nothing selected", { 0, "Niets geselecteerd","Nichts ausgewählt","Rien de sélectionné","Nada seleccionado","Niente selezionato","Ничего не выбрано","Нічого не вибрано","Нищо не е избрано","Ништа није изабрано","Τίποτα δεν επιλέχθηκε","Nada selecionado" } }, - { "Notification sound set", { 0, "Meldingsgeluid ingesteld","Benachrichtigungston festgelegt","Son de notification défini","Sonido de notificación establecido","Suono di notifica impostato","Звук уведомления задан","Звук сповіщення встановлено","Звукът за известие е зададен","Звук обавештења постављен","Ήχος ειδοποίησης ορίστηκε","Som de notificação definido" } }, - { "Notify on new contact", { 0, "Melden bij nieuw contact","Bei neuem Kontakt benachrichtigen","Notifier pour un nouveau contact","Avisar con nuevo contacto","Avvisa a nuovo contatto","Уведомлять о новом контакте","Сповіщати про новий контакт","Известяване при нов контакт","Обавести о новом контакту","Ειδοποίηση για νέα επαφή","Notificar novo contato" } }, - { "On boot", { 0, "Bij opstarten","Beim Start","Au démarrage","Al arrancar","All'avvio","При запуске","При запуску","При стартиране","При покретању","Στην εκκίνηση","Ao iniciar" } }, - { "Open Advertise app", { 0, "Advertentie-app openen","Advertise-App öffnen","Ouvrir l'appli Advertise","Abrir la app Advertise","Apri l'app Advertise","Открыть приложение Advertise","Відкрити застосунок Advertise","Отвори приложението Advertise","Отвори апликацију Advertise","Άνοιγμα εφαρμογής Advertise","Abrir o app Advertise" } }, - { "Open a .wav, then tap 'Set as notification sound'", { 0, "Open een .wav, tik dan op 'Instellen als meldingsgeluid'","Öffne eine .wav, dann auf 'Als Benachrichtigungston festlegen' tippen","Ouvrez un .wav, puis touchez « Définir comme son de notification »","Abre un .wav y toca 'Establecer como sonido de notificación'","Apri un .wav, poi tocca 'Imposta come suono di notifica'","Откройте .wav, затем нажмите «Задать как звук уведомления»","Відкрийте .wav, потім торкніться «Встановити як звук сповіщення»","Отворете .wav, после докоснете 'Задай като звук за известие'","Отворите .wav, па додирните 'Постави као звук обавештења'","Ανοίξτε ένα .wav και πατήστε «Ορισμός ως ήχος ειδοποίησης»","Abra um .wav e toque em 'Definir como som de notificação'" } }, - { "Open a JPEG, then tap 'Set as wallpaper'", { 0, "Open een JPEG, tik dan op 'Instellen als achtergrond'","Öffne ein JPEG, dann auf 'Als Hintergrund festlegen' tippen","Ouvrez un JPEG, puis touchez « Définir comme fond d'écran »","Abre un JPEG y toca 'Establecer como fondo'","Apri un JPEG, poi tocca 'Imposta come sfondo'","Откройте JPEG, затем нажмите «Задать как обои»","Відкрийте JPEG, потім торкніться «Встановити як шпалери»","Отворете JPEG, после докоснете 'Задай като тапет'","Отворите JPEG, па додирните 'Постави као позадину'","Ανοίξτε ένα JPEG και πατήστε «Ορισμός ως ταπετσαρία»","Abra um JPEG e toque em 'Definir como papel de parede'" } }, - { "Open mesh console", { 0, "Mesh-console openen","Mesh-Konsole öffnen","Ouvrir la console mesh","Abrir consola de la malla","Apri console mesh","Открыть консоль mesh","Відкрити консоль mesh","Отвори mesh конзолата","Отвори mesh конзолу","Άνοιγμα κονσόλας mesh","Abrir console da malha" } }, - { "Orange temp Cyan humidity", { 0, "Oranje temp Cyaan vocht","Orange Temp Cyan Feuchte","Orange temp. Cyan humidité","Naranja temp. Cian humedad","Arancione temp. Ciano umidità","Оранжевый темп. Голубой влажн.","Помаранч. темп. Блакитн. волог.","Оранжево темп. Циан влажн.","Наранџаста темп. Цијан влага","Πορτοκαλί θερμ. Κυανό υγρασία","Laranja temp. Ciano umidade" } }, - { "Overwrite the SD card's settings and\nidentity with the internal copies,\nthen reboot?", { 0, "Instellingen en identiteit van de SD-kaart\noverschrijven met de interne kopieën\nen dan herstarten?","Einstellungen und Identität der SD-Karte\nmit den internen Kopien überschreiben\nund dann neu starten?","Écraser les réglages et l'identité de la carte SD\navec les copies internes,\npuis redémarrer ?","¿Sobrescribir los ajustes e identidad de la SD\ncon las copias internas\ny reiniciar?","Sovrascrivere impostazioni e identità della SD\ncon le copie interne\ne poi riavviare?","Перезаписать настройки и идентичность SD-карты\nвнутренними копиями\nи перезагрузить?","Перезаписати налаштування й ідентичність SD-картки\nвнутрішніми копіями\nта перезавантажити?","Да се презапишат настройките и идентичността на SD\nс вътрешните копия\nи да се рестартира?","Пребрисати подешавања и идентитет SD картице\nунутрашњим копијама\nи затим рестартовати?","Αντικατάσταση ρυθμίσεων και ταυτότητας κάρτας SD\nμε τα εσωτερικά αντίγραφα\nκαι επανεκκίνηση;","Sobrescrever as configurações e a identidade do SD\ncom as cópias internas\ne reiniciar?" } }, - { "Pairing code", { 0, "Koppelcode","Kopplungscode","Code d'appairage","Código de emparejamiento","Codice di accoppiamento","Код сопряжения","Код сполучення","Код за сдвояване","Код за упаривање","Κωδικός σύζευξης","Código de pareamento" } }, - { "Pairing code saved — reboot to apply", { 0, "Koppelcode opgeslagen — herstart om toe te passen","Kopplungscode gespeichert — Neustart zum Anwenden","Code d'appairage enregistré — redémarrez pour appliquer","Código de emparejamiento guardado — reinicia para aplicar","Codice di accoppiamento salvato — riavvia per applicare","Код сопряжения сохранён — перезагрузите для применения","Код сполучення збережено — перезавантажте для застосування","Кодът за сдвояване е запазен — рестартирайте за прилагане","Код за упаривање сачуван — рестартујте да примените","Ο κωδικός σύζευξης αποθηκεύτηκε — επανεκκίνηση για εφαρμογή","Código de pareamento salvo — reinicie para aplicar" } }, - { "Password (optional)", { 0, "Wachtwoord (optioneel)","Passwort (optional)","Mot de passe (facultatif)","Contraseña (opcional)","Password (facoltativa)","Пароль (необязательно)","Пароль (необов'язково)","Парола (по избор)","Лозинка (опционо)","Κωδικός (προαιρετικό)","Senha (opcional)" } }, - { "Paused — score %d", { 0, "Gepauzeerd — score %d","Pausiert — Punkte %d","En pause — score %d","En pausa — puntuación %d","In pausa — punteggio %d","Пауза — счёт %d","Пауза — рахунок %d","На пауза — резултат %d","Паузирано — резултат %d","Σε παύση — σκορ %d","Pausado — pontuação %d" } }, - { "Poll (min)", { 0, "Ophalen (min)","Abfrage (Min)","Interrogation (min)","Sondeo (min)","Sondaggio (min)","Опрос (мин)","Опитування (хв)","Запитване (мин)","Провера (мин)","Δημοσκόπηση (λεπτά)","Consulta (min)" } }, - { "Poll every", { 0, "Ophalen elke","Abfrage alle","Interroger toutes les","Sondear cada","Sondaggio ogni","Опрашивать каждые","Опитувати кожні","Запитване на всеки","Провери сваких","Δημοσκόπηση κάθε","Consultar a cada" } }, - { "Poll every %d min", { 0, "Ophalen elke %d min","Abfrage alle %d Min","Interroger toutes les %d min","Sondear cada %d min","Sondaggio ogni %d min","Опрос каждые %d мин","Опитування кожні %d хв","Запитване на всеки %d мин","Провери сваких %d мин","Δημοσκόπηση κάθε %d λεπτά","Consultar a cada %d min" } }, - { "Port", { 0, "Poort","Port","Port","Puerto","Porta","Порт","Порт","Порт","Порт","Θύρα","Porta" } }, - { "Press a key…", { 0, "Druk op een toets…","Taste drücken…","Appuyez sur une touche…","Pulsa una tecla…","Premi un tasto…","Нажмите клавишу…","Натисніть клавішу…","Натиснете клавиш…","Притисните тастер…","Πατήστε ένα πλήκτρο…","Pressione uma tecla…" } }, - { "Probing neighbours…", { 0, "Buren onderzoeken…","Nachbarn werden geprüft…","Sondage des voisins…","Sondeando vecinos…","Ricerca vicini…","Опрос соседей…","Опитування сусідів…","Проверка на съседи…","Испитивање суседа…","Έλεγχος γειτόνων…","Sondando vizinhos…" } }, - { "Publish channel messages", { 0, "Kanaalberichten publiceren","Kanalnachrichten veröffentlichen","Publier les messages de canal","Publicar mensajes de canal","Pubblica messaggi del canale","Публиковать сообщения каналов","Публікувати повідомлення каналів","Публикуване на съобщения от канала","Објави поруке канала","Δημοσίευση μηνυμάτων καναλιού","Publicar mensagens do canal" } }, - { "Publish direct messages", { 0, "Directe berichten publiceren","Direktnachrichten veröffentlichen","Publier les messages directs","Publicar mensajes directos","Pubblica messaggi diretti","Публиковать личные сообщения","Публікувати особисті повідомлення","Публикуване на директни съобщения","Објави директне поруке","Δημοσίευση άμεσων μηνυμάτων","Publicar mensagens diretas" } }, - { "Purge", { 0, "Wissen","Löschen","Purger","Purgar","Elimina","Очистить","Очистити","Изчистване","Обриши","Εκκαθάριση","Limpar" } }, - { "Purge all discovered nodes?", { 0, "Alle ontdekte nodes wissen?","Alle entdeckten Nodes löschen?","Purger tous les nœuds découverts ?","¿Purgar todos los nodos descubiertos?","Eliminare tutti i nodi scoperti?","Очистить все обнаруженные узлы?","Очистити всі виявлені вузли?","Изчистване на всички открити възли?","Обрисати све откривене чворове?","Εκκαθάριση όλων των κόμβων που βρέθηκαν;","Limpar todos os nós descobertos?" } }, - { "RAM %u%% · PSRAM %u%%", { 0, "RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%" } }, - { "RX %d entries · Raw %d entries", { 0, "RX %d items · Raw %d items","RX %d Einträge · Raw %d Einträge","RX %d entrées · Raw %d entrées","RX %d entradas · Raw %d entradas","RX %d voci · Raw %d voci","RX %d записей · Raw %d записей","RX %d записів · Raw %d записів","RX %d записа · Raw %d записа","RX %d уноса · Raw %d уноса","RX %d καταχωρήσεις · Raw %d καταχωρήσεις","RX %d entradas · Raw %d entradas" } }, - { "Reads packets on a background task so a busy screen can't drop them.", { 0, "Leest pakketten in een achtergrondtaak zodat een druk scherm ze niet laat vallen.","Liest Pakete in einer Hintergrundaufgabe, damit ein ausgelasteter Bildschirm sie nicht verliert.","Lit les paquets en tâche de fond pour qu'un écran occupé ne les perde pas.","Lee paquetes en una tarea en segundo plano para que una pantalla ocupada no los pierda.","Legge i pacchetti in un task in background così uno schermo occupato non li perde.","Читает пакеты в фоновой задаче, чтобы занятый экран их не терял.","Читає пакети у фоновому завданні, щоб зайнятий екран їх не втрачав.","Чете пакети във фонова задача, за да не ги изпуска зает екран.","Чита пакете у позадинском задатку да их заузет екран не изгуби.","Διαβάζει πακέτα σε εργασία παρασκηνίου ώστε μια απασχολημένη οθόνη να μην τα χάνει.","Lê pacotes em uma tarefa em segundo plano para que uma tela ocupada não os perca." } }, - { "Receive unreleased test firmware — newer features, but less tested. The update check and Install update both follow the beta channel.", { 0, "Ontvang niet-uitgebrachte testfirmware — nieuwere functies, maar minder getest. De updatecontrole en Update installeren volgen beide het bètakanaal.","Nicht veröffentlichte Test-Firmware empfangen — neuere Funktionen, aber weniger getestet. Update-Prüfung und Update installieren folgen beide dem Beta-Kanal.","Recevoir un firmware de test non publié — fonctions plus récentes, mais moins testées. La vérification et l'installation suivent le canal bêta.","Recibe firmware de prueba no publicado — funciones más nuevas, pero menos probadas. La comprobación y la instalación siguen el canal beta.","Ricevi firmware di prova non rilasciato — funzioni più recenti, ma meno testate. Il controllo e l'installazione seguono il canale beta.","Получать невыпущенную тестовую прошивку — новее функции, но меньше проверены. Проверка и установка обновлений идут по бета-каналу.","Отримувати невипущену тестову прошивку — новіші функції, але менш перевірені. Перевірка та встановлення оновлень йдуть бета-каналом.","Получаване на неиздаден тестов firmware — по-нови функции, но по-малко тествани. Проверката и инсталирането следват бета канала.","Прими необјављени тест firmware — новије функције, али мање тестиране. Провера и инсталација прате бета канал.","Λήψη μη κυκλοφορημένου δοκιμαστικού firmware — νεότερες λειτουργίες, αλλά λιγότερο δοκιμασμένες. Ο έλεγχος και η εγκατάσταση ακολουθούν το κανάλι beta.","Receber firmware de teste não lançado — recursos mais novos, mas menos testados. A verificação e a instalação seguem o canal beta." } }, - { "Recently heard", { 0, "Recent gehoord","Kürzlich gehört","Entendu récemment","Escuchado hace poco","Sentito di recente","Недавно слышно","Нещодавно чуто","Наскоро чути","Недавно чути","Ακούστηκε πρόσφατα","Ouvido recentemente" } }, - { "Reformat SD as MESHCOMOD (FAT32)?\nALL data on the card will be erased.", { 0, "SD herformatteren als MESHCOMOD (FAT32)?\nALLE data op de kaart wordt gewist.","SD als MESHCOMOD (FAT32) neu formatieren?\nALLE Daten auf der Karte werden gelöscht.","Reformater la SD en MESHCOMOD (FAT32) ?\nTOUTES les données de la carte seront effacées.","¿Reformatear la SD como MESHCOMOD (FAT32)?\nSe borrarán TODOS los datos de la tarjeta.","Riformattare la SD come MESHCOMOD (FAT32)?\nTUTTI i dati sulla scheda saranno cancellati.","Отформатировать SD как MESHCOMOD (FAT32)?\nВСЕ данные на карте будут стёрты.","Переформатувати SD як MESHCOMOD (FAT32)?\nУСІ дані на картці будуть стерті.","Форматиране на SD като MESHCOMOD (FAT32)?\nВСИЧКИ данни на картата ще бъдат изтрити.","Форматирати SD као MESHCOMOD (FAT32)?\nСВИ подаци на картици биће обрисани.","Επαναδιαμόρφωση SD ως MESHCOMOD (FAT32);\nΟΛΑ τα δεδομένα στην κάρτα θα διαγραφούν.","Reformatar o SD como MESHCOMOD (FAT32)?\nTODOS os dados do cartão serão apagados." } }, - { "Region scope (#tag), blank = default.", { 0, "Regiobereik (#tag), leeg = standaard.","Regionsbereich (#tag), leer = Standard.","Portée de région (#tag), vide = défaut.","Ámbito de región (#tag), vacío = predeterminado.","Ambito regione (#tag), vuoto = predefinito.","Область региона (#tag), пусто = по умолчанию.","Область регіону (#tag), порожньо = за замовчуванням.","Обхват на регион (#tag), празно = по подразбиране.","Опсег региона (#tag), празно = подразумевано.","Εύρος περιοχής (#tag), κενό = προεπιλογή.","Escopo da região (#tag), vazio = padrão." } }, - { "Remember password", { 0, "Wachtwoord onthouden","Passwort merken","Retenir le mot de passe","Recordar contraseña","Ricorda password","Запомнить пароль","Запам'ятати пароль","Запомни паролата","Запамти лозинку","Απομνημόνευση κωδικού","Lembrar senha" } }, - { "Replay", { 0, "Opnieuw afspelen","Wiedergeben","Rejouer","Reproducir","Riproduci","Повтор","Повтор","Повтори","Понови","Επανάληψη","Reproduzir" } }, - { "Replay route on map", { 0, "Route opnieuw afspelen op kaart","Route auf Karte abspielen","Rejouer l'itinéraire sur la carte","Reproducir ruta en el mapa","Riproduci la rotta sulla mappa","Повторить маршрут на карте","Повторити маршрут на карті","Повтори маршрута на картата","Понови руту на мапи","Επανάληψη διαδρομής στον χάρτη","Reproduzir rota no mapa" } }, - { "Replaying route…", { 0, "Route wordt opnieuw afgespeeld…","Route wird abgespielt…","Relecture de l'itinéraire…","Reproduciendo ruta…","Riproduzione rotta…","Повтор маршрута…","Повтор маршруту…","Повтаряне на маршрута…","Понављање руте…","Επανάληψη διαδρομής…","Reproduzindo rota…" } }, - { "Resend", { 0, "Opnieuw versturen","Erneut senden","Renvoyer","Reenviar","Reinvia","Отправить снова","Надіслати знову","Изпрати отново","Пошаљи поново","Επαναποστολή","Reenviar" } }, - { "Resend failed", { 0, "Opnieuw versturen mislukt","Erneut senden fehlgeschlagen","Échec du renvoi","Falló el reenvío","Reinvio non riuscito","Не удалось отправить снова","Не вдалося надіслати знову","Повторното изпращане е неуспешно","Поновно слање није успело","Η επαναποστολή απέτυχε","Falha ao reenviar" } }, - { "Reverse scrollball", { 0, "Scrollball omkeren","Scrollball umkehren","Inverser la boule de défilement","Invertir la bola de desplazamiento","Inverti scrollball","Инвертировать трекбол","Інвертувати трекбол","Обръщане на скролбол","Обрни scrollball","Αντιστροφή scrollball","Inverter o scrollball" } }, - { "Room is read-only for you.\nJoin with the room password to post.", { 0, "De ruimte is voor jou alleen-lezen.\nDoe mee met het ruimtewachtwoord om te posten.","Der Raum ist für dich schreibgeschützt.\nMit dem Raumpasswort beitreten, um zu posten.","La salle est en lecture seule pour vous.\nRejoignez avec le mot de passe pour publier.","La sala es de solo lectura para ti.\nÚnete con la contraseña de la sala para publicar.","La stanza è di sola lettura per te.\nEntra con la password della stanza per pubblicare.","Комната только для чтения.\nВойдите с паролем комнаты, чтобы писать.","Кімната лише для читання.\nУвійдіть з паролем кімнати, щоб писати.","Стаята е само за четене за вас.\nВлезте с паролата на стаята, за да публикувате.","Соба је само за читање за вас.\nПридружите се лозинком собе да објавите.","Το δωμάτιο είναι μόνο για ανάγνωση για εσάς.\nΣυνδεθείτε με τον κωδικό του δωματίου για δημοσίευση.","A sala é somente leitura para você.\nEntre com a senha da sala para publicar." } }, - { "SD card %s", { 0, "SD-kaart %s","SD-Karte %s","Carte SD %s","Tarjeta SD %s","Scheda SD %s","SD-карта %s","SD-картка %s","SD карта %s","SD картица %s","Κάρτα SD %s","Cartão SD %s" } }, - { "SD card (tap to mount)", { 0, "SD-kaart (tik om te koppelen)","SD-Karte (zum Einbinden tippen)","Carte SD (toucher pour monter)","Tarjeta SD (toca para montar)","Scheda SD (tocca per montare)","SD-карта (нажмите для монтирования)","SD-картка (торкніться для монтування)","SD карта (докоснете за монтиране)","SD картица (додирни за монтирање)","Κάρτα SD (πατήστε για προσάρτηση)","Cartão SD (toque para montar)" } }, - { "SD download failed: %s", { 0, "SD-download mislukt: %s","SD-Download fehlgeschlagen: %s","Échec du téléchargement SD : %s","Fallo al descargar en SD: %s","Download su SD non riuscito: %s","Не удалось загрузить на SD: %s","Не вдалося завантажити на SD: %s","Изтеглянето на SD е неуспешно: %s","Преузимање на SD није успело: %s","Η λήψη στην SD απέτυχε: %s","Falha ao baixar no SD: %s" } }, - { "Saved network", { 0, "Opgeslagen netwerk","Gespeichertes Netzwerk","Réseau enregistré","Red guardada","Rete salvata","Сохранённая сеть","Збережена мережа","Запазена мрежа","Сачувана мрежа","Αποθηκευμένο δίκτυο","Rede salva" } }, - { "Saved: %s\nFlash it from the Launcher.", { 0, "Opgeslagen: %s\nFlash het vanuit de Launcher.","Gespeichert: %s\nFlashe es über den Launcher.","Enregistré : %s\nFlashez-le depuis le Launcher.","Guardado: %s\nFlashéalo desde el Launcher.","Salvato: %s\nFlashalo dal Launcher.","Сохранено: %s\nПрошейте из Launcher.","Збережено: %s\nПрошийте з Launcher.","Запазено: %s\nФлашнете го от Launcher.","Сачувано: %s\nФлешујте га из Launcher-а.","Αποθηκεύτηκε: %s\nΕγκαταστήστε το από τον Launcher.","Salvo: %s\nInstale pelo Launcher." } }, - { "Saving to SD… %d%%", { 0, "Opslaan naar SD… %d%%","Speichern auf SD… %d%%","Enregistrement sur SD… %d%%","Guardando en SD… %d%%","Salvataggio su SD… %d%%","Сохранение на SD… %d%%","Збереження на SD… %d%%","Запис на SD… %d%%","Чување на SD… %d%%","Αποθήκευση στην SD… %d%%","Salvando no SD… %d%%" } }, - { "Saving to SD… 0%", { 0, "Opslaan naar SD… 0%","Speichern auf SD… 0%","Enregistrement sur SD… 0%","Guardando en SD… 0%","Salvataggio su SD… 0%","Сохранение на SD… 0%","Збереження на SD… 0%","Запис на SD… 0%","Чување на SD… 0%","Αποθήκευση στην SD… 0%","Salvando no SD… 0%" } }, - { "Scope direct msgs to region", { 0, "Directe berichten beperken tot regio","Direktnachrichten auf Region begrenzen","Limiter les messages directs à la région","Limitar mensajes directos a la región","Limita i messaggi diretti alla regione","Ограничить личные сообщения регионом","Обмежити особисті повідомлення регіоном","Ограничи директните съобщения до региона","Ограничи директне поруке на регион","Περιορισμός άμεσων μηνυμάτων στην περιοχή","Limitar mensagens diretas à região" } }, - { "Screen timeout (s, 0 = never, min 10)", { 0, "Schermtime-out (s, 0 = nooit, min 10)","Bildschirm-Timeout (s, 0 = nie, min 10)","Délai d'écran (s, 0 = jamais, min 10)","Tiempo de pantalla (s, 0 = nunca, mín 10)","Timeout schermo (s, 0 = mai, min 10)","Тайм-аут экрана (с, 0 = никогда, мин 10)","Тайм-аут екрана (с, 0 = ніколи, мін 10)","Изключване на екрана (с, 0 = никога, мин 10)","Истек екрана (с, 0 = никад, мин 10)","Λήξη οθόνης (δ, 0 = ποτέ, ελάχ 10)","Tempo da tela (s, 0 = nunca, mín 10)" } }, - { "Screen-off just dims", { 0, "Scherm-uit dimt alleen","Bildschirm-Aus dimmt nur","Écran éteint = juste tamisé","Pantalla apagada solo atenúa","Schermo spento solo attenua","Выключение экрана только затемняет","Вимкнення екрана лише притемнює","Изключването на екрана само затъмнява","Гашење екрана само затамни","Οθόνη κλειστή απλώς σκοτεινιάζει","Tela desligada só escurece" } }, - { "Screenshot needs an SD card", { 0, "Schermafbeelding vereist een SD-kaart","Screenshot benötigt eine SD-Karte","La capture nécessite une carte SD","La captura necesita una tarjeta SD","Lo screenshot richiede una scheda SD","Для скриншота нужна SD-карта","Для скриншота потрібна SD-картка","Екранната снимка изисква SD карта","Снимак екрана захтева SD картицу","Το στιγμιότυπο χρειάζεται κάρτα SD","A captura precisa de um cartão SD" } }, - { "Screenshot: low memory", { 0, "Schermafbeelding: weinig geheugen","Screenshot: wenig Speicher","Capture : mémoire faible","Captura: poca memoria","Screenshot: memoria insufficiente","Скриншот: мало памяти","Скриншот: мало пам'яті","Екранна снимка: малко памет","Снимак екрана: мало меморије","Στιγμιότυπο: λίγη μνήμη","Captura: pouca memória" } }, - { "Screenshot: no SD card", { 0, "Schermafbeelding: geen SD-kaart","Screenshot: keine SD-Karte","Capture : pas de carte SD","Captura: sin tarjeta SD","Screenshot: nessuna scheda SD","Скриншот: нет SD-карты","Скриншот: немає SD-картки","Екранна снимка: няма SD карта","Снимак екрана: нема SD картице","Στιγμιότυπο: καμία κάρτα SD","Captura: sem cartão SD" } }, - { "Screenshot: write failed", { 0, "Schermafbeelding: schrijven mislukt","Screenshot: Schreiben fehlgeschlagen","Capture : échec d'écriture","Captura: fallo de escritura","Screenshot: scrittura non riuscita","Скриншот: ошибка записи","Скриншот: помилка запису","Екранна снимка: записът е неуспешен","Снимак екрана: упис није успео","Στιγμιότυπο: αποτυχία εγγραφής","Captura: falha na gravação" } }, - { "Scrollball: normal", { 0, "Scrollball: normaal","Scrollball: normal","Boule de défilement : normale","Bola de desplazamiento: normal","Scrollball: normale","Трекбол: обычный","Трекбол: звичайний","Скролбол: нормален","Scrollball: нормалан","Scrollball: κανονικό","Scrollball: normal" } }, - { "Scrollball: reversed", { 0, "Scrollball: omgekeerd","Scrollball: umgekehrt","Boule de défilement : inversée","Bola de desplazamiento: invertida","Scrollball: invertita","Трекбол: инвертирован","Трекбол: інвертований","Скролбол: обърнат","Scrollball: обрнут","Scrollball: αντεστραμμένο","Scrollball: invertido" } }, - { "Select all", { 0, "Alles selecteren","Alles auswählen","Tout sélectionner","Seleccionar todo","Seleziona tutto","Выбрать всё","Вибрати все","Избери всичко","Изабери све","Επιλογή όλων","Selecionar tudo" } }, - { "Send an advert now, or set how often you re-advertise.", { 0, "Verstuur nu een advert, of stel in hoe vaak je opnieuw adverteert.","Jetzt einen Advert senden oder festlegen, wie oft du neu advertisest.","Envoyer un advert maintenant, ou définir la fréquence de ré-advert.","Envía un advert ahora, o define con qué frecuencia re-adviertes.","Invia un advert ora, o imposta ogni quanto ripubblicare l'advert.","Отправить advert сейчас или задать частоту повторной отправки.","Надіслати advert зараз або задати частоту повторної відправки.","Изпратете advert сега или задайте колко често го повтаряте.","Пошаљите advert сада или подесите колико често га понављате.","Στείλτε ένα advert τώρα ή ορίστε πόσο συχνά επαναλαμβάνετε.","Envie um advert agora, ou defina com que frequência re-anuncia." } }, - { "Sensors tab: off (restart to apply)", { 0, "Sensortab: uit (herstart om toe te passen)","Sensoren-Tab: aus (Neustart zum Anwenden)","Onglet capteurs : désactivé (redémarrez pour appliquer)","Pestaña sensores: desactivada (reinicia para aplicar)","Scheda sensori: off (riavvia per applicare)","Вкладка датчиков: выкл (перезагрузите для применения)","Вкладка датчиків: вимк (перезавантажте для застосування)","Раздел сензори: изключен (рестарт за прилагане)","Картица сензора: искључено (рестарт за примену)","Καρτέλα αισθητήρων: κλειστή (επανεκκίνηση για εφαρμογή)","Aba de sensores: desligada (reinicie para aplicar)" } }, - { "Sensors tab: on (restart to apply)", { 0, "Sensortab: aan (herstart om toe te passen)","Sensoren-Tab: an (Neustart zum Anwenden)","Onglet capteurs : activé (redémarrez pour appliquer)","Pestaña sensores: activada (reinicia para aplicar)","Scheda sensori: on (riavvia per applicare)","Вкладка датчиков: вкл (перезагрузите для применения)","Вкладка датчиків: увімк (перезавантажте для застосування)","Раздел сензори: включен (рестарт за прилагане)","Картица сензора: укључено (рестарт за примену)","Καρτέλα αισθητήρων: ανοιχτή (επανεκκίνηση για εφαρμογή)","Aba de sensores: ligada (reinicie para aplicar)" } }, - { "Share: %s", { 0, "Delen: %s","Teilen: %s","Partager : %s","Compartir: %s","Condividi: %s","Поделиться: %s","Поділитися: %s","Споделяне: %s","Подели: %s","Κοινή χρήση: %s","Compartilhar: %s" } }, - { "Show", { 0, "Toon","Anzeigen","Afficher","Mostrar","Mostra","Показать","Показати","Покажи","Прикажи","Εμφάνιση","Mostrar" } }, - { "Show Sensors tab", { 0, "Sensoren-tab tonen","Sensoren-Tab anzeigen","Afficher l'onglet Capteurs","Mostrar pestaña Sensores","Mostra scheda Sensori","Показать вкладку датчиков","Показати вкладку датчиків","Покажи раздел сензори","Прикажи картицу сензора","Εμφάνιση καρτέλας αισθητήρων","Mostrar aba Sensores" } }, - { "Show menu-bar letters", { 0, "Menubalk-letters tonen","Menüleisten-Buchstaben zeigen","Afficher les lettres du menu","Mostrar letras de la barra","Mostra lettere della barra","Показать буквы меню","Показати літери меню","Покажи буквите на менюто","Прикажи слова у менију","Εμφάνιση γραμμάτων μενού","Mostrar letras do menu" } }, - { "Show on chart", { 0, "Op grafiek tonen","Im Diagramm zeigen","Afficher sur le graphique","Mostrar en gráfico","Mostra su grafico","Показать на графике","Показати на графіку","Покажи на графиката","Прикажи на графикону","Εμφάνιση στο γράφημα","Mostrar no gráfico" } }, - { "Show on map", { 0, "Op kaart tonen","Auf Karte zeigen","Afficher sur la carte","Mostrar en mapa","Mostra su mappa","Показать на карте","Показати на карті","Покажи на картата","Прикажи на мапи","Εμφάνιση στον χάρτη","Mostrar no mapa" } }, - { "Signal & traffic", { 0, "Signaal & verkeer","Signal & Verkehr","Signal & trafic","Señal y tráfico","Segnale e traffico","Сигнал и трафик","Сигнал і трафік","Сигнал и трафик","Сигнал и саобраћај","Σήμα & κίνηση","Sinal e tráfego" } }, - { "Sleep ready", { 0, "Klaar voor slaap","Bereit zum Schlafen","Prêt pour veille","Listo para dormir","Pronto per lo standby","Готов ко сну","Готовий до сну","Готов за заспиване","Спреман за спавање","Έτοιμο για ύπνο","Pronto para dormir" } }, - { "Snake — swipe or roll the trackball", { 0, "Snake — veeg of rol de trackball","Snake — wischen oder Trackball rollen","Snake — glissez ou roulez le trackball","Snake — desliza o gira el trackball","Snake — scorri o gira la trackball","Snake — проведите или крутите трекбол","Snake — проведіть або крутіть трекбол","Snake — плъзни или завърти трекбола","Snake — превуци или заврти трекбол","Snake — σύρετε ή κυλήστε το trackball","Snake — deslize ou gire o trackball" } }, - { "Sort", { 0, "Sorteer","Sortieren","Trier","Ordenar","Ordina","Сортировка","Сортувати","Сортирай","Сортирај","Ταξινόμηση","Ordenar" } }, - { "TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s", { 0, "TCP %s BLE %s\nWS-clients %d GPS %s Zoemer %s","TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s","TCP %s BLE %s\nClients WS %d GPS %s Buzzer %s","TCP %s BLE %s\nClientes WS %d GPS %s Zumbador %s","TCP %s BLE %s\nClient WS %d GPS %s Cicalino %s","TCP %s BLE %s\nWS-клиенты %d GPS %s Зуммер %s","TCP %s BLE %s\nWS-клієнти %d GPS %s Зумер %s","TCP %s BLE %s\nWS клиенти %d GPS %s Зумер %s","TCP %s BLE %s\nWS клијенти %d GPS %s Зујалица %s","TCP %s BLE %s\nΠελάτες WS %d GPS %s Βομβητής %s","TCP %s BLE %s\nClientes WS %d GPS %s Buzzer %s" } }, - { "TOUCH BETA", { 0, "TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA" } }, - { "Tab hotkeys — tap a row, then press a key", { 0, "Tab-sneltoetsen — tik een rij, druk dan een toets","Tab-Tasten — Zeile tippen, dann Taste drücken","Raccourcis onglet — touchez une ligne, puis une touche","Atajos de pestaña — toca una fila, luego una tecla","Tasti scheda — tocca una riga, poi premi un tasto","Горячие клавиши вкладки — коснитесь строки, затем нажмите клавишу","Гарячі клавіші вкладки — торкніться рядка, потім натисніть клавішу","Клавиши за раздела — докосни ред, после натисни клавиш","Пречице картице — додирни ред, па притисни тастер","Πλήκτρα καρτέλας — πατήστε μια σειρά, μετά ένα πλήκτρο","Atalhos da aba — toque numa linha, depois numa tecla" } }, - { "Telemetry requests allowed", { 0, "Telemetrie-verzoeken toegestaan","Telemetrie-Anfragen erlaubt","Requêtes télémétrie autorisées","Solicitudes de telemetría permitidas","Richieste telemetria consentite","Запросы телеметрии разрешены","Запити телеметрії дозволені","Заявките за телеметрия разрешени","Захтеви телеметрије дозвољени","Επιτρέπονται αιτήματα τηλεμετρίας","Solicitações de telemetria permitidas" } }, - { "Telemetry requests off", { 0, "Telemetrie-verzoeken uit","Telemetrie-Anfragen aus","Requêtes télémétrie désactivées","Solicitudes de telemetría desactivadas","Richieste telemetria disattivate","Запросы телеметрии выкл","Запити телеметрії вимк","Заявките за телеметрия изкл","Захтеви телеметрије искључени","Αιτήματα τηλεμετρίας ανενεργά","Solicitações de telemetria desligadas" } }, - { "Telemetry req…", { 0, "Telemetrie-verz…","Telemetrie-Anfr…","Requêtes télé…","Solic. telem…","Richieste tel…","Запросы теле…","Запити теле…","Заявки телем…","Захтеви теле…","Αιτήματα τηλε…","Solic. telem…" } }, - { "Telemetry settings", { 0, "Telemetrie-instellingen","Telemetrie-Einstellungen","Réglages télémétrie","Ajustes de telemetría","Impostazioni telemetria","Настройки телеметрии","Налаштування телеметрії","Настройки за телеметрия","Подешавања телеметрије","Ρυθμίσεις τηλεμετρίας","Ajustes de telemetria" } }, - { "Temperature", { 0, "Temperatuur","Temperatur","Température","Temperatura","Temperatura","Температура","Температура","Температура","Температура","Θερμοκρασία","Temperatura" } }, - { "Test builds: off", { 0, "Testbuilds: uit","Test-Builds: aus","Builds de test : off","Compilaciones de prueba: off","Build di test: off","Тестовые сборки: выкл","Тестові збірки: вимк","Тестови компилации: изкл","Тест верзије: искључено","Δοκιμαστικά builds: off","Builds de teste: off" } }, - { "Test builds: on", { 0, "Testbuilds: aan","Test-Builds: an","Builds de test : on","Compilaciones de prueba: on","Build di test: on","Тестовые сборки: вкл","Тестові збірки: увімк","Тестови компилации: вкл","Тест верзије: укључено","Δοκιμαστικά builds: on","Builds de teste: on" } }, - { "This device can run Wi-Fi and Bluetooth at once, as a standalone radio and a phone companion (MeshCore app) together.\n\nRunning both at the same time uses more RAM, so turn on only what you need.\n\nSet them up anytime in Settings.", { 0, "Dit apparaat kan Wi-Fi en Bluetooth tegelijk draaien, als zelfstandige radio én telefoon-companion (MeshCore-app) samen.\n\nBeide tegelijk gebruikt meer RAM, dus zet alleen aan wat je nodig hebt.\n\nStel ze altijd in via Instellingen.","Dieses Gerät kann Wi-Fi und Bluetooth gleichzeitig als eigenständiges Funkgerät und Telefon-Begleiter (MeshCore-App) betreiben.\n\nBeides gleichzeitig braucht mehr RAM, aktiviere also nur, was du brauchst.\n\nJederzeit in den Einstellungen einrichtbar.","Cet appareil peut utiliser Wi-Fi et Bluetooth en même temps, comme radio autonome et compagnon de téléphone (app MeshCore).\n\nLes deux à la fois consomment plus de RAM, n'activez que le nécessaire.\n\nRéglez-les à tout moment dans Réglages.","Este dispositivo puede usar Wi-Fi y Bluetooth a la vez, como radio independiente y compañero del teléfono (app MeshCore).\n\nUsar ambos consume más RAM, así que activa solo lo que necesites.\n\nConfigúralos cuando quieras en Ajustes.","Questo dispositivo può usare Wi-Fi e Bluetooth insieme, come radio autonoma e compagno del telefono (app MeshCore).\n\nUsarli entrambi consuma più RAM, quindi attiva solo ciò che serve.\n\nConfigurali quando vuoi in Impostazioni.","Это устройство может использовать Wi-Fi и Bluetooth одновременно — как автономную рацию и компаньон телефона (приложение MeshCore).\n\nОба сразу используют больше ОЗУ, включайте только нужное.\n\nНастройте их в любой момент в Настройках.","Цей пристрій може використовувати Wi-Fi і Bluetooth одночасно — як автономну рацію та компаньйон телефона (застосунок MeshCore).\n\nОбидва разом використовують більше ОЗП, вмикайте лише потрібне.\n\nНалаштуйте їх будь-коли в Налаштуваннях.","Това устройство може да ползва Wi-Fi и Bluetooth едновременно — като самостоятелно радио и телефонен компаньон (приложение MeshCore).\n\nДвете заедно ползват повече RAM, затова включвай само нужното.\n\nНастрой ги по всяко време в Настройки.","Овај уређај може користити Wi-Fi и Bluetooth истовремено — као самостални радио и телефонски пратилац (MeshCore апликација).\n\nОба заједно троше више RAM-а, зато укључи само оно што ти треба.\n\nПодеси их било кад у Подешавањима.","Αυτή η συσκευή μπορεί να χρησιμοποιεί Wi-Fi και Bluetooth ταυτόχρονα, ως αυτόνομος πομποδέκτης και συνοδός τηλεφώνου (εφαρμογή MeshCore).\n\nΚαι τα δύο μαζί χρησιμοποιούν περισσότερη RAM, οπότε ενεργοποιήστε μόνο ό,τι χρειάζεστε.\n\nΡυθμίστε τα οποτεδήποτε στις Ρυθμίσεις.","Este aparelho pode usar Wi-Fi e Bluetooth ao mesmo tempo, como rádio autônomo e companheiro do celular (app MeshCore).\n\nUsar os dois juntos consome mais RAM, então ligue só o que precisar.\n\nConfigure quando quiser em Ajustes." } }, - { "Throttles the CPU when idle to save power", { 0, "Beperkt de CPU bij inactiviteit om stroom te sparen","Drosselt die CPU im Leerlauf zum Stromsparen","Limite le CPU au repos pour économiser l'énergie","Reduce la CPU en reposo para ahorrar energía","Rallenta la CPU da inattiva per risparmiare energia","Снижает частоту ЦП в простое для экономии энергии","Знижує частоту ЦП у простої для економії енергії","Ограничава процесора при бездействие за пестене на ток","Успорава процесор у мировању ради уштеде енергије","Περιορίζει τη CPU σε αδράνεια για εξοικονόμηση ενέργειας","Reduz a CPU em repouso para poupar energia" } }, - { "Tiles cleared — reconnect Wi-Fi to re-download", { 0, "Tegels gewist — verbind Wi-Fi opnieuw om te herladen","Kacheln gelöscht — Wi-Fi neu verbinden zum Neuladen","Tuiles effacées — reconnectez le Wi-Fi pour retélécharger","Teselas borradas — reconecta Wi-Fi para descargar de nuevo","Tessere cancellate — riconnetti il Wi-Fi per riscaricare","Тайлы очищены — переподключите Wi-Fi для загрузки","Тайли очищено — перепідключіть Wi-Fi для завантаження","Плочките изчистени — свържи Wi-Fi отново за изтегляне","Плочице обрисане — поново повежи Wi-Fi за преузимање","Τα πλακίδια διαγράφηκαν — επανασυνδέστε Wi-Fi για λήψη","Blocos apagados — reconecte o Wi-Fi para rebaixar" } }, - { "Time range (hours ago)", { 0, "Tijdsbereik (uren geleden)","Zeitbereich (Stunden her)","Plage horaire (heures)","Rango de tiempo (horas atrás)","Intervallo (ore fa)","Диапазон времени (часов назад)","Діапазон часу (годин тому)","Времеви обхват (часа назад)","Временски опсег (сати уназад)","Χρονικό εύρος (ώρες πριν)","Intervalo (horas atrás)" } }, - { "Time zone", { 0, "Tijdzone","Zeitzone","Fuseau horaire","Zona horaria","Fuso orario","Часовой пояс","Часовий пояс","Часова зона","Временска зона","Ζώνη ώρας","Fuso horário" } }, - { "Time zone: %s", { 0, "Tijdzone: %s","Zeitzone: %s","Fuseau horaire : %s","Zona horaria: %s","Fuso orario: %s","Часовой пояс: %s","Часовий пояс: %s","Часова зона: %s","Временска зона: %s","Ζώνη ώρας: %s","Fuso horário: %s" } }, - { "Today", { 0, "Vandaag","Heute","Aujourd'hui","Hoy","Oggi","Сегодня","Сьогодні","Днес","Данас","Σήμερα","Hoje" } }, - { "Topographic map", { 0, "Topografische kaart","Topografische Karte","Carte topographique","Mapa topográfico","Mappa topografica","Топографическая карта","Топографічна карта","Топографска карта","Топографска мапа","Τοπογραφικός χάρτης","Mapa topográfico" } }, - { "Trace", { 0, "Traceren","Verfolgen","Tracer","Rastrear","Traccia","Трассировка","Трасування","Проследяване","Праћење","Ιχνηλάτηση","Rastrear" } }, - { "Trace route", { 0, "Route traceren","Route verfolgen","Tracer la route","Rastrear ruta","Traccia percorso","Трассировка маршрута","Трасування маршруту","Проследи маршрута","Прати руту","Ιχνηλάτηση διαδρομής","Rastrear rota" } }, - { "Trackball navigates UI", { 0, "Trackball navigeert UI","Trackball steuert UI","Le trackball navigue l'UI","Trackball navega la UI","La trackball naviga UI","Трекбол управляет UI","Трекбол керує UI","Трекболът навигира UI","Трекбол управља UI","Το trackball πλοηγεί το UI","Trackball navega a UI" } }, - { "Trackball: cursor", { 0, "Trackball: cursor","Trackball: Cursor","Trackball : curseur","Trackball: cursor","Trackball: cursore","Трекбол: курсор","Трекбол: курсор","Трекбол: курсор","Трекбол: курсор","Trackball: δρομέας","Trackball: cursor" } }, - { "Trackball: navigate UI", { 0, "Trackball: navigeer UI","Trackball: UI steuern","Trackball : naviguer l'UI","Trackball: navegar UI","Trackball: naviga UI","Трекбол: навигация UI","Трекбол: навігація UI","Трекбол: навигирай UI","Трекбол: управљај UI","Trackball: πλοήγηση UI","Trackball: navegar UI" } }, - { "Turn Wi-Fi on first", { 0, "Zet eerst Wi-Fi aan","Zuerst Wi-Fi einschalten","Activez d'abord le Wi-Fi","Activa primero el Wi-Fi","Attiva prima il Wi-Fi","Сначала включите Wi-Fi","Спершу увімкніть Wi-Fi","Първо включи Wi-Fi","Прво укључи Wi-Fi","Ενεργοποιήστε πρώτα το Wi-Fi","Ligue o Wi-Fi primeiro" } }, - { "Type a new 6-digit code · applies after a reboot", { 0, "Voer een nieuwe 6-cijferige code in · geldt na herstart","Neuen 6-stelligen Code eingeben · gilt nach Neustart","Entrez un nouveau code à 6 chiffres · appliqué au redémarrage","Escribe un nuevo código de 6 dígitos · se aplica al reiniciar","Inserisci un nuovo codice a 6 cifre · attivo dopo il riavvio","Введите новый 6-значный код · применится после перезагрузки","Введіть новий 6-значний код · діє після перезавантаження","Въведи нов 6-цифрен код · важи след рестарт","Унеси нови 6-цифрени код · важи после рестарта","Πληκτρολογήστε νέο 6ψήφιο κωδικό · ισχύει μετά την επανεκκίνηση","Digite um novo código de 6 dígitos · vale após reiniciar" } }, - { "UI size (restart to apply)", { 0, "UI-grootte (herstart om toe te passen)","UI-Größe (Neustart nötig)","Taille UI (redémarrer pour appliquer)","Tamaño UI (reiniciar para aplicar)","Dimensione UI (riavvia per applicare)","Размер UI (перезапуск для применения)","Розмір UI (перезапуск для застосування)","Размер на UI (рестарт за прилагане)","Величина UI (рестарт за примену)","Μέγεθος UI (επανεκκίνηση για εφαρμογή)","Tamanho da UI (reinicie para aplicar)" } }, - { "UI size saved — restart to apply", { 0, "UI-grootte opgeslagen — herstart om toe te passen","UI-Größe gespeichert — Neustart zum Anwenden","Taille UI enregistrée — redémarrez pour appliquer","Tamaño UI guardado — reinicia para aplicar","Dimensione UI salvata — riavvia per applicare","Размер UI сохранён — перезапуск для применения","Розмір UI збережено — перезапуск для застосування","Размерът на UI е запазен — рестарт за прилагане","Величина UI сачувана — рестарт за примену","Το μέγεθος UI αποθηκεύτηκε — επανεκκίνηση για εφαρμογή","Tamanho da UI salvo — reinicie para aplicar" } }, - { "USB powered", { 0, "USB-gevoed","USB-versorgt","Alimenté par USB","Alimentado por USB","Alimentato via USB","Питание от USB","Живлення від USB","Захранване от USB","Напајање преко USB","Τροφοδοσία USB","Alimentado por USB" } }, - { "Unblocked", { 0, "Gedeblokkeerd","Freigegeben","Débloqué","Desbloqueado","Sbloccato","Разблокирован","Розблоковано","Разблокиран","Одблокиран","Ξεμπλοκαρίστηκε","Desbloqueado" } }, - { "Unfavorite it first", { 0, "Eerst uit favorieten halen","Zuerst aus Favoriten entfernen","Retirez-le d'abord des favoris","Quítalo de favoritos primero","Rimuovi prima dai preferiti","Сначала уберите из избранного","Спершу приберіть з обраного","Първо махни от любими","Прво уклони из омиљених","Αφαιρέστε το πρώτα από τα αγαπημένα","Remova dos favoritos primeiro" } }, - { "Unmute @", { 0, "Dempen op @","Stumm aus @","Réactiver @","Reactivar @","Riattiva @","Включить @","Увімкнути @","Възстанови @","Укључи @","Ενεργοποίηση @","Reativar @" } }, - { "Unmute msgs", { 0, "Berichten aan","Nachrichten an","Réactiver msgs","Reactivar msjs","Riattiva msg","Включить сообщ.","Увімкнути повідом.","Възстанови съобщ.","Укључи поруке","Ενεργοποίηση μηνυμ.","Reativar msgs" } }, - { "Unsupported WAV (need 16-bit PCM)", { 0, "Niet-ondersteunde WAV (16-bit PCM vereist)","Nicht unterstütztes WAV (16-Bit PCM nötig)","WAV non pris en charge (16-bit PCM requis)","WAV no compatible (requiere PCM de 16 bits)","WAV non supportato (serve PCM a 16 bit)","WAV не поддерживается (нужен 16-бит PCM)","WAV не підтримується (потрібен 16-біт PCM)","Неподдържан WAV (нужен 16-битов PCM)","Неподржан WAV (потребан 16-битни PCM)","Μη υποστηριζόμενο WAV (χρειάζεται 16-bit PCM)","WAV não suportado (precisa de PCM 16 bits)" } }, - { "Update bin saved to SD", { 0, "Update-bin opgeslagen op SD","Update-Bin auf SD gespeichert","Fichier de mise à jour enregistré sur SD","Bin de actualización guardado en SD","Bin di aggiornamento salvato su SD","Файл обновления сохранён на SD","Файл оновлення збережено на SD","Файлът за обновяване е записан на SD","Датотека ажурирања сачувана на SD","Το αρχείο ενημέρωσης αποθηκεύτηκε στη SD","Bin de atualização salvo no SD" } }, - { "Update complete.\nRebooting...", { 0, "Update voltooid.\nOpnieuw opstarten...","Update fertig.\nNeustart...","Mise à jour terminée.\nRedémarrage...","Actualización completa.\nReiniciando...","Aggiornamento completato.\nRiavvio...","Обновление завершено.\nПерезагрузка...","Оновлення завершено.\nПерезавантаження...","Обновяването завърши.\nРестартиране...","Ажурирање завршено.\nПоновно покретање...","Η ενημέρωση ολοκληρώθηκε.\nΕπανεκκίνηση...","Atualização concluída.\nReiniciando..." } }, - { "Update via the Launcher", { 0, "Update via de Launcher","Update über den Launcher","Mise à jour via le Launcher","Actualiza vía el Launcher","Aggiorna tramite il Launcher","Обновление через Launcher","Оновлення через Launcher","Обнови през Launcher","Ажурирај преко Launcher-а","Ενημέρωση μέσω του Launcher","Atualize pelo Launcher" } }, - { "Update via the Launcher / flasher.wadamesh.com", { 0, "Update via de Launcher / flasher.wadamesh.com","Update über den Launcher / flasher.wadamesh.com","Mise à jour via le Launcher / flasher.wadamesh.com","Actualiza vía el Launcher / flasher.wadamesh.com","Aggiorna tramite il Launcher / flasher.wadamesh.com","Обновление через Launcher / flasher.wadamesh.com","Оновлення через Launcher / flasher.wadamesh.com","Обнови през Launcher / flasher.wadamesh.com","Ажурирај преко Launcher-а / flasher.wadamesh.com","Ενημέρωση μέσω του Launcher / flasher.wadamesh.com","Atualize pelo Launcher / flasher.wadamesh.com" } }, - { "Update via the Launcher / flasher.wadamesh.com.", { 0, "Update via de Launcher / flasher.wadamesh.com.","Update über den Launcher / flasher.wadamesh.com.","Mise à jour via le Launcher / flasher.wadamesh.com.","Actualiza vía el Launcher / flasher.wadamesh.com.","Aggiorna tramite il Launcher / flasher.wadamesh.com.","Обновление через Launcher / flasher.wadamesh.com.","Оновлення через Launcher / flasher.wadamesh.com.","Обнови през Launcher / flasher.wadamesh.com.","Ажурирај преко Launcher-а / flasher.wadamesh.com.","Ενημέρωση μέσω του Launcher / flasher.wadamesh.com.","Atualize pelo Launcher / flasher.wadamesh.com." } }, - { "Username (optional)", { 0, "Gebruikersnaam (optioneel)","Benutzername (optional)","Nom d'utilisateur (facultatif)","Nombre de usuario (opcional)","Nome utente (facoltativo)","Имя пользователя (необязательно)","Ім'я користувача (необов'язково)","Потребителско име (по избор)","Корисничко име (опционо)","Όνομα χρήστη (προαιρετικό)","Nome de usuário (opcional)" } }, - { "Volume", { 0, "Volume","Lautstärke","Volume","Volumen","Volume","Громкость","Гучність","Сила на звука","Јачина звука","Ένταση","Volume" } }, - { "W up · A left · D right · Z down · S select · Q back", { 0, "W omhoog · A links · D rechts · Z omlaag · S kies · Q terug","W hoch · A links · D rechts · Z runter · S wählen · Q zurück","W haut · A gauche · D droite · Z bas · S choisir · Q retour","W arriba · A izq. · D der. · Z abajo · S elegir · Q atrás","W su · A sinistra · D destra · Z giù · S scegli · Q indietro","W вверх · A влево · D вправо · Z вниз · S выбор · Q назад","W вгору · A вліво · D вправо · Z вниз · S вибір · Q назад","W нагоре · A ляво · D дясно · Z надолу · S избор · Q назад","W горе · A лево · D десно · Z доле · S избор · Q назад","W πάνω · A αριστερά · D δεξιά · Z κάτω · S επιλογή · Q πίσω","W cima · A esq. · D dir. · Z baixo · S selecionar · Q voltar" } }, - { "WADA#15B6A6 MESH#", { 0, "WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#" } }, - { "WADAMESH", { 0, "WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH" } }, - { "Wi-Fi not connected", { 0, "Wi-Fi niet verbonden","Wi-Fi nicht verbunden","Wi-Fi non connecté","Wi-Fi no conectado","Wi-Fi non connesso","Wi-Fi не подключён","Wi-Fi не під'єднано","Wi-Fi не е свързан","Wi-Fi није повезан","Το Wi-Fi δεν συνδέθηκε","Wi-Fi não conectado" } }, - { "Wi-Fi on, scanning…", { 0, "Wi-Fi aan, scannen…","Wi-Fi an, suche…","Wi-Fi activé, recherche…","Wi-Fi activado, buscando…","Wi-Fi attivo, scansione…","Wi-Fi вкл, поиск…","Wi-Fi увімк, пошук…","Wi-Fi вкл, сканиране…","Wi-Fi укључен, тражим…","Wi-Fi ενεργό, σάρωση…","Wi-Fi ligado, buscando…" } }, - { "Yesterday", { 0, "Gisteren","Gestern","Hier","Ayer","Ieri","Вчера","Вчора","Вчера","Јуче","Χθες","Ontem" } }, - { "Zoom: +/- buttons", { 0, "Zoom: +/- knoppen","Zoom: +/- Tasten","Zoom : boutons +/-","Zoom: botones +/-","Zoom: pulsanti +/-","Масштаб: кнопки +/-","Масштаб: кнопки +/-","Мащаб: бутони +/-","Зум: тастери +/-","Ζουμ: κουμπιά +/-","Zoom: botões +/-" } }, - { "client connected", { 0, "client verbonden","Client verbunden","client connecté","cliente conectado","client connesso","клиент подключён","клієнт під'єднано","клиент свързан","клијент повезан","ο πελάτης συνδέθηκε","cliente conectado" } }, - { "command", { 0, "opdracht","Befehl","commande","comando","comando","команда","команда","команда","команда","εντολή","comando" } }, - { "diag boot...", { 0, "diag-boot...","Diag-Boot...","démarrage diag...","arranque diag...","avvio diag...","диаг. загрузка...","діаг. запуск...","диаг. старт...","диаг. покретање...","εκκίνηση διαγν...","boot diag..." } }, - { "drive the UI without the screen; keys still type in text fields", { 0, "bedien de UI zonder scherm; toetsen typen nog in tekstvelden","UI ohne Bildschirm bedienen; Tasten tippen weiter in Textfelder","piloter l'UI sans l'écran ; les touches saisissent le texte","controla la UI sin la pantalla; las teclas siguen escribiendo","usa la UI senza schermo; i tasti scrivono ancora nei campi","управление UI без экрана; клавиши всё ещё печатают в полях","керування UI без екрана; клавіші все ще друкують у полях","управлявай UI без екрана; клавишите още пишат в полетата","управљај UI без екрана; тастери и даље куцају у пољима","χειρισμός UI χωρίς οθόνη· τα πλήκτρα γράφουν στα πεδία","controle a UI sem a tela; as teclas ainda digitam nos campos" } }, - { "from", { 0, "van","von","de","de","da","от","від","от","од","από","de" } }, - { "hold the d-pad to unlock", { 0, "houd de d-pad vast om te ontgrendelen","D-Pad halten zum Entsperren","maintenez le d-pad pour déverrouiller","mantén el d-pad para desbloquear","tieni premuto il d-pad per sbloccare","удерживайте d-pad для разблокировки","утримуйте d-pad для розблокування","задръж d-pad за отключване","држи d-pad за откључавање","κρατήστε το d-pad για ξεκλείδωμα","segure o d-pad para desbloquear" } }, - { "last wake: ", { 0, "laatst wakker: ","Letztes Wecken: ","dernier réveil : ","último despertar: ","ultimo risveglio: ","последнее пробуждение: ","останнє пробудження: ","последно събуждане: ","последње буђење: ","τελευταία αφύπνιση: ","último despertar: " } }, - { "lights the keyboard + wakes the screen on an incoming message", { 0, "verlicht het toetsenbord + wekt het scherm bij een inkomend bericht","beleuchtet die Tastatur + weckt den Bildschirm bei einer Nachricht","éclaire le clavier + réveille l'écran à un message entrant","ilumina el teclado + despierta la pantalla al recibir un mensaje","illumina la tastiera + risveglia lo schermo a un messaggio in arrivo","подсвечивает клавиатуру + будит экран при входящем сообщении","підсвічує клавіатуру + будить екран при вхідному повідомленні","осветява клавиатурата + събужда екрана при входящо съобщение","осветљава тастатуру + буди екран при долазној поруци","φωτίζει το πληκτρολόγιο + ξυπνά την οθόνη σε εισερχόμενο μήνυμα","acende o teclado + acorda a tela ao receber uma mensagem" } }, - { "mesh busy", { 0, "mesh bezig","Mesh belegt","mesh occupé","malla ocupada","mesh occupata","сеть занята","мережа зайнята","мрежата заета","мрежа заузета","το mesh απασχολημένο","malha ocupada" } }, - { "off = Enter adds a new line; tap Send to send", { 0, "uit = Enter voegt een regel toe; tik Verzenden om te versturen","aus = Enter fügt eine neue Zeile ein; auf Senden tippen","off = Entrée ajoute une ligne ; touchez Envoyer pour envoyer","off = Enter añade una línea nueva; toca Enviar para enviar","off = Invio aggiunge una riga; tocca Invia per inviare","выкл = Enter добавляет строку; нажмите Отправить","вимк = Enter додає рядок; натисніть Надіслати","изкл = Enter добавя нов ред; докосни Изпрати","искључено = Enter додаје нови ред; додирни Пошаљи","off = το Enter προσθέτει γραμμή· πατήστε Αποστολή","off = Enter adiciona nova linha; toque Enviar" } }, - { "peak --", { 0, "piek --","Spitze --","crête --","pico --","picco --","пик --","пік --","пик --","врх --","κορυφή --","pico --" } }, - { "press Volume Down to unlock", { 0, "druk Volume Omlaag om te ontgrendelen","Lautstärke leiser drücken zum Entsperren","appuyez sur Volume Bas pour déverrouiller","pulsa Bajar Volumen para desbloquear","premi Volume Giù per sbloccare","нажмите Громкость вниз для разблокировки","натисніть Гучність вниз для розблокування","натисни Намаляване на звука за отключване","притисни Смањи звук за откључавање","πατήστε Μείωση έντασης για ξεκλείδωμα","pressione Diminuir Volume para desbloquear" } }, - { "push past a screen edge to scroll content", { 0, "duw voorbij een schermrand om te scrollen","über den Bildschirmrand schieben zum Scrollen","poussez au-delà du bord pour faire défiler","empuja más allá del borde para desplazar","spingi oltre il bordo per scorrere","толкните за край экрана для прокрутки","штовхніть за край екрана для прокрутки","избутай отвъд ръба на екрана за превъртане","гурни преко ивице екрана за померање","σπρώξτε πέρα από την άκρη για κύλιση","empurre além da borda para rolar" } }, - { "score %d", { 0, "score %d","Punkte %d","score %d","puntos %d","punteggio %d","счёт %d","рахунок %d","точки %d","резултат %d","σκορ %d","placar %d" } }, - { "tap for details", { 0, "tik voor details","für Details tippen","touchez pour les détails","toca para detalles","tocca per dettagli","коснитесь для деталей","торкніться для деталей","докосни за детайли","додирни за детаље","πατήστε για λεπτομέρειες","toque para detalhes" } }, - { "zoom", { 0, "zoom","Zoom","zoom","zoom","zoom","масштаб","масштаб","мащаб","зум","ζουμ","zoom" } }, - { "zoom %d", { 0, "zoom %d","Zoom %d","zoom %d","zoom %d","zoom %d","масштаб %d","масштаб %d","мащаб %d","зум %d","ζουμ %d","zoom %d" } }, - { "~17 dB amp for quiet/remote areas; turn off in noisy spots.", { 0, "~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken.","~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus.","ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante.","amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas.","ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose.","усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных.","підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих.","усилвател ~17 dB за тихи/отдалечени места; изключи в шумни.","појачало ~17 dB за тиха/удаљена места; искључи у бучним.","ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις.","amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos." } }, - { "© OpenStreetMap", { 0, "© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap" } }, - { "© OpenTopoMap", { 0, "© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap" } }, + { "Multi-ack %s · Repeat %s · RX boost %s", { 0, "Multi-ack %s · Herhalen %s · RX-boost %s","Multi-Ack %s · Wdh. %s · RX-Boost %s","Multi-ack %s · Répéter %s · RX boost %s","Multi-ack %s · Repetir %s · RX boost %s","Multi-ack %s · Ripeti %s · RX boost %s","Мульти-ack %s · Повтор %s · RX boost %s","Мульти-ack %s · Повтор %s · RX boost %s","Мулти-ack %s · Повтор %s · RX boost %s","Мулти-ack %s · Понови %s · RX boost %s","Multi-ack %s · Επανάλ. %s · RX boost %s","Multi-ack %s · Repetir %s · RX boost %s","Multi-ack %s · Repetare %s · Boost RX %s" } }, + { "Mute @", { 0, "Demp @","@ stumm","Muet @","Silenciar @","Muto @","Без звука @","Без звуку @","Заглуши @","Утишај @","Σίγαση @","Silenciar @","Fără sunet @" } }, + { "Mute msgs", { 0, "Berichten dempen","Nachr. stumm","Muet msgs","Silenciar msjs","Muto msg","Заглушить сообщ.","Вимкнути звук повід.","Заглуши съобщ.","Утишај поруке","Σίγαση μην.","Silenciar msgs","Fără sunet mesaje" } }, + { "Mute notification sound", { 0, "Meldingsgeluid dempen","Benachrichtigungston stumm","Couper le son des notifications","Silenciar sonido de notificación","Silenzia suono notifiche","Отключить звук уведомлений","Вимкнути звук сповіщень","Заглуши звука на известията","Утишај звук обавештења","Σίγαση ήχου ειδοποιήσεων","Silenciar som de notificação","Dezactivează sunetul notificărilor" } }, + { "My position (no GPS fix)", { 0, "Mijn positie (geen GPS-fix)","Meine Position (kein GPS-Fix)","Ma position (pas de fix GPS)","Mi posición (sin fijación GPS)","La mia posizione (nessun fix GPS)","Моё положение (нет GPS-фикса)","Моє положення (немає GPS-фікса)","Моята позиция (няма GPS фиксация)","Моја позиција (нема GPS фиксације)","Η θέση μου (χωρίς στίγμα GPS)","Minha posição (sem fixação GPS)","Poziţia mea (fără fix GPS)" } }, + { "Navigation keys", { 0, "Navigatietoetsen","Navigationstasten","Touches de navigation","Teclas de navegación","Tasti di navigazione","Клавиши навигации","Клавіші навігації","Навигационни клавиши","Тастери за навигацију","Πλήκτρα πλοήγησης","Teclas de navegação","Taste de navigare" } }, + { "Navigation keys — tap a row, then press a key", { 0, "Navigatietoetsen — tik een rij, druk dan een toets","Navigationstasten — Zeile antippen, dann Taste drücken","Touches de navigation — touchez une ligne, puis une touche","Teclas de navegación — toca una fila, luego pulsa una tecla","Tasti di navigazione — tocca una riga, poi premi un tasto","Клавиши навигации — коснитесь строки, затем нажмите клавишу","Клавіші навігації — торкніться рядка, потім натисніть клавішу","Навигационни клавиши — докоснете ред, после натиснете клавиш","Тастери за навигацију — додирните ред, па притисните тастер","Πλήκτρα πλοήγησης — πατήστε μια γραμμή, μετά ένα πλήκτρο","Teclas de navegação — toque uma linha, depois pressione uma tecla","Taste de navigare — atinge un rând, apoi apasă o tastă" } }, + { "Night mode (invert)", { 0, "Nachtmodus (omkeren)","Nachtmodus (invertieren)","Mode nuit (inverser)","Modo noche (invertir)","Modalità notte (inverti)","Ночной режим (инверсия)","Нічний режим (інверсія)","Нощен режим (инвертиране)","Ноћни режим (инвертуј)","Νυχτερινή λειτουργία (αντιστροφή)","Modo noturno (inverter)","Mod nocturn (inversare)" } }, + { "No @mentions yet", { 0, "Nog geen @vermeldingen","Noch keine @Erwähnungen","Aucune @mention pour l'instant","Aún no hay @menciones","Ancora nessuna @menzione","Пока нет @упоминаний","Поки що немає @згадок","Още няма @споменавания","Још нема @помена","Καμία @αναφορά ακόμη","Ainda sem @menções","Nicio menţiune @ încă" } }, + { "No GPS module on this device.", { 0, "Geen GPS-module op dit apparaat.","Kein GPS-Modul auf diesem Gerät.","Aucun module GPS sur cet appareil.","Sin módulo GPS en este dispositivo.","Nessun modulo GPS su questo dispositivo.","На этом устройстве нет модуля GPS.","На цьому пристрої немає модуля GPS.","Няма GPS модул на това устройство.","Нема GPS модула на овом уређају.","Χωρίς μονάδα GPS σε αυτή τη συσκευή.","Sem módulo GPS neste dispositivo.","Fără modul GPS pe acest dispozitiv." } }, + { "No SD card", { 0, "Geen SD-kaart","Keine SD-Karte","Pas de carte SD","Sin tarjeta SD","Nessuna scheda SD","Нет SD-карты","Немає SD-карти","Няма SD карта","Нема SD картице","Χωρίς κάρτα SD","Sem cartão SD","Fără card SD" } }, + { "No SD card (or unreadable format)", { 0, "Geen SD-kaart (of onleesbaar formaat)","Keine SD-Karte (oder unlesbares Format)","Pas de carte SD (ou format illisible)","Sin tarjeta SD (o formato ilegible)","Nessuna scheda SD (o formato illeggibile)","Нет SD-карты (или нечитаемый формат)","Немає SD-карти (або нечитабельний формат)","Няма SD карта (или нечетим формат)","Нема SD картице (или нечитљив формат)","Χωρίς κάρτα SD (ή μη αναγνώσιμη μορφή)","Sem cartão SD (ou formato ilegível)","Fără card SD (sau format ilizibil)" } }, + { "No battery history yet.\n\nLogged every 5 minutes.", { 0, "Nog geen batterijgeschiedenis.\n\nElke 5 minuten gelogd.","Noch kein Akkuverlauf.\n\nAlle 5 Minuten protokolliert.","Pas encore d'historique de batterie.\n\nEnregistré toutes les 5 minutes.","Aún no hay historial de batería.\n\nRegistrado cada 5 minutos.","Ancora nessuno storico batteria.\n\nRegistrato ogni 5 minuti.","Истории батареи пока нет.\n\nЗапись каждые 5 минут.","Історії батареї ще немає.\n\nЗапис кожні 5 хвилин.","Все още няма история на батерията.\n\nЗапис на всеки 5 минути.","Још нема историје батерије.\n\nБележи се сваких 5 минута.","Δεν υπάρχει ιστορικό μπαταρίας.\n\nΚαταγραφή κάθε 5 λεπτά.","Ainda sem histórico da bateria.\n\nRegistrado a cada 5 minutos.","Niciun istoric al bateriei încă.\n\nSe înregistrează la 5 minute." } }, + { "No discovered nodes yet.\n\nAnything heard over the air that isn't in your contacts will show up here. When auto-add is off, you can manually add nodes to your contacts.", { 0, "Nog geen ontdekte nodes.\n\nAlles wat via de ether wordt gehoord en niet in je contacten staat, verschijnt hier. Als automatisch toevoegen uit staat, kun je nodes handmatig aan je contacten toevoegen.","Noch keine entdeckten Nodes.\n\nAlles über Funk Gehörte, das nicht in deinen Kontakten steht, erscheint hier. Wenn Auto-Hinzufügen aus ist, kannst du Nodes manuell zu deinen Kontakten hinzufügen.","Aucun nœud découvert pour l'instant.\n\nTout ce qui est entendu par radio et qui n'est pas dans vos contacts apparaît ici. Si l'ajout auto est désactivé, vous pouvez ajouter des nœuds manuellement.","Aún no hay nodos descubiertos.\n\nTodo lo que se oiga por radio y no esté en tus contactos aparecerá aquí. Si el auto-añadir está desactivado, puedes añadir nodos manualmente a tus contactos.","Ancora nessun nodo scoperto.\n\nTutto ciò che si sente via radio e non è nei tuoi contatti apparirà qui. Se l'aggiunta automatica è disattivata, puoi aggiungere nodi manualmente.","Обнаруженных узлов пока нет.\n\nВсё услышанное в эфире, чего нет в контактах, появится здесь. Когда автодобавление выключено, узлы можно добавить в контакты вручную.","Виявлених вузлів ще немає.\n\nУсе почуте в ефірі, чого немає в контактах, з'явиться тут. Коли автододавання вимкнено, вузли можна додати до контактів вручну.","Все още няма открити възли.\n\nВсичко чуто в ефира, което не е в контактите ви, ще се появи тук. Когато авто-добавянето е изключено, можете да добавяте възли ръчно.","Још нема откривених чворова.\n\nСве чуто у етру што није у контактима појавиће се овде. Када је аутоматско додавање искључено, чворове можете додати ручно.","Δεν βρέθηκαν κόμβοι ακόμη.\n\nΟτιδήποτε ακούγεται στον αέρα και δεν είναι στις επαφές σας θα εμφανιστεί εδώ. Όταν η αυτόματη προσθήκη είναι κλειστή, μπορείτε να προσθέσετε κόμβους χειροκίνητα.","Ainda sem nós descobertos.\n\nTudo ouvido pelo rádio que não esteja nos seus contatos aparecerá aqui. Quando a adição automática está desligada, você pode adicionar nós manualmente.","Niciun nod descoperit încă.\n\nTot ce se aude prin eter şi nu e în contactele tale apare aici. Când auto-adăugarea e oprită, poţi adăuga manual noduri la contacte." } }, + { "No earlier versions yet", { 0, "Nog geen eerdere versies","Noch keine früheren Versionen","Aucune version antérieure","Aún no hay versiones anteriores","Ancora nessuna versione precedente","Ранних версий пока нет","Ранніх версій ще немає","Все още няма по-ранни версии","Још нема ранијих верзија","Δεν υπάρχουν προηγούμενες εκδόσεις","Ainda sem versões anteriores","Nicio versiune anterioară încă" } }, + { "No local expansion data", { 0, "Geen lokale uitbreidingsdata","Keine lokalen Erweiterungsdaten","Aucune donnée d'extension locale","Sin datos de expansión local","Nessun dato di espansione locale","Нет локальных данных расширения","Немає локальних даних розширення","Няма локални данни за разширение","Нема локалних података о проширењу","Δεν υπάρχουν τοπικά δεδομένα επέκτασης","Sem dados de expansão local","Fără date locale de extensie" } }, + { "No location for this contact", { 0, "Geen locatie voor dit contact","Kein Standort für diesen Kontakt","Aucune position pour ce contact","Sin ubicación para este contacto","Nessuna posizione per questo contatto","Нет местоположения для этого контакта","Немає розташування для цього контакту","Няма местоположение за този контакт","Нема локације за овај контакт","Καμία τοποθεσία για αυτήν την επαφή","Sem localização para este contato","Fără locaţie pentru acest contact" } }, + { "No map tiles here.\n\nConnect to Wi-Fi (Settings → Wi-Fi)\nto download this area.", { 0, "Hier geen kaarttegels.\n\nMaak verbinding met Wi-Fi (Instellingen → Wi-Fi)\nom dit gebied te downloaden.","Hier keine Kartenkacheln.\n\nMit Wi-Fi verbinden (Einstellungen → Wi-Fi),\num dieses Gebiet zu laden.","Aucune tuile ici.\n\nConnectez-vous au Wi-Fi (Réglages → Wi-Fi)\npour télécharger cette zone.","No hay teselas aquí.\n\nConéctate a Wi-Fi (Ajustes → Wi-Fi)\npara descargar esta zona.","Nessuna tessera qui.\n\nConnettiti al Wi-Fi (Impostazioni → Wi-Fi)\nper scaricare quest'area.","Здесь нет тайлов карты.\n\nПодключитесь к Wi-Fi (Настройки → Wi-Fi),\nчтобы загрузить этот район.","Тут немає плиток карти.\n\nПідключіться до Wi-Fi (Налаштування → Wi-Fi),\nщоб завантажити цю ділянку.","Тук няма плочки на картата.\n\nСвържете се с Wi-Fi (Настройки → Wi-Fi),\nза да изтеглите този район.","Овде нема плочица карте.\n\nПовежите се на Wi-Fi (Подешавања → Wi-Fi)\nда преузмете ову област.","Δεν υπάρχουν πλακίδια εδώ.\n\nΣυνδεθείτε στο Wi-Fi (Ρυθμίσεις → Wi-Fi)\nγια λήψη αυτής της περιοχής.","Sem blocos de mapa aqui.\n\nConecte-se ao Wi-Fi (Ajustes → Wi-Fi)\npara baixar esta área.","Fără dale de hartă aici.\n\nConectează Wi-Fi (Setări → Wi-Fi)\npentru a descărca această zonă." } }, + { "No mapped repeaters in this route", { 0, "Geen repeaters op de kaart in deze route","Keine kartierten Repeater auf dieser Route","Aucun répéteur cartographié sur cet itinéraire","Sin repetidores mapeados en esta ruta","Nessun ripetitore mappato in questa rotta","Нет нанесённых ретрансляторов на этом маршруте","Немає нанесених ретрансляторів на цьому маршруті","Няма картографирани ретранслатори по този маршрут","Нема мапираних репетитора на овој рути","Δεν υπάρχουν χαρτογραφημένοι αναμεταδότες σε αυτή τη διαδρομή","Sem repetidores mapeados nesta rota","Niciun repetor cartografiat pe această rută" } }, + { "No onboard GPS", { 0, "Geen ingebouwde GPS","Kein integriertes GPS","Pas de GPS intégré","Sin GPS integrado","Nessun GPS integrato","Нет встроенного GPS","Немає вбудованого GPS","Няма вграден GPS","Нема уграђеног GPS","Χωρίς ενσωματωμένο GPS","Sem GPS integrado","Fără GPS integrat" } }, + { "No other networks found", { 0, "Geen andere netwerken gevonden","Keine anderen Netzwerke gefunden","Aucun autre réseau trouvé","No se encontraron otras redes","Nessun'altra rete trovata","Другие сети не найдены","Інших мереж не знайдено","Не са открити други мрежи","Нема других мрежа","Δεν βρέθηκαν άλλα δίκτυα","Nenhuma outra rede encontrada","Nicio altă reţea găsită" } }, + { "No reply. Check password, server,\nor device clock (device: %s)", { 0, "Geen antwoord. Controleer wachtwoord, server,\nof apparaatklok (apparaat: %s)","Keine Antwort. Prüfe Passwort, Server\noder Geräteuhr (Gerät: %s)","Pas de réponse. Vérifiez mot de passe, serveur\nou horloge de l'appareil (appareil : %s)","Sin respuesta. Revisa contraseña, servidor\no reloj del dispositivo (dispositivo: %s)","Nessuna risposta. Controlla password, server\no orologio del dispositivo (dispositivo: %s)","Нет ответа. Проверьте пароль, сервер\nили часы устройства (устройство: %s)","Немає відповіді. Перевірте пароль, сервер\nабо годинник пристрою (пристрій: %s)","Няма отговор. Проверете паролата, сървъра\nили часовника на устройството (устройство: %s)","Нема одговора. Проверите лозинку, сервер\nили сат уређаја (уређај: %s)","Καμία απάντηση. Ελέγξτε κωδικό, διακομιστή\nή ρολόι συσκευής (συσκευή: %s)","Sem resposta. Verifique senha, servidor\nou relógio do dispositivo (dispositivo: %s)","Niciun răspuns. Verifică parola, serverul\nsau ceasul dispozitivului (dispozitiv: %s)" } }, + { "No saved map tiles here.\n\nConnect to Wi-Fi (Settings → Wi-Fi)\nto download this area.\nSaved tiles stay available offline.", { 0, "Hier geen opgeslagen kaarttegels.\n\nMaak verbinding met Wi-Fi (Instellingen → Wi-Fi)\nom dit gebied te downloaden.\nOpgeslagen tegels blijven offline beschikbaar.","Hier keine gespeicherten Kacheln.\n\nMit Wi-Fi verbinden (Einstellungen → Wi-Fi),\num dieses Gebiet zu laden.\nGespeicherte Kacheln bleiben offline verfügbar.","Aucune tuile enregistrée ici.\n\nConnectez-vous au Wi-Fi (Réglages → Wi-Fi)\npour télécharger cette zone.\nLes tuiles enregistrées restent hors ligne.","No hay teselas guardadas aquí.\n\nConéctate a Wi-Fi (Ajustes → Wi-Fi)\npara descargar esta zona.\nLas teselas guardadas quedan disponibles sin conexión.","Nessuna tessera salvata qui.\n\nConnettiti al Wi-Fi (Impostazioni → Wi-Fi)\nper scaricare quest'area.\nLe tessere salvate restano disponibili offline.","Здесь нет сохранённых тайлов.\n\nПодключитесь к Wi-Fi (Настройки → Wi-Fi),\nчтобы загрузить этот район.\nСохранённые тайлы остаются доступны офлайн.","Тут немає збережених плиток.\n\nПідключіться до Wi-Fi (Налаштування → Wi-Fi),\nщоб завантажити цю ділянку.\nЗбережені плитки лишаються доступні офлайн.","Тук няма запазени плочки.\n\nСвържете се с Wi-Fi (Настройки → Wi-Fi),\nза да изтеглите този район.\nЗапазените плочки остават достъпни офлайн.","Овде нема сачуваних плочица.\n\nПовежите се на Wi-Fi (Подешавања → Wi-Fi)\nда преузмете ову област.\nСачуване плочице остају доступне ван мреже.","Δεν υπάρχουν αποθηκευμένα πλακίδια εδώ.\n\nΣυνδεθείτε στο Wi-Fi (Ρυθμίσεις → Wi-Fi)\nγια λήψη αυτής της περιοχής.\nΤα αποθηκευμένα πλακίδια μένουν διαθέσιμα εκτός σύνδεσης.","Sem blocos salvos aqui.\n\nConecte-se ao Wi-Fi (Ajustes → Wi-Fi)\npara baixar esta área.\nOs blocos salvos ficam disponíveis offline.","Fără dale salvate aici.\n\nConectează Wi-Fi (Setări → Wi-Fi)\npentru a descărca zona.\nDalele salvate rămân offline." } }, + { "No telemetry in this window.\nRequest one, or widen the range below.", { 0, "Geen telemetrie in dit venster.\nVraag er een aan, of verbreed het bereik hieronder.","Keine Telemetrie in diesem Fenster.\nFordere welche an oder erweitere unten den Bereich.","Aucune télémétrie dans cette fenêtre.\nDemandez-en une ou élargissez la plage ci-dessous.","Sin telemetría en esta ventana.\nSolicita una o amplía el rango abajo.","Nessuna telemetria in questa finestra.\nRichiedine una o amplia l'intervallo sotto.","Нет телеметрии в этом окне.\nЗапросите её или расширьте диапазон ниже.","Немає телеметрії в цьому вікні.\nЗапросіть її або розширте діапазон нижче.","Няма телеметрия в този прозорец.\nПоискайте или разширете обхвата по-долу.","Нема телеметрије у овом прозору.\nЗатражите је или проширите опсег испод.","Δεν υπάρχει τηλεμετρία σε αυτό το παράθυρο.\nΖητήστε μία ή διευρύνετε το εύρος παρακάτω.","Sem telemetria nesta janela.\nSolicite uma ou amplie o intervalo abaixo.","Fără telemetrie în acest interval.\nCere una sau lărgeşte intervalul de mai jos." } }, + { "No version known yet. Wi-Fi and a completed update check are needed first.", { 0, "Nog geen versie bekend. Eerst zijn Wi-Fi en een voltooide updatecontrole nodig.","Noch keine Version bekannt. Zuerst sind Wi-Fi und eine abgeschlossene Update-Prüfung nötig.","Aucune version connue. Le Wi-Fi et une vérification de mise à jour terminée sont d'abord requis.","Aún no se conoce versión. Primero se necesitan Wi-Fi y una comprobación de actualización completada.","Nessuna versione nota. Servono prima il Wi-Fi e un controllo aggiornamenti completato.","Версия пока неизвестна. Сначала нужны Wi-Fi и завершённая проверка обновлений.","Версія поки невідома. Спершу потрібні Wi-Fi та завершена перевірка оновлень.","Все още няма известна версия. Първо са нужни Wi-Fi и завършена проверка за обновления.","Верзија још није позната. Прво су потребни Wi-Fi и завршена провера ажурирања.","Καμία γνωστή έκδοση ακόμη. Χρειάζονται πρώτα Wi-Fi και ολοκληρωμένος έλεγχος ενημέρωσης.","Ainda sem versão conhecida. Primeiro são necessários Wi-Fi e uma verificação de atualização concluída.","Nicio versiune cunoscută încă. E nevoie mai întâi de Wi-Fi şi de o verificare de actualizare completă." } }, + { "Node\nRegion\nRadio\nSignal\nContacts\nChannels\nBattery\nUptime", { 0, "Node\nRegio\nRadio\nSignaal\nContacten\nKanalen\nBatterij\nBedrijfstijd","Node\nRegion\nRadio\nSignal\nKontakte\nKanäle\nAkku\nLaufzeit","Nœud\nRégion\nRadio\nSignal\nContacts\nCanaux\nBatterie\nDisponibilité","Nodo\nRegión\nRadio\nSeñal\nContactos\nCanales\nBatería\nActividad","Nodo\nRegione\nRadio\nSegnale\nContatti\nCanali\nBatteria\nAttività","Узел\nРегион\nРадио\nСигнал\nКонтакты\nКаналы\nБатарея\nВремя работы","Вузол\nРегіон\nРадіо\nСигнал\nКонтакти\nКанали\nБатарея\nЧас роботи","Възел\nРегион\nРадио\nСигнал\nКонтакти\nКанали\nБатерия\nВреме на работа","Чвор\nРегион\nРадио\nСигнал\nКонтакти\nКанали\nБатерија\nВреме рада","Κόμβος\nΠεριοχή\nΡαδιό\nΣήμα\nΕπαφές\nΚανάλια\nΜπαταρία\nΧρόνος λειτουργίας","Nó\nRegião\nRádio\nSinal\nContatos\nCanais\nBateria\nAtividade","Nod\nRegiune\nRadio\nSemnal\nContacte\nCanale\nBaterie\nTimp de funcţionare" } }, + { "Normal (100%)\nLarge (150%)\nHuge (200%)", { 0, "Normaal (100%)\nGroot (150%)\nEnorm (200%)","Normal (100%)\nGroß (150%)\nRiesig (200%)","Normal (100%)\nGrand (150%)\nÉnorme (200%)","Normal (100%)\nGrande (150%)\nEnorme (200%)","Normale (100%)\nGrande (150%)\nEnorme (200%)","Обычный (100%)\nБольшой (150%)\nОгромный (200%)","Звичайний (100%)\nВеликий (150%)\nВеличезний (200%)","Нормален (100%)\nГолям (150%)\nОгромен (200%)","Нормалан (100%)\nВелик (150%)\nОгроман (200%)","Κανονικό (100%)\nΜεγάλο (150%)\nΤεράστιο (200%)","Normal (100%)\nGrande (150%)\nEnorme (200%)","Normal (100%)\nMare (150%)\nFoarte mare (200%)" } }, + { "Not enough free memory for Bluetooth. Turn Wi-Fi off first.", { 0, "Niet genoeg vrij geheugen voor Bluetooth. Schakel eerst Wi-Fi uit.","Nicht genug freier Speicher für Bluetooth. Schalte zuerst Wi-Fi aus.","Mémoire insuffisante pour le Bluetooth. Désactivez d'abord le Wi-Fi.","No hay memoria suficiente para Bluetooth. Desactiva primero el Wi-Fi.","Memoria insufficiente per il Bluetooth. Disattiva prima il Wi-Fi.","Недостаточно памяти для Bluetooth. Сначала выключите Wi-Fi.","Недостатньо пам'яті для Bluetooth. Спершу вимкніть Wi-Fi.","Недостатъчно памет за Bluetooth. Първо изключете Wi-Fi.","Нема довољно меморије за Bluetooth. Прво искључите Wi-Fi.","Ανεπαρκής μνήμη για Bluetooth. Απενεργοποιήστε πρώτα το Wi-Fi.","Memória insuficiente para Bluetooth. Desligue o Wi-Fi primeiro.","Memorie liberă insuficientă pentru Bluetooth. Opreşte mai întâi Wi-Fi." } }, + { "Not enough free memory for Wi-Fi. Turn Bluetooth off first.", { 0, "Niet genoeg vrij geheugen voor Wi-Fi. Schakel eerst Bluetooth uit.","Nicht genug freier Speicher für Wi-Fi. Schalte zuerst Bluetooth aus.","Mémoire insuffisante pour le Wi-Fi. Désactivez d'abord le Bluetooth.","No hay memoria suficiente para Wi-Fi. Desactiva primero el Bluetooth.","Memoria insufficiente per il Wi-Fi. Disattiva prima il Bluetooth.","Недостаточно памяти для Wi-Fi. Сначала выключите Bluetooth.","Недостатньо пам'яті для Wi-Fi. Спершу вимкніть Bluetooth.","Недостатъчно памет за Wi-Fi. Първо изключете Bluetooth.","Нема довољно меморије за Wi-Fi. Прво искључите Bluetooth.","Ανεπαρκής μνήμη για Wi-Fi. Απενεργοποιήστε πρώτα το Bluetooth.","Memória insuficiente para Wi-Fi. Desligue o Bluetooth primeiro.","Memorie liberă insuficientă pentru Wi-Fi. Opreşte mai întâi Bluetooth." } }, + { "Nothing selected", { 0, "Niets geselecteerd","Nichts ausgewählt","Rien de sélectionné","Nada seleccionado","Niente selezionato","Ничего не выбрано","Нічого не вибрано","Нищо не е избрано","Ништа није изабрано","Τίποτα δεν επιλέχθηκε","Nada selecionado","Nimic selectat" } }, + { "Notification sound set", { 0, "Meldingsgeluid ingesteld","Benachrichtigungston festgelegt","Son de notification défini","Sonido de notificación establecido","Suono di notifica impostato","Звук уведомления задан","Звук сповіщення встановлено","Звукът за известие е зададен","Звук обавештења постављен","Ήχος ειδοποίησης ορίστηκε","Som de notificação definido","Sunet de notificare setat" } }, + { "Notify on new contact", { 0, "Melden bij nieuw contact","Bei neuem Kontakt benachrichtigen","Notifier pour un nouveau contact","Avisar con nuevo contacto","Avvisa a nuovo contatto","Уведомлять о новом контакте","Сповіщати про новий контакт","Известяване при нов контакт","Обавести о новом контакту","Ειδοποίηση για νέα επαφή","Notificar novo contato","Notifică la contact nou" } }, + { "On boot", { 0, "Bij opstarten","Beim Start","Au démarrage","Al arrancar","All'avvio","При запуске","При запуску","При стартиране","При покретању","Στην εκκίνηση","Ao iniciar","La pornire" } }, + { "Open Advertise app", { 0, "Advertentie-app openen","Advertise-App öffnen","Ouvrir l'appli Advertise","Abrir la app Advertise","Apri l'app Advertise","Открыть приложение Advertise","Відкрити застосунок Advertise","Отвори приложението Advertise","Отвори апликацију Advertise","Άνοιγμα εφαρμογής Advertise","Abrir o app Advertise","Deschide aplicaţia Advertise" } }, + { "Open a .wav, then tap 'Set as notification sound'", { 0, "Open een .wav, tik dan op 'Instellen als meldingsgeluid'","Öffne eine .wav, dann auf 'Als Benachrichtigungston festlegen' tippen","Ouvrez un .wav, puis touchez « Définir comme son de notification »","Abre un .wav y toca 'Establecer como sonido de notificación'","Apri un .wav, poi tocca 'Imposta come suono di notifica'","Откройте .wav, затем нажмите «Задать как звук уведомления»","Відкрийте .wav, потім торкніться «Встановити як звук сповіщення»","Отворете .wav, после докоснете 'Задай като звук за известие'","Отворите .wav, па додирните 'Постави као звук обавештења'","Ανοίξτε ένα .wav και πατήστε «Ορισμός ως ήχος ειδοποίησης»","Abra um .wav e toque em 'Definir como som de notificação'","Deschide un .wav, apoi atinge 'Setează ca sunet de notificare'" } }, + { "Open a JPEG, then tap 'Set as wallpaper'", { 0, "Open een JPEG, tik dan op 'Instellen als achtergrond'","Öffne ein JPEG, dann auf 'Als Hintergrund festlegen' tippen","Ouvrez un JPEG, puis touchez « Définir comme fond d'écran »","Abre un JPEG y toca 'Establecer como fondo'","Apri un JPEG, poi tocca 'Imposta come sfondo'","Откройте JPEG, затем нажмите «Задать как обои»","Відкрийте JPEG, потім торкніться «Встановити як шпалери»","Отворете JPEG, после докоснете 'Задай като тапет'","Отворите JPEG, па додирните 'Постави као позадину'","Ανοίξτε ένα JPEG και πατήστε «Ορισμός ως ταπετσαρία»","Abra um JPEG e toque em 'Definir como papel de parede'","Deschide un JPEG, apoi atinge 'Setează ca fundal'" } }, + { "Open mesh console", { 0, "Mesh-console openen","Mesh-Konsole öffnen","Ouvrir la console mesh","Abrir consola de la malla","Apri console mesh","Открыть консоль mesh","Відкрити консоль mesh","Отвори mesh конзолата","Отвори mesh конзолу","Άνοιγμα κονσόλας mesh","Abrir console da malha","Deschide consola mesh" } }, + { "Orange temp Cyan humidity", { 0, "Oranje temp Cyaan vocht","Orange Temp Cyan Feuchte","Orange temp. Cyan humidité","Naranja temp. Cian humedad","Arancione temp. Ciano umidità","Оранжевый темп. Голубой влажн.","Помаранч. темп. Блакитн. волог.","Оранжево темп. Циан влажн.","Наранџаста темп. Цијан влага","Πορτοκαλί θερμ. Κυανό υγρασία","Laranja temp. Ciano umidade","Portocaliu temp Cyan umiditate" } }, + { "Overwrite the SD card's settings and\nidentity with the internal copies,\nthen reboot?", { 0, "Instellingen en identiteit van de SD-kaart\noverschrijven met de interne kopieën\nen dan herstarten?","Einstellungen und Identität der SD-Karte\nmit den internen Kopien überschreiben\nund dann neu starten?","Écraser les réglages et l'identité de la carte SD\navec les copies internes,\npuis redémarrer ?","¿Sobrescribir los ajustes e identidad de la SD\ncon las copias internas\ny reiniciar?","Sovrascrivere impostazioni e identità della SD\ncon le copie interne\ne poi riavviare?","Перезаписать настройки и идентичность SD-карты\nвнутренними копиями\nи перезагрузить?","Перезаписати налаштування й ідентичність SD-картки\nвнутрішніми копіями\nта перезавантажити?","Да се презапишат настройките и идентичността на SD\nс вътрешните копия\nи да се рестартира?","Пребрисати подешавања и идентитет SD картице\nунутрашњим копијама\nи затим рестартовати?","Αντικατάσταση ρυθμίσεων και ταυτότητας κάρτας SD\nμε τα εσωτερικά αντίγραφα\nκαι επανεκκίνηση;","Sobrescrever as configurações e a identidade do SD\ncom as cópias internas\ne reiniciar?","Suprascrii setările şi identitatea\nde pe SD cu copiile interne,\napoi reporneşti?" } }, + { "Pairing code", { 0, "Koppelcode","Kopplungscode","Code d'appairage","Código de emparejamiento","Codice di accoppiamento","Код сопряжения","Код сполучення","Код за сдвояване","Код за упаривање","Κωδικός σύζευξης","Código de pareamento","Cod de asociere" } }, + { "Pairing code saved — reboot to apply", { 0, "Koppelcode opgeslagen — herstart om toe te passen","Kopplungscode gespeichert — Neustart zum Anwenden","Code d'appairage enregistré — redémarrez pour appliquer","Código de emparejamiento guardado — reinicia para aplicar","Codice di accoppiamento salvato — riavvia per applicare","Код сопряжения сохранён — перезагрузите для применения","Код сполучення збережено — перезавантажте для застосування","Кодът за сдвояване е запазен — рестартирайте за прилагане","Код за упаривање сачуван — рестартујте да примените","Ο κωδικός σύζευξης αποθηκεύτηκε — επανεκκίνηση για εφαρμογή","Código de pareamento salvo — reinicie para aplicar","Cod de asociere salvat — reporneşte pentru aplicare" } }, + { "Password (optional)", { 0, "Wachtwoord (optioneel)","Passwort (optional)","Mot de passe (facultatif)","Contraseña (opcional)","Password (facoltativa)","Пароль (необязательно)","Пароль (необов'язково)","Парола (по избор)","Лозинка (опционо)","Κωδικός (προαιρετικό)","Senha (opcional)","Parolă (opţională)" } }, + { "Paused — score %d", { 0, "Gepauzeerd — score %d","Pausiert — Punkte %d","En pause — score %d","En pausa — puntuación %d","In pausa — punteggio %d","Пауза — счёт %d","Пауза — рахунок %d","На пауза — резултат %d","Паузирано — резултат %d","Σε παύση — σκορ %d","Pausado — pontuação %d","Pauză — scor %d" } }, + { "Poll (min)", { 0, "Ophalen (min)","Abfrage (Min)","Interrogation (min)","Sondeo (min)","Sondaggio (min)","Опрос (мин)","Опитування (хв)","Запитване (мин)","Провера (мин)","Δημοσκόπηση (λεπτά)","Consulta (min)","Interogare (min)" } }, + { "Poll every", { 0, "Ophalen elke","Abfrage alle","Interroger toutes les","Sondear cada","Sondaggio ogni","Опрашивать каждые","Опитувати кожні","Запитване на всеки","Провери сваких","Δημοσκόπηση κάθε","Consultar a cada","Interogare la fiecare" } }, + { "Poll every %d min", { 0, "Ophalen elke %d min","Abfrage alle %d Min","Interroger toutes les %d min","Sondear cada %d min","Sondaggio ogni %d min","Опрос каждые %d мин","Опитування кожні %d хв","Запитване на всеки %d мин","Провери сваких %d мин","Δημοσκόπηση κάθε %d λεπτά","Consultar a cada %d min","Interogare la %d min" } }, + { "Port", { 0, "Poort","Port","Port","Puerto","Porta","Порт","Порт","Порт","Порт","Θύρα","Porta","Port" } }, + { "Press a key…", { 0, "Druk op een toets…","Taste drücken…","Appuyez sur une touche…","Pulsa una tecla…","Premi un tasto…","Нажмите клавишу…","Натисніть клавішу…","Натиснете клавиш…","Притисните тастер…","Πατήστε ένα πλήκτρο…","Pressione uma tecla…","Apasă o tastă…" } }, + { "Probing neighbours…", { 0, "Buren onderzoeken…","Nachbarn werden geprüft…","Sondage des voisins…","Sondeando vecinos…","Ricerca vicini…","Опрос соседей…","Опитування сусідів…","Проверка на съседи…","Испитивање суседа…","Έλεγχος γειτόνων…","Sondando vizinhos…","Se sondează vecinii…" } }, + { "Publish channel messages", { 0, "Kanaalberichten publiceren","Kanalnachrichten veröffentlichen","Publier les messages de canal","Publicar mensajes de canal","Pubblica messaggi del canale","Публиковать сообщения каналов","Публікувати повідомлення каналів","Публикуване на съобщения от канала","Објави поруке канала","Δημοσίευση μηνυμάτων καναλιού","Publicar mensagens do canal","Publică mesajele canalelor" } }, + { "Publish direct messages", { 0, "Directe berichten publiceren","Direktnachrichten veröffentlichen","Publier les messages directs","Publicar mensajes directos","Pubblica messaggi diretti","Публиковать личные сообщения","Публікувати особисті повідомлення","Публикуване на директни съобщения","Објави директне поруке","Δημοσίευση άμεσων μηνυμάτων","Publicar mensagens diretas","Publică mesajele directe" } }, + { "Purge", { 0, "Wissen","Löschen","Purger","Purgar","Elimina","Очистить","Очистити","Изчистване","Обриши","Εκκαθάριση","Limpar","Curăţă" } }, + { "Purge all discovered nodes?", { 0, "Alle ontdekte nodes wissen?","Alle entdeckten Nodes löschen?","Purger tous les nœuds découverts ?","¿Purgar todos los nodos descubiertos?","Eliminare tutti i nodi scoperti?","Очистить все обнаруженные узлы?","Очистити всі виявлені вузли?","Изчистване на всички открити възли?","Обрисати све откривене чворове?","Εκκαθάριση όλων των κόμβων που βρέθηκαν;","Limpar todos os nós descobertos?","Elimini toate nodurile descoperite?" } }, + { "RAM %u%% · PSRAM %u%%", { 0, "RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%","RAM %u%% · PSRAM %u%%" } }, + { "RX %d entries · Raw %d entries", { 0, "RX %d items · Raw %d items","RX %d Einträge · Raw %d Einträge","RX %d entrées · Raw %d entrées","RX %d entradas · Raw %d entradas","RX %d voci · Raw %d voci","RX %d записей · Raw %d записей","RX %d записів · Raw %d записів","RX %d записа · Raw %d записа","RX %d уноса · Raw %d уноса","RX %d καταχωρήσεις · Raw %d καταχωρήσεις","RX %d entradas · Raw %d entradas","RX %d intrări · Brut %d intrări" } }, + { "Reads packets on a background task so a busy screen can't drop them.", { 0, "Leest pakketten in een achtergrondtaak zodat een druk scherm ze niet laat vallen.","Liest Pakete in einer Hintergrundaufgabe, damit ein ausgelasteter Bildschirm sie nicht verliert.","Lit les paquets en tâche de fond pour qu'un écran occupé ne les perde pas.","Lee paquetes en una tarea en segundo plano para que una pantalla ocupada no los pierda.","Legge i pacchetti in un task in background così uno schermo occupato non li perde.","Читает пакеты в фоновой задаче, чтобы занятый экран их не терял.","Читає пакети у фоновому завданні, щоб зайнятий екран їх не втрачав.","Чете пакети във фонова задача, за да не ги изпуска зает екран.","Чита пакете у позадинском задатку да их заузет екран не изгуби.","Διαβάζει πακέτα σε εργασία παρασκηνίου ώστε μια απασχολημένη οθόνη να μην τα χάνει.","Lê pacotes em uma tarefa em segundo plano para que uma tela ocupada não os perca.","Citeşte pachetele pe o sarcină de fundal, ca un ecran ocupat să nu le piardă." } }, + { "Receive unreleased test firmware — newer features, but less tested. The update check and Install update both follow the beta channel.", { 0, "Ontvang niet-uitgebrachte testfirmware — nieuwere functies, maar minder getest. De updatecontrole en Update installeren volgen beide het bètakanaal.","Nicht veröffentlichte Test-Firmware empfangen — neuere Funktionen, aber weniger getestet. Update-Prüfung und Update installieren folgen beide dem Beta-Kanal.","Recevoir un firmware de test non publié — fonctions plus récentes, mais moins testées. La vérification et l'installation suivent le canal bêta.","Recibe firmware de prueba no publicado — funciones más nuevas, pero menos probadas. La comprobación y la instalación siguen el canal beta.","Ricevi firmware di prova non rilasciato — funzioni più recenti, ma meno testate. Il controllo e l'installazione seguono il canale beta.","Получать невыпущенную тестовую прошивку — новее функции, но меньше проверены. Проверка и установка обновлений идут по бета-каналу.","Отримувати невипущену тестову прошивку — новіші функції, але менш перевірені. Перевірка та встановлення оновлень йдуть бета-каналом.","Получаване на неиздаден тестов firmware — по-нови функции, но по-малко тествани. Проверката и инсталирането следват бета канала.","Прими необјављени тест firmware — новије функције, али мање тестиране. Провера и инсталација прате бета канал.","Λήψη μη κυκλοφορημένου δοκιμαστικού firmware — νεότερες λειτουργίες, αλλά λιγότερο δοκιμασμένες. Ο έλεγχος και η εγκατάσταση ακολουθούν το κανάλι beta.","Receber firmware de teste não lançado — recursos mais novos, mas menos testados. A verificação e a instalação seguem o canal beta.","Primeşti firmware de test nepublicat — funcţii mai noi, dar mai puţin testate. Verificarea de actualizare şi Instalează actualizarea urmează ambele canalul beta." } }, + { "Recently heard", { 0, "Recent gehoord","Kürzlich gehört","Entendu récemment","Escuchado hace poco","Sentito di recente","Недавно слышно","Нещодавно чуто","Наскоро чути","Недавно чути","Ακούστηκε πρόσφατα","Ouvido recentemente","Auzite recent" } }, + { "Reformat SD as MESHCOMOD (FAT32)?\nALL data on the card will be erased.", { 0, "SD herformatteren als MESHCOMOD (FAT32)?\nALLE data op de kaart wordt gewist.","SD als MESHCOMOD (FAT32) neu formatieren?\nALLE Daten auf der Karte werden gelöscht.","Reformater la SD en MESHCOMOD (FAT32) ?\nTOUTES les données de la carte seront effacées.","¿Reformatear la SD como MESHCOMOD (FAT32)?\nSe borrarán TODOS los datos de la tarjeta.","Riformattare la SD come MESHCOMOD (FAT32)?\nTUTTI i dati sulla scheda saranno cancellati.","Отформатировать SD как MESHCOMOD (FAT32)?\nВСЕ данные на карте будут стёрты.","Переформатувати SD як MESHCOMOD (FAT32)?\nУСІ дані на картці будуть стерті.","Форматиране на SD като MESHCOMOD (FAT32)?\nВСИЧКИ данни на картата ще бъдат изтрити.","Форматирати SD као MESHCOMOD (FAT32)?\nСВИ подаци на картици биће обрисани.","Επαναδιαμόρφωση SD ως MESHCOMOD (FAT32);\nΟΛΑ τα δεδομένα στην κάρτα θα διαγραφούν.","Reformatar o SD como MESHCOMOD (FAT32)?\nTODOS os dados do cartão serão apagados.","Reformatezi SD ca MESHCOMOD (FAT32)?\nTOATE datele de pe card vor fi şterse." } }, + { "Region scope (#tag), blank = default.", { 0, "Regiobereik (#tag), leeg = standaard.","Regionsbereich (#tag), leer = Standard.","Portée de région (#tag), vide = défaut.","Ámbito de región (#tag), vacío = predeterminado.","Ambito regione (#tag), vuoto = predefinito.","Область региона (#tag), пусто = по умолчанию.","Область регіону (#tag), порожньо = за замовчуванням.","Обхват на регион (#tag), празно = по подразбиране.","Опсег региона (#tag), празно = подразумевано.","Εύρος περιοχής (#tag), κενό = προεπιλογή.","Escopo da região (#tag), vazio = padrão.","Domeniu regional (#tag), gol = implicit." } }, + { "Remember password", { 0, "Wachtwoord onthouden","Passwort merken","Retenir le mot de passe","Recordar contraseña","Ricorda password","Запомнить пароль","Запам'ятати пароль","Запомни паролата","Запамти лозинку","Απομνημόνευση κωδικού","Lembrar senha","Reţine parola" } }, + { "Replay", { 0, "Opnieuw afspelen","Wiedergeben","Rejouer","Reproducir","Riproduci","Повтор","Повтор","Повтори","Понови","Επανάληψη","Reproduzir","Redă" } }, + { "Replay route on map", { 0, "Route opnieuw afspelen op kaart","Route auf Karte abspielen","Rejouer l'itinéraire sur la carte","Reproducir ruta en el mapa","Riproduci la rotta sulla mappa","Повторить маршрут на карте","Повторити маршрут на карті","Повтори маршрута на картата","Понови руту на мапи","Επανάληψη διαδρομής στον χάρτη","Reproduzir rota no mapa","Redă ruta pe hartă" } }, + { "Replaying route…", { 0, "Route wordt opnieuw afgespeeld…","Route wird abgespielt…","Relecture de l'itinéraire…","Reproduciendo ruta…","Riproduzione rotta…","Повтор маршрута…","Повтор маршруту…","Повтаряне на маршрута…","Понављање руте…","Επανάληψη διαδρομής…","Reproduzindo rota…","Se redă ruta…" } }, + { "Resend", { 0, "Opnieuw versturen","Erneut senden","Renvoyer","Reenviar","Reinvia","Отправить снова","Надіслати знову","Изпрати отново","Пошаљи поново","Επαναποστολή","Reenviar","Retrimite" } }, + { "Resend failed", { 0, "Opnieuw versturen mislukt","Erneut senden fehlgeschlagen","Échec du renvoi","Falló el reenvío","Reinvio non riuscito","Не удалось отправить снова","Не вдалося надіслати знову","Повторното изпращане е неуспешно","Поновно слање није успело","Η επαναποστολή απέτυχε","Falha ao reenviar","Retrimitere eşuată" } }, + { "Reverse scrollball", { 0, "Scrollball omkeren","Scrollball umkehren","Inverser la boule de défilement","Invertir la bola de desplazamiento","Inverti scrollball","Инвертировать трекбол","Інвертувати трекбол","Обръщане на скролбол","Обрни scrollball","Αντιστροφή scrollball","Inverter o scrollball","Inversează scrollball-ul" } }, + { "Room is read-only for you.\nJoin with the room password to post.", { 0, "De ruimte is voor jou alleen-lezen.\nDoe mee met het ruimtewachtwoord om te posten.","Der Raum ist für dich schreibgeschützt.\nMit dem Raumpasswort beitreten, um zu posten.","La salle est en lecture seule pour vous.\nRejoignez avec le mot de passe pour publier.","La sala es de solo lectura para ti.\nÚnete con la contraseña de la sala para publicar.","La stanza è di sola lettura per te.\nEntra con la password della stanza per pubblicare.","Комната только для чтения.\nВойдите с паролем комнаты, чтобы писать.","Кімната лише для читання.\nУвійдіть з паролем кімнати, щоб писати.","Стаята е само за четене за вас.\nВлезте с паролата на стаята, за да публикувате.","Соба је само за читање за вас.\nПридружите се лозинком собе да објавите.","Το δωμάτιο είναι μόνο για ανάγνωση για εσάς.\nΣυνδεθείτε με τον κωδικό του δωματίου για δημοσίευση.","A sala é somente leitura para você.\nEntre com a senha da sala para publicar.","Camera e doar în citire pentru tine.\nIntră cu parola camerei ca să postezi." } }, + { "SD card %s", { 0, "SD-kaart %s","SD-Karte %s","Carte SD %s","Tarjeta SD %s","Scheda SD %s","SD-карта %s","SD-картка %s","SD карта %s","SD картица %s","Κάρτα SD %s","Cartão SD %s","Card SD %s" } }, + { "SD card (tap to mount)", { 0, "SD-kaart (tik om te koppelen)","SD-Karte (zum Einbinden tippen)","Carte SD (toucher pour monter)","Tarjeta SD (toca para montar)","Scheda SD (tocca per montare)","SD-карта (нажмите для монтирования)","SD-картка (торкніться для монтування)","SD карта (докоснете за монтиране)","SD картица (додирни за монтирање)","Κάρτα SD (πατήστε για προσάρτηση)","Cartão SD (toque para montar)","Card SD (atinge pentru montare)" } }, + { "SD download failed: %s", { 0, "SD-download mislukt: %s","SD-Download fehlgeschlagen: %s","Échec du téléchargement SD : %s","Fallo al descargar en SD: %s","Download su SD non riuscito: %s","Не удалось загрузить на SD: %s","Не вдалося завантажити на SD: %s","Изтеглянето на SD е неуспешно: %s","Преузимање на SD није успело: %s","Η λήψη στην SD απέτυχε: %s","Falha ao baixar no SD: %s","Descărcare SD eşuată: %s" } }, + { "Saved network", { 0, "Opgeslagen netwerk","Gespeichertes Netzwerk","Réseau enregistré","Red guardada","Rete salvata","Сохранённая сеть","Збережена мережа","Запазена мрежа","Сачувана мрежа","Αποθηκευμένο δίκτυο","Rede salva","Reţea salvată" } }, + { "Saved: %s\nFlash it from the Launcher.", { 0, "Opgeslagen: %s\nFlash het vanuit de Launcher.","Gespeichert: %s\nFlashe es über den Launcher.","Enregistré : %s\nFlashez-le depuis le Launcher.","Guardado: %s\nFlashéalo desde el Launcher.","Salvato: %s\nFlashalo dal Launcher.","Сохранено: %s\nПрошейте из Launcher.","Збережено: %s\nПрошийте з Launcher.","Запазено: %s\nФлашнете го от Launcher.","Сачувано: %s\nФлешујте га из Launcher-а.","Αποθηκεύτηκε: %s\nΕγκαταστήστε το από τον Launcher.","Salvo: %s\nInstale pelo Launcher.","Salvat: %s\nInstalează-l din Launcher." } }, + { "Saving to SD… %d%%", { 0, "Opslaan naar SD… %d%%","Speichern auf SD… %d%%","Enregistrement sur SD… %d%%","Guardando en SD… %d%%","Salvataggio su SD… %d%%","Сохранение на SD… %d%%","Збереження на SD… %d%%","Запис на SD… %d%%","Чување на SD… %d%%","Αποθήκευση στην SD… %d%%","Salvando no SD… %d%%","Se salvează pe SD… %d%%" } }, + { "Saving to SD… 0%", { 0, "Opslaan naar SD… 0%","Speichern auf SD… 0%","Enregistrement sur SD… 0%","Guardando en SD… 0%","Salvataggio su SD… 0%","Сохранение на SD… 0%","Збереження на SD… 0%","Запис на SD… 0%","Чување на SD… 0%","Αποθήκευση στην SD… 0%","Salvando no SD… 0%","Se salvează pe SD… 0%" } }, + { "Scope direct msgs to region", { 0, "Directe berichten beperken tot regio","Direktnachrichten auf Region begrenzen","Limiter les messages directs à la région","Limitar mensajes directos a la región","Limita i messaggi diretti alla regione","Ограничить личные сообщения регионом","Обмежити особисті повідомлення регіоном","Ограничи директните съобщения до региона","Ограничи директне поруке на регион","Περιορισμός άμεσων μηνυμάτων στην περιοχή","Limitar mensagens diretas à região","Limitează DM-urile la regiune" } }, + { "Screen timeout (s, 0 = never, min 10)", { 0, "Schermtime-out (s, 0 = nooit, min 10)","Bildschirm-Timeout (s, 0 = nie, min 10)","Délai d'écran (s, 0 = jamais, min 10)","Tiempo de pantalla (s, 0 = nunca, mín 10)","Timeout schermo (s, 0 = mai, min 10)","Тайм-аут экрана (с, 0 = никогда, мин 10)","Тайм-аут екрана (с, 0 = ніколи, мін 10)","Изключване на екрана (с, 0 = никога, мин 10)","Истек екрана (с, 0 = никад, мин 10)","Λήξη οθόνης (δ, 0 = ποτέ, ελάχ 10)","Tempo da tela (s, 0 = nunca, mín 10)","Stingere ecran (s, 0 = niciodată, min 10)" } }, + { "Screen-off just dims", { 0, "Scherm-uit dimt alleen","Bildschirm-Aus dimmt nur","Écran éteint = juste tamisé","Pantalla apagada solo atenúa","Schermo spento solo attenua","Выключение экрана только затемняет","Вимкнення екрана лише притемнює","Изключването на екрана само затъмнява","Гашење екрана само затамни","Οθόνη κλειστή απλώς σκοτεινιάζει","Tela desligada só escurece","Ecranul stins doar se întunecă" } }, + { "Screenshot needs an SD card", { 0, "Schermafbeelding vereist een SD-kaart","Screenshot benötigt eine SD-Karte","La capture nécessite une carte SD","La captura necesita una tarjeta SD","Lo screenshot richiede una scheda SD","Для скриншота нужна SD-карта","Для скриншота потрібна SD-картка","Екранната снимка изисква SD карта","Снимак екрана захтева SD картицу","Το στιγμιότυπο χρειάζεται κάρτα SD","A captura precisa de um cartão SD","Captura de ecran necesită un card SD" } }, + { "Screenshot: low memory", { 0, "Schermafbeelding: weinig geheugen","Screenshot: wenig Speicher","Capture : mémoire faible","Captura: poca memoria","Screenshot: memoria insufficiente","Скриншот: мало памяти","Скриншот: мало пам'яті","Екранна снимка: малко памет","Снимак екрана: мало меморије","Στιγμιότυπο: λίγη μνήμη","Captura: pouca memória","Captură: memorie redusă" } }, + { "Screenshot: no SD card", { 0, "Schermafbeelding: geen SD-kaart","Screenshot: keine SD-Karte","Capture : pas de carte SD","Captura: sin tarjeta SD","Screenshot: nessuna scheda SD","Скриншот: нет SD-карты","Скриншот: немає SD-картки","Екранна снимка: няма SD карта","Снимак екрана: нема SD картице","Στιγμιότυπο: καμία κάρτα SD","Captura: sem cartão SD","Captură: fără card SD" } }, + { "Screenshot: write failed", { 0, "Schermafbeelding: schrijven mislukt","Screenshot: Schreiben fehlgeschlagen","Capture : échec d'écriture","Captura: fallo de escritura","Screenshot: scrittura non riuscita","Скриншот: ошибка записи","Скриншот: помилка запису","Екранна снимка: записът е неуспешен","Снимак екрана: упис није успео","Στιγμιότυπο: αποτυχία εγγραφής","Captura: falha na gravação","Captură: scriere eşuată" } }, + { "Scrollball: normal", { 0, "Scrollball: normaal","Scrollball: normal","Boule de défilement : normale","Bola de desplazamiento: normal","Scrollball: normale","Трекбол: обычный","Трекбол: звичайний","Скролбол: нормален","Scrollball: нормалан","Scrollball: κανονικό","Scrollball: normal","Scrollball: normal" } }, + { "Scrollball: reversed", { 0, "Scrollball: omgekeerd","Scrollball: umgekehrt","Boule de défilement : inversée","Bola de desplazamiento: invertida","Scrollball: invertita","Трекбол: инвертирован","Трекбол: інвертований","Скролбол: обърнат","Scrollball: обрнут","Scrollball: αντεστραμμένο","Scrollball: invertido","Scrollball: inversat" } }, + { "Select all", { 0, "Alles selecteren","Alles auswählen","Tout sélectionner","Seleccionar todo","Seleziona tutto","Выбрать всё","Вибрати все","Избери всичко","Изабери све","Επιλογή όλων","Selecionar tudo","Selectează tot" } }, + { "Send an advert now, or set how often you re-advertise.", { 0, "Verstuur nu een advert, of stel in hoe vaak je opnieuw adverteert.","Jetzt einen Advert senden oder festlegen, wie oft du neu advertisest.","Envoyer un advert maintenant, ou définir la fréquence de ré-advert.","Envía un advert ahora, o define con qué frecuencia re-adviertes.","Invia un advert ora, o imposta ogni quanto ripubblicare l'advert.","Отправить advert сейчас или задать частоту повторной отправки.","Надіслати advert зараз або задати частоту повторної відправки.","Изпратете advert сега или задайте колко често го повтаряте.","Пошаљите advert сада или подесите колико често га понављате.","Στείλτε ένα advert τώρα ή ορίστε πόσο συχνά επαναλαμβάνετε.","Envie um advert agora, ou defina com que frequência re-anuncia.","Trimite un advert acum sau setează cât de des te re-anunţi." } }, + { "Sensors tab: off (restart to apply)", { 0, "Sensortab: uit (herstart om toe te passen)","Sensoren-Tab: aus (Neustart zum Anwenden)","Onglet capteurs : désactivé (redémarrez pour appliquer)","Pestaña sensores: desactivada (reinicia para aplicar)","Scheda sensori: off (riavvia per applicare)","Вкладка датчиков: выкл (перезагрузите для применения)","Вкладка датчиків: вимк (перезавантажте для застосування)","Раздел сензори: изключен (рестарт за прилагане)","Картица сензора: искључено (рестарт за примену)","Καρτέλα αισθητήρων: κλειστή (επανεκκίνηση για εφαρμογή)","Aba de sensores: desligada (reinicie para aplicar)","Fila Senzori: oprită (repornire pentru aplicare)" } }, + { "Sensors tab: on (restart to apply)", { 0, "Sensortab: aan (herstart om toe te passen)","Sensoren-Tab: an (Neustart zum Anwenden)","Onglet capteurs : activé (redémarrez pour appliquer)","Pestaña sensores: activada (reinicia para aplicar)","Scheda sensori: on (riavvia per applicare)","Вкладка датчиков: вкл (перезагрузите для применения)","Вкладка датчиків: увімк (перезавантажте для застосування)","Раздел сензори: включен (рестарт за прилагане)","Картица сензора: укључено (рестарт за примену)","Καρτέλα αισθητήρων: ανοιχτή (επανεκκίνηση για εφαρμογή)","Aba de sensores: ligada (reinicie para aplicar)","Fila Senzori: pornită (repornire pentru aplicare)" } }, + { "Share: %s", { 0, "Delen: %s","Teilen: %s","Partager : %s","Compartir: %s","Condividi: %s","Поделиться: %s","Поділитися: %s","Споделяне: %s","Подели: %s","Κοινή χρήση: %s","Compartilhar: %s","Partajare: %s" } }, + { "Show", { 0, "Toon","Anzeigen","Afficher","Mostrar","Mostra","Показать","Показати","Покажи","Прикажи","Εμφάνιση","Mostrar","Arată" } }, + { "Show Sensors tab", { 0, "Sensoren-tab tonen","Sensoren-Tab anzeigen","Afficher l'onglet Capteurs","Mostrar pestaña Sensores","Mostra scheda Sensori","Показать вкладку датчиков","Показати вкладку датчиків","Покажи раздел сензори","Прикажи картицу сензора","Εμφάνιση καρτέλας αισθητήρων","Mostrar aba Sensores","Arată fila Senzori" } }, + { "Show menu-bar letters", { 0, "Menubalk-letters tonen","Menüleisten-Buchstaben zeigen","Afficher les lettres du menu","Mostrar letras de la barra","Mostra lettere della barra","Показать буквы меню","Показати літери меню","Покажи буквите на менюто","Прикажи слова у менију","Εμφάνιση γραμμάτων μενού","Mostrar letras do menu","Arată literele barei de meniu" } }, + { "Show on chart", { 0, "Op grafiek tonen","Im Diagramm zeigen","Afficher sur le graphique","Mostrar en gráfico","Mostra su grafico","Показать на графике","Показати на графіку","Покажи на графиката","Прикажи на графикону","Εμφάνιση στο γράφημα","Mostrar no gráfico","Arată pe grafic" } }, + { "Show on map", { 0, "Op kaart tonen","Auf Karte zeigen","Afficher sur la carte","Mostrar en mapa","Mostra su mappa","Показать на карте","Показати на карті","Покажи на картата","Прикажи на мапи","Εμφάνιση στον χάρτη","Mostrar no mapa","Arată pe hartă" } }, + { "Signal & traffic", { 0, "Signaal & verkeer","Signal & Verkehr","Signal & trafic","Señal y tráfico","Segnale e traffico","Сигнал и трафик","Сигнал і трафік","Сигнал и трафик","Сигнал и саобраћај","Σήμα & κίνηση","Sinal e tráfego","Semnal şi trafic" } }, + { "Sleep ready", { 0, "Klaar voor slaap","Bereit zum Schlafen","Prêt pour veille","Listo para dormir","Pronto per lo standby","Готов ко сну","Готовий до сну","Готов за заспиване","Спреман за спавање","Έτοιμο για ύπνο","Pronto para dormir","Pregătit de somn" } }, + { "Snake — swipe or roll the trackball", { 0, "Snake — veeg of rol de trackball","Snake — wischen oder Trackball rollen","Snake — glissez ou roulez le trackball","Snake — desliza o gira el trackball","Snake — scorri o gira la trackball","Snake — проведите или крутите трекбол","Snake — проведіть або крутіть трекбол","Snake — плъзни или завърти трекбола","Snake — превуци или заврти трекбол","Snake — σύρετε ή κυλήστε το trackball","Snake — deslize ou gire o trackball","Snake — glisează sau roteşte trackball-ul" } }, + { "Sort", { 0, "Sorteer","Sortieren","Trier","Ordenar","Ordina","Сортировка","Сортувати","Сортирай","Сортирај","Ταξινόμηση","Ordenar","Sortează" } }, + { "TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s", { 0, "TCP %s BLE %s\nWS-clients %d GPS %s Zoemer %s","TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s","TCP %s BLE %s\nClients WS %d GPS %s Buzzer %s","TCP %s BLE %s\nClientes WS %d GPS %s Zumbador %s","TCP %s BLE %s\nClient WS %d GPS %s Cicalino %s","TCP %s BLE %s\nWS-клиенты %d GPS %s Зуммер %s","TCP %s BLE %s\nWS-клієнти %d GPS %s Зумер %s","TCP %s BLE %s\nWS клиенти %d GPS %s Зумер %s","TCP %s BLE %s\nWS клијенти %d GPS %s Зујалица %s","TCP %s BLE %s\nΠελάτες WS %d GPS %s Βομβητής %s","TCP %s BLE %s\nClientes WS %d GPS %s Buzzer %s","TCP %s BLE %s\nClienţi WS %d GPS %s Buzzer %s" } }, + { "TOUCH BETA", { 0, "TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA","TOUCH BETA" } }, + { "Tab hotkeys — tap a row, then press a key", { 0, "Tab-sneltoetsen — tik een rij, druk dan een toets","Tab-Tasten — Zeile tippen, dann Taste drücken","Raccourcis onglet — touchez une ligne, puis une touche","Atajos de pestaña — toca una fila, luego una tecla","Tasti scheda — tocca una riga, poi premi un tasto","Горячие клавиши вкладки — коснитесь строки, затем нажмите клавишу","Гарячі клавіші вкладки — торкніться рядка, потім натисніть клавішу","Клавиши за раздела — докосни ред, после натисни клавиш","Пречице картице — додирни ред, па притисни тастер","Πλήκτρα καρτέλας — πατήστε μια σειρά, μετά ένα πλήκτρο","Atalhos da aba — toque numa linha, depois numa tecla","Taste rapide pentru file — atinge un rând, apoi apasă o tastă" } }, + { "Telemetry requests allowed", { 0, "Telemetrie-verzoeken toegestaan","Telemetrie-Anfragen erlaubt","Requêtes télémétrie autorisées","Solicitudes de telemetría permitidas","Richieste telemetria consentite","Запросы телеметрии разрешены","Запити телеметрії дозволені","Заявките за телеметрия разрешени","Захтеви телеметрије дозвољени","Επιτρέπονται αιτήματα τηλεμετρίας","Solicitações de telemetria permitidas","Cereri de telemetrie permise" } }, + { "Telemetry requests off", { 0, "Telemetrie-verzoeken uit","Telemetrie-Anfragen aus","Requêtes télémétrie désactivées","Solicitudes de telemetría desactivadas","Richieste telemetria disattivate","Запросы телеметрии выкл","Запити телеметрії вимк","Заявките за телеметрия изкл","Захтеви телеметрије искључени","Αιτήματα τηλεμετρίας ανενεργά","Solicitações de telemetria desligadas","Cereri de telemetrie oprite" } }, + { "Telemetry req…", { 0, "Telemetrie-verz…","Telemetrie-Anfr…","Requêtes télé…","Solic. telem…","Richieste tel…","Запросы теле…","Запити теле…","Заявки телем…","Захтеви теле…","Αιτήματα τηλε…","Solic. telem…","Cerere telemetrie…" } }, + { "Telemetry settings", { 0, "Telemetrie-instellingen","Telemetrie-Einstellungen","Réglages télémétrie","Ajustes de telemetría","Impostazioni telemetria","Настройки телеметрии","Налаштування телеметрії","Настройки за телеметрия","Подешавања телеметрије","Ρυθμίσεις τηλεμετρίας","Ajustes de telemetria","Setări telemetrie" } }, + { "Temperature", { 0, "Temperatuur","Temperatur","Température","Temperatura","Temperatura","Температура","Температура","Температура","Температура","Θερμοκρασία","Temperatura","Temperatură" } }, + { "Test builds: off", { 0, "Testbuilds: uit","Test-Builds: aus","Builds de test : off","Compilaciones de prueba: off","Build di test: off","Тестовые сборки: выкл","Тестові збірки: вимк","Тестови компилации: изкл","Тест верзије: искључено","Δοκιμαστικά builds: off","Builds de teste: off","Versiuni de test: oprite" } }, + { "Test builds: on", { 0, "Testbuilds: aan","Test-Builds: an","Builds de test : on","Compilaciones de prueba: on","Build di test: on","Тестовые сборки: вкл","Тестові збірки: увімк","Тестови компилации: вкл","Тест верзије: укључено","Δοκιμαστικά builds: on","Builds de teste: on","Versiuni de test: pornite" } }, + { "This device can run Wi-Fi and Bluetooth at once, as a standalone radio and a phone companion (MeshCore app) together.\n\nRunning both at the same time uses more RAM, so turn on only what you need.\n\nSet them up anytime in Settings.", { 0, "Dit apparaat kan Wi-Fi en Bluetooth tegelijk draaien, als zelfstandige radio én telefoon-companion (MeshCore-app) samen.\n\nBeide tegelijk gebruikt meer RAM, dus zet alleen aan wat je nodig hebt.\n\nStel ze altijd in via Instellingen.","Dieses Gerät kann Wi-Fi und Bluetooth gleichzeitig als eigenständiges Funkgerät und Telefon-Begleiter (MeshCore-App) betreiben.\n\nBeides gleichzeitig braucht mehr RAM, aktiviere also nur, was du brauchst.\n\nJederzeit in den Einstellungen einrichtbar.","Cet appareil peut utiliser Wi-Fi et Bluetooth en même temps, comme radio autonome et compagnon de téléphone (app MeshCore).\n\nLes deux à la fois consomment plus de RAM, n'activez que le nécessaire.\n\nRéglez-les à tout moment dans Réglages.","Este dispositivo puede usar Wi-Fi y Bluetooth a la vez, como radio independiente y compañero del teléfono (app MeshCore).\n\nUsar ambos consume más RAM, así que activa solo lo que necesites.\n\nConfigúralos cuando quieras en Ajustes.","Questo dispositivo può usare Wi-Fi e Bluetooth insieme, come radio autonoma e compagno del telefono (app MeshCore).\n\nUsarli entrambi consuma più RAM, quindi attiva solo ciò che serve.\n\nConfigurali quando vuoi in Impostazioni.","Это устройство может использовать Wi-Fi и Bluetooth одновременно — как автономную рацию и компаньон телефона (приложение MeshCore).\n\nОба сразу используют больше ОЗУ, включайте только нужное.\n\nНастройте их в любой момент в Настройках.","Цей пристрій може використовувати Wi-Fi і Bluetooth одночасно — як автономну рацію та компаньйон телефона (застосунок MeshCore).\n\nОбидва разом використовують більше ОЗП, вмикайте лише потрібне.\n\nНалаштуйте їх будь-коли в Налаштуваннях.","Това устройство може да ползва Wi-Fi и Bluetooth едновременно — като самостоятелно радио и телефонен компаньон (приложение MeshCore).\n\nДвете заедно ползват повече RAM, затова включвай само нужното.\n\nНастрой ги по всяко време в Настройки.","Овај уређај може користити Wi-Fi и Bluetooth истовремено — као самостални радио и телефонски пратилац (MeshCore апликација).\n\nОба заједно троше више RAM-а, зато укључи само оно што ти треба.\n\nПодеси их било кад у Подешавањима.","Αυτή η συσκευή μπορεί να χρησιμοποιεί Wi-Fi και Bluetooth ταυτόχρονα, ως αυτόνομος πομποδέκτης και συνοδός τηλεφώνου (εφαρμογή MeshCore).\n\nΚαι τα δύο μαζί χρησιμοποιούν περισσότερη RAM, οπότε ενεργοποιήστε μόνο ό,τι χρειάζεστε.\n\nΡυθμίστε τα οποτεδήποτε στις Ρυθμίσεις.","Este aparelho pode usar Wi-Fi e Bluetooth ao mesmo tempo, como rádio autônomo e companheiro do celular (app MeshCore).\n\nUsar os dois juntos consome mais RAM, então ligue só o que precisar.\n\nConfigure quando quiser em Ajustes.","Acest dispozitiv poate rula Wi-Fi şi Bluetooth simultan, ca radio de sine stătător şi companion de telefon (aplicaţia MeshCore) în acelaşi timp.\n\nRularea ambelor foloseşte mai multă memorie RAM, deci porneşte doar ce îţi trebuie.\n\nLe poţi configura oricând în Setări." } }, + { "Throttles the CPU when idle to save power", { 0, "Beperkt de CPU bij inactiviteit om stroom te sparen","Drosselt die CPU im Leerlauf zum Stromsparen","Limite le CPU au repos pour économiser l'énergie","Reduce la CPU en reposo para ahorrar energía","Rallenta la CPU da inattiva per risparmiare energia","Снижает частоту ЦП в простое для экономии энергии","Знижує частоту ЦП у простої для економії енергії","Ограничава процесора при бездействие за пестене на ток","Успорава процесор у мировању ради уштеде енергије","Περιορίζει τη CPU σε αδράνεια για εξοικονόμηση ενέργειας","Reduz a CPU em repouso para poupar energia","Reduce frecvenţa CPU în repaus pentru economie de energie" } }, + { "Tiles cleared — reconnect Wi-Fi to re-download", { 0, "Tegels gewist — verbind Wi-Fi opnieuw om te herladen","Kacheln gelöscht — Wi-Fi neu verbinden zum Neuladen","Tuiles effacées — reconnectez le Wi-Fi pour retélécharger","Teselas borradas — reconecta Wi-Fi para descargar de nuevo","Tessere cancellate — riconnetti il Wi-Fi per riscaricare","Тайлы очищены — переподключите Wi-Fi для загрузки","Тайли очищено — перепідключіть Wi-Fi для завантаження","Плочките изчистени — свържи Wi-Fi отново за изтегляне","Плочице обрисане — поново повежи Wi-Fi за преузимање","Τα πλακίδια διαγράφηκαν — επανασυνδέστε Wi-Fi για λήψη","Blocos apagados — reconecte o Wi-Fi para rebaixar","Dale şterse — reconectează Wi-Fi pentru re-descărcare" } }, + { "Time range (hours ago)", { 0, "Tijdsbereik (uren geleden)","Zeitbereich (Stunden her)","Plage horaire (heures)","Rango de tiempo (horas atrás)","Intervallo (ore fa)","Диапазон времени (часов назад)","Діапазон часу (годин тому)","Времеви обхват (часа назад)","Временски опсег (сати уназад)","Χρονικό εύρος (ώρες πριν)","Intervalo (horas atrás)","Interval (ore în urmă)" } }, + { "Time zone", { 0, "Tijdzone","Zeitzone","Fuseau horaire","Zona horaria","Fuso orario","Часовой пояс","Часовий пояс","Часова зона","Временска зона","Ζώνη ώρας","Fuso horário","Fus orar" } }, + { "Time zone: %s", { 0, "Tijdzone: %s","Zeitzone: %s","Fuseau horaire : %s","Zona horaria: %s","Fuso orario: %s","Часовой пояс: %s","Часовий пояс: %s","Часова зона: %s","Временска зона: %s","Ζώνη ώρας: %s","Fuso horário: %s","Fus orar: %s" } }, + { "Today", { 0, "Vandaag","Heute","Aujourd'hui","Hoy","Oggi","Сегодня","Сьогодні","Днес","Данас","Σήμερα","Hoje","Azi" } }, + { "Topographic map", { 0, "Topografische kaart","Topografische Karte","Carte topographique","Mapa topográfico","Mappa topografica","Топографическая карта","Топографічна карта","Топографска карта","Топографска мапа","Τοπογραφικός χάρτης","Mapa topográfico","Hartă topografică" } }, + { "Trace", { 0, "Traceren","Verfolgen","Tracer","Rastrear","Traccia","Трассировка","Трасування","Проследяване","Праћење","Ιχνηλάτηση","Rastrear","Trasează" } }, + { "Trace route", { 0, "Route traceren","Route verfolgen","Tracer la route","Rastrear ruta","Traccia percorso","Трассировка маршрута","Трасування маршруту","Проследи маршрута","Прати руту","Ιχνηλάτηση διαδρομής","Rastrear rota","Trasează ruta" } }, + { "Trackball navigates UI", { 0, "Trackball navigeert UI","Trackball steuert UI","Le trackball navigue l'UI","Trackball navega la UI","La trackball naviga UI","Трекбол управляет UI","Трекбол керує UI","Трекболът навигира UI","Трекбол управља UI","Το trackball πλοηγεί το UI","Trackball navega a UI","Trackball-ul navighează UI-ul" } }, + { "Trackball: cursor", { 0, "Trackball: cursor","Trackball: Cursor","Trackball : curseur","Trackball: cursor","Trackball: cursore","Трекбол: курсор","Трекбол: курсор","Трекбол: курсор","Трекбол: курсор","Trackball: δρομέας","Trackball: cursor","Trackball: cursor" } }, + { "Trackball: navigate UI", { 0, "Trackball: navigeer UI","Trackball: UI steuern","Trackball : naviguer l'UI","Trackball: navegar UI","Trackball: naviga UI","Трекбол: навигация UI","Трекбол: навігація UI","Трекбол: навигирай UI","Трекбол: управљај UI","Trackball: πλοήγηση UI","Trackball: navegar UI","Trackball: navigare UI" } }, + { "Turn Wi-Fi on first", { 0, "Zet eerst Wi-Fi aan","Zuerst Wi-Fi einschalten","Activez d'abord le Wi-Fi","Activa primero el Wi-Fi","Attiva prima il Wi-Fi","Сначала включите Wi-Fi","Спершу увімкніть Wi-Fi","Първо включи Wi-Fi","Прво укључи Wi-Fi","Ενεργοποιήστε πρώτα το Wi-Fi","Ligue o Wi-Fi primeiro","Porneşte mai întâi Wi-Fi" } }, + { "Type a new 6-digit code · applies after a reboot", { 0, "Voer een nieuwe 6-cijferige code in · geldt na herstart","Neuen 6-stelligen Code eingeben · gilt nach Neustart","Entrez un nouveau code à 6 chiffres · appliqué au redémarrage","Escribe un nuevo código de 6 dígitos · se aplica al reiniciar","Inserisci un nuovo codice a 6 cifre · attivo dopo il riavvio","Введите новый 6-значный код · применится после перезагрузки","Введіть новий 6-значний код · діє після перезавантаження","Въведи нов 6-цифрен код · важи след рестарт","Унеси нови 6-цифрени код · важи после рестарта","Πληκτρολογήστε νέο 6ψήφιο κωδικό · ισχύει μετά την επανεκκίνηση","Digite um novo código de 6 dígitos · vale após reiniciar","Tastează un cod nou de 6 cifre · se aplică după repornire" } }, + { "UI size (restart to apply)", { 0, "UI-grootte (herstart om toe te passen)","UI-Größe (Neustart nötig)","Taille UI (redémarrer pour appliquer)","Tamaño UI (reiniciar para aplicar)","Dimensione UI (riavvia per applicare)","Размер UI (перезапуск для применения)","Розмір UI (перезапуск для застосування)","Размер на UI (рестарт за прилагане)","Величина UI (рестарт за примену)","Μέγεθος UI (επανεκκίνηση για εφαρμογή)","Tamanho da UI (reinicie para aplicar)","Dimensiune UI (repornire pentru aplicare)" } }, + { "UI size saved — restart to apply", { 0, "UI-grootte opgeslagen — herstart om toe te passen","UI-Größe gespeichert — Neustart zum Anwenden","Taille UI enregistrée — redémarrez pour appliquer","Tamaño UI guardado — reinicia para aplicar","Dimensione UI salvata — riavvia per applicare","Размер UI сохранён — перезапуск для применения","Розмір UI збережено — перезапуск для застосування","Размерът на UI е запазен — рестарт за прилагане","Величина UI сачувана — рестарт за примену","Το μέγεθος UI αποθηκεύτηκε — επανεκκίνηση για εφαρμογή","Tamanho da UI salvo — reinicie para aplicar","Dimensiune UI salvată — reporneşte pentru aplicare" } }, + { "USB powered", { 0, "USB-gevoed","USB-versorgt","Alimenté par USB","Alimentado por USB","Alimentato via USB","Питание от USB","Живлення від USB","Захранване от USB","Напајање преко USB","Τροφοδοσία USB","Alimentado por USB","Alimentat prin USB" } }, + { "Unblocked", { 0, "Gedeblokkeerd","Freigegeben","Débloqué","Desbloqueado","Sbloccato","Разблокирован","Розблоковано","Разблокиран","Одблокиран","Ξεμπλοκαρίστηκε","Desbloqueado","Deblocat" } }, + { "Unfavorite it first", { 0, "Eerst uit favorieten halen","Zuerst aus Favoriten entfernen","Retirez-le d'abord des favoris","Quítalo de favoritos primero","Rimuovi prima dai preferiti","Сначала уберите из избранного","Спершу приберіть з обраного","Първо махни от любими","Прво уклони из омиљених","Αφαιρέστε το πρώτα από τα αγαπημένα","Remova dos favoritos primeiro","Scoate-l mai întâi de la favorite" } }, + { "Unmute @", { 0, "Dempen op @","Stumm aus @","Réactiver @","Reactivar @","Riattiva @","Включить @","Увімкнути @","Възстанови @","Укључи @","Ενεργοποίηση @","Reativar @","Cu sunet @" } }, + { "Unmute msgs", { 0, "Berichten aan","Nachrichten an","Réactiver msgs","Reactivar msjs","Riattiva msg","Включить сообщ.","Увімкнути повідом.","Възстанови съобщ.","Укључи поруке","Ενεργοποίηση μηνυμ.","Reativar msgs","Cu sunet mesaje" } }, + { "Unsupported WAV (need 16-bit PCM)", { 0, "Niet-ondersteunde WAV (16-bit PCM vereist)","Nicht unterstütztes WAV (16-Bit PCM nötig)","WAV non pris en charge (16-bit PCM requis)","WAV no compatible (requiere PCM de 16 bits)","WAV non supportato (serve PCM a 16 bit)","WAV не поддерживается (нужен 16-бит PCM)","WAV не підтримується (потрібен 16-біт PCM)","Неподдържан WAV (нужен 16-битов PCM)","Неподржан WAV (потребан 16-битни PCM)","Μη υποστηριζόμενο WAV (χρειάζεται 16-bit PCM)","WAV não suportado (precisa de PCM 16 bits)","WAV neacceptat (necesită PCM 16-bit)" } }, + { "Update bin saved to SD", { 0, "Update-bin opgeslagen op SD","Update-Bin auf SD gespeichert","Fichier de mise à jour enregistré sur SD","Bin de actualización guardado en SD","Bin di aggiornamento salvato su SD","Файл обновления сохранён на SD","Файл оновлення збережено на SD","Файлът за обновяване е записан на SD","Датотека ажурирања сачувана на SD","Το αρχείο ενημέρωσης αποθηκεύτηκε στη SD","Bin de atualização salvo no SD","Bin de actualizare salvat pe SD" } }, + { "Update complete.\nRebooting...", { 0, "Update voltooid.\nOpnieuw opstarten...","Update fertig.\nNeustart...","Mise à jour terminée.\nRedémarrage...","Actualización completa.\nReiniciando...","Aggiornamento completato.\nRiavvio...","Обновление завершено.\nПерезагрузка...","Оновлення завершено.\nПерезавантаження...","Обновяването завърши.\nРестартиране...","Ажурирање завршено.\nПоновно покретање...","Η ενημέρωση ολοκληρώθηκε.\nΕπανεκκίνηση...","Atualização concluída.\nReiniciando...","Actualizare completă.\nSe reporneşte..." } }, + { "Update via the Launcher", { 0, "Update via de Launcher","Update über den Launcher","Mise à jour via le Launcher","Actualiza vía el Launcher","Aggiorna tramite il Launcher","Обновление через Launcher","Оновлення через Launcher","Обнови през Launcher","Ажурирај преко Launcher-а","Ενημέρωση μέσω του Launcher","Atualize pelo Launcher","Actualizează prin Launcher" } }, + { "Update via the Launcher / flasher.wadamesh.com", { 0, "Update via de Launcher / flasher.wadamesh.com","Update über den Launcher / flasher.wadamesh.com","Mise à jour via le Launcher / flasher.wadamesh.com","Actualiza vía el Launcher / flasher.wadamesh.com","Aggiorna tramite il Launcher / flasher.wadamesh.com","Обновление через Launcher / flasher.wadamesh.com","Оновлення через Launcher / flasher.wadamesh.com","Обнови през Launcher / flasher.wadamesh.com","Ажурирај преко Launcher-а / flasher.wadamesh.com","Ενημέρωση μέσω του Launcher / flasher.wadamesh.com","Atualize pelo Launcher / flasher.wadamesh.com","Actualizează prin Launcher / flasher.wadamesh.com" } }, + { "Update via the Launcher / flasher.wadamesh.com.", { 0, "Update via de Launcher / flasher.wadamesh.com.","Update über den Launcher / flasher.wadamesh.com.","Mise à jour via le Launcher / flasher.wadamesh.com.","Actualiza vía el Launcher / flasher.wadamesh.com.","Aggiorna tramite il Launcher / flasher.wadamesh.com.","Обновление через Launcher / flasher.wadamesh.com.","Оновлення через Launcher / flasher.wadamesh.com.","Обнови през Launcher / flasher.wadamesh.com.","Ажурирај преко Launcher-а / flasher.wadamesh.com.","Ενημέρωση μέσω του Launcher / flasher.wadamesh.com.","Atualize pelo Launcher / flasher.wadamesh.com.","Actualizează prin Launcher / flasher.wadamesh.com." } }, + { "Username (optional)", { 0, "Gebruikersnaam (optioneel)","Benutzername (optional)","Nom d'utilisateur (facultatif)","Nombre de usuario (opcional)","Nome utente (facoltativo)","Имя пользователя (необязательно)","Ім'я користувача (необов'язково)","Потребителско име (по избор)","Корисничко име (опционо)","Όνομα χρήστη (προαιρετικό)","Nome de usuário (opcional)","Utilizator (opţional)" } }, + { "Volume", { 0, "Volume","Lautstärke","Volume","Volumen","Volume","Громкость","Гучність","Сила на звука","Јачина звука","Ένταση","Volume","Volum" } }, + { "W up · A left · D right · Z down · S select · Q back", { 0, "W omhoog · A links · D rechts · Z omlaag · S kies · Q terug","W hoch · A links · D rechts · Z runter · S wählen · Q zurück","W haut · A gauche · D droite · Z bas · S choisir · Q retour","W arriba · A izq. · D der. · Z abajo · S elegir · Q atrás","W su · A sinistra · D destra · Z giù · S scegli · Q indietro","W вверх · A влево · D вправо · Z вниз · S выбор · Q назад","W вгору · A вліво · D вправо · Z вниз · S вибір · Q назад","W нагоре · A ляво · D дясно · Z надолу · S избор · Q назад","W горе · A лево · D десно · Z доле · S избор · Q назад","W πάνω · A αριστερά · D δεξιά · Z κάτω · S επιλογή · Q πίσω","W cima · A esq. · D dir. · Z baixo · S selecionar · Q voltar","W sus · A stânga · D dreapta · Z jos · S selectare · Q înapoi" } }, + { "WADA#15B6A6 MESH#", { 0, "WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#","WADA#15B6A6 MESH#" } }, + { "WADAMESH", { 0, "WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH","WADAMESH" } }, + { "Wi-Fi not connected", { 0, "Wi-Fi niet verbonden","Wi-Fi nicht verbunden","Wi-Fi non connecté","Wi-Fi no conectado","Wi-Fi non connesso","Wi-Fi не подключён","Wi-Fi не під'єднано","Wi-Fi не е свързан","Wi-Fi није повезан","Το Wi-Fi δεν συνδέθηκε","Wi-Fi não conectado","Wi-Fi neconectat" } }, + { "Wi-Fi on, scanning…", { 0, "Wi-Fi aan, scannen…","Wi-Fi an, suche…","Wi-Fi activé, recherche…","Wi-Fi activado, buscando…","Wi-Fi attivo, scansione…","Wi-Fi вкл, поиск…","Wi-Fi увімк, пошук…","Wi-Fi вкл, сканиране…","Wi-Fi укључен, тражим…","Wi-Fi ενεργό, σάρωση…","Wi-Fi ligado, buscando…","Wi-Fi pornit, se scanează…" } }, + { "Yesterday", { 0, "Gisteren","Gestern","Hier","Ayer","Ieri","Вчера","Вчора","Вчера","Јуче","Χθες","Ontem","Ieri" } }, + { "Zoom: +/- buttons", { 0, "Zoom: +/- knoppen","Zoom: +/- Tasten","Zoom : boutons +/-","Zoom: botones +/-","Zoom: pulsanti +/-","Масштаб: кнопки +/-","Масштаб: кнопки +/-","Мащаб: бутони +/-","Зум: тастери +/-","Ζουμ: κουμπιά +/-","Zoom: botões +/-","Zoom: butoane +/-" } }, + { "client connected", { 0, "client verbonden","Client verbunden","client connecté","cliente conectado","client connesso","клиент подключён","клієнт під'єднано","клиент свързан","клијент повезан","ο πελάτης συνδέθηκε","cliente conectado","client conectat" } }, + { "command", { 0, "opdracht","Befehl","commande","comando","comando","команда","команда","команда","команда","εντολή","comando","comandă" } }, + { "diag boot...", { 0, "diag-boot...","Diag-Boot...","démarrage diag...","arranque diag...","avvio diag...","диаг. загрузка...","діаг. запуск...","диаг. старт...","диаг. покретање...","εκκίνηση διαγν...","boot diag...","pornire diag..." } }, + { "drive the UI without the screen; keys still type in text fields", { 0, "bedien de UI zonder scherm; toetsen typen nog in tekstvelden","UI ohne Bildschirm bedienen; Tasten tippen weiter in Textfelder","piloter l'UI sans l'écran ; les touches saisissent le texte","controla la UI sin la pantalla; las teclas siguen escribiendo","usa la UI senza schermo; i tasti scrivono ancora nei campi","управление UI без экрана; клавиши всё ещё печатают в полях","керування UI без екрана; клавіші все ще друкують у полях","управлявай UI без екрана; клавишите още пишат в полетата","управљај UI без екрана; тастери и даље куцају у пољима","χειρισμός UI χωρίς οθόνη· τα πλήκτρα γράφουν στα πεδία","controle a UI sem a tela; as teclas ainda digitam nos campos","controlează UI-ul fără ecran; tastele scriu în continuare în câmpurile text" } }, + { "from", { 0, "van","von","de","de","da","от","від","от","од","από","de","de la" } }, + { "hold the d-pad to unlock", { 0, "houd de d-pad vast om te ontgrendelen","D-Pad halten zum Entsperren","maintenez le d-pad pour déverrouiller","mantén el d-pad para desbloquear","tieni premuto il d-pad per sbloccare","удерживайте d-pad для разблокировки","утримуйте d-pad для розблокування","задръж d-pad за отключване","држи d-pad за откључавање","κρατήστε το d-pad για ξεκλείδωμα","segure o d-pad para desbloquear","ţine d-pad pentru deblocare" } }, + { "last wake: ", { 0, "laatst wakker: ","Letztes Wecken: ","dernier réveil : ","último despertar: ","ultimo risveglio: ","последнее пробуждение: ","останнє пробудження: ","последно събуждане: ","последње буђење: ","τελευταία αφύπνιση: ","último despertar: ","ultima trezire: " } }, + { "lights the keyboard + wakes the screen on an incoming message", { 0, "verlicht het toetsenbord + wekt het scherm bij een inkomend bericht","beleuchtet die Tastatur + weckt den Bildschirm bei einer Nachricht","éclaire le clavier + réveille l'écran à un message entrant","ilumina el teclado + despierta la pantalla al recibir un mensaje","illumina la tastiera + risveglia lo schermo a un messaggio in arrivo","подсвечивает клавиатуру + будит экран при входящем сообщении","підсвічує клавіатуру + будить екран при вхідному повідомленні","осветява клавиатурата + събужда екрана при входящо съобщение","осветљава тастатуру + буди екран при долазној поруци","φωτίζει το πληκτρολόγιο + ξυπνά την οθόνη σε εισερχόμενο μήνυμα","acende o teclado + acorda a tela ao receber uma mensagem","aprinde tastatura + trezeşte ecranul la un mesaj primit" } }, + { "mesh busy", { 0, "mesh bezig","Mesh belegt","mesh occupé","malla ocupada","mesh occupata","сеть занята","мережа зайнята","мрежата заета","мрежа заузета","το mesh απασχολημένο","malha ocupada","mesh ocupat" } }, + { "off = Enter adds a new line; tap Send to send", { 0, "uit = Enter voegt een regel toe; tik Verzenden om te versturen","aus = Enter fügt eine neue Zeile ein; auf Senden tippen","off = Entrée ajoute une ligne ; touchez Envoyer pour envoyer","off = Enter añade una línea nueva; toca Enviar para enviar","off = Invio aggiunge una riga; tocca Invia per inviare","выкл = Enter добавляет строку; нажмите Отправить","вимк = Enter додає рядок; натисніть Надіслати","изкл = Enter добавя нов ред; докосни Изпрати","искључено = Enter додаје нови ред; додирни Пошаљи","off = το Enter προσθέτει γραμμή· πατήστε Αποστολή","off = Enter adiciona nova linha; toque Enviar","oprit = Enter adaugă rând nou; atinge Trimite pentru trimitere" } }, + { "peak --", { 0, "piek --","Spitze --","crête --","pico --","picco --","пик --","пік --","пик --","врх --","κορυφή --","pico --","vârf --" } }, + { "press Volume Down to unlock", { 0, "druk Volume Omlaag om te ontgrendelen","Lautstärke leiser drücken zum Entsperren","appuyez sur Volume Bas pour déverrouiller","pulsa Bajar Volumen para desbloquear","premi Volume Giù per sbloccare","нажмите Громкость вниз для разблокировки","натисніть Гучність вниз для розблокування","натисни Намаляване на звука за отключване","притисни Смањи звук за откључавање","πατήστε Μείωση έντασης για ξεκλείδωμα","pressione Diminuir Volume para desbloquear","apasă Volum jos pentru deblocare" } }, + { "push past a screen edge to scroll content", { 0, "duw voorbij een schermrand om te scrollen","über den Bildschirmrand schieben zum Scrollen","poussez au-delà du bord pour faire défiler","empuja más allá del borde para desplazar","spingi oltre il bordo per scorrere","толкните за край экрана для прокрутки","штовхніть за край екрана для прокрутки","избутай отвъд ръба на екрана за превъртане","гурни преко ивице екрана за померање","σπρώξτε πέρα από την άκρη για κύλιση","empurre além da borda para rolar","împinge peste marginea ecranului pentru a derula conţinutul" } }, + { "score %d", { 0, "score %d","Punkte %d","score %d","puntos %d","punteggio %d","счёт %d","рахунок %d","точки %d","резултат %d","σκορ %d","placar %d","scor %d" } }, + { "tap for details", { 0, "tik voor details","für Details tippen","touchez pour les détails","toca para detalles","tocca per dettagli","коснитесь для деталей","торкніться для деталей","докосни за детайли","додирни за детаље","πατήστε για λεπτομέρειες","toque para detalhes","atinge pentru detalii" } }, + { "zoom", { 0, "zoom","Zoom","zoom","zoom","zoom","масштаб","масштаб","мащаб","зум","ζουμ","zoom","zoom" } }, + { "zoom %d", { 0, "zoom %d","Zoom %d","zoom %d","zoom %d","zoom %d","масштаб %d","масштаб %d","мащаб %d","зум %d","ζουμ %d","zoom %d","zoom %d" } }, + { "~17 dB amp for quiet/remote areas; turn off in noisy spots.", { 0, "~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken.","~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus.","ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante.","amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas.","ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose.","усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных.","підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих.","усилвател ~17 dB за тихи/отдалечени места; изключи в шумни.","појачало ~17 dB за тиха/удаљена места; искључи у бучним.","ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις.","amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos.","amplificator ~17 dB pentru zone liniştite/izolate; opreşte-l în locuri zgomotoase." } }, + { "© OpenStreetMap", { 0, "© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap","© OpenStreetMap" } }, + { "© OpenTopoMap", { 0, "© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap","© OpenTopoMap" } }, }; static const int kI18nCount = (int)(sizeof(kI18n) / sizeof(kI18n[0])); diff --git a/src/ui-touch/i18n.h b/src/ui-touch/i18n.h index 5c24d16..bef65e5 100644 --- a/src/ui-touch/i18n.h +++ b/src/ui-touch/i18n.h @@ -9,7 +9,7 @@ // Keep this enum in sync with kUiLangNames + the table column order in i18n.cpp. enum UiLang : uint8_t { LANG_EN = 0, LANG_NL, LANG_DE, LANG_FR, LANG_ES, LANG_IT, - LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_COUNT + LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_RO, LANG_COUNT }; // Native names for the language picker (e.g. "Nederlands", "Русский", "Ελληνικά"). From 20f84039281d5f331c2392723ff22916f1c08af9 Mon Sep 17 00:00:00 2001 From: Fr Cyril Date: Wed, 29 Jul 2026 09:31:39 -0700 Subject: [PATCH 35/99] =?UTF-8?q?Add=20Romanian=20keyboard=20layout=20+=20?= =?UTF-8?q?accent-popup=20=C4=83/=C5=9F/=C5=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fr Cyril --- src/ui-touch/KeyboardLayouts.cpp | 53 ++++++++++++++++++++++++++++++++ src/ui-touch/KeyboardLayouts.h | 1 + src/ui-touch/UITask.cpp | 23 +++++++------- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/ui-touch/KeyboardLayouts.cpp b/src/ui-touch/KeyboardLayouts.cpp index ef34685..3a39878 100644 --- a/src/ui-touch/KeyboardLayouts.cpp +++ b/src/ui-touch/KeyboardLayouts.cpp @@ -370,6 +370,39 @@ static const lv_btnmatrix_ctrl_t kb_it_upper_ctrl[] = { LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 }; +/* Romanian keeps QWERTY and promotes the three letters the accent popup can't + * offer today straight onto the deck: ă on the home row (like Spanish's ñ) and + * ş / ţ on the bottom row (like Italian's è / ì). â and î stay reachable from + * the a / i accent popups. Cedilla forms (ş/ţ) match the bundled font coverage; + * switch to comma-below (ș/ț) if the extras_* fonts ever gain U+0218-021B. */ +static const char* const kb_ro_lower[] = { + "1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", + "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "ă", LV_SYMBOL_NEW_LINE, "\n", + "ş", "ţ", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", + LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" +}; + +static const lv_btnmatrix_ctrl_t kb_ro_lower_ctrl[] = { + LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7, + LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7, + EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), + LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 +}; + +static const char* const kb_ro_upper[] = { + "1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", + "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "Ă", LV_SYMBOL_NEW_LINE, "\n", + "Ş", "Ţ", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", + LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" +}; + +static const lv_btnmatrix_ctrl_t kb_ro_upper_ctrl[] = { + LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7, + LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7, + EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), + LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2 +}; + struct OsKeyboardLayout { KeyboardLayoutId id; const char* name; @@ -416,6 +449,9 @@ static const OsKeyboardLayout k_os_layouts[] = { { KeyboardLayoutId::IT, "IT", kb_it_lower, kb_it_lower_ctrl, kb_it_upper, kb_it_upper_ctrl }, + { KeyboardLayoutId::RO, "RO", + kb_ro_lower, kb_ro_lower_ctrl, + kb_ro_upper, kb_ro_upper_ctrl }, }; /* ================================================================ @@ -440,6 +476,7 @@ static const HwKeyboardLayout k_hw_layouts[] = { { KeyboardLayoutId::DE, "DE" }, { KeyboardLayoutId::ES, "ES" }, { KeyboardLayoutId::IT, "IT" }, + { KeyboardLayoutId::RO, "RO" }, }; /* Bulgarian phonetic mapping for T-Deck. @@ -671,6 +708,21 @@ static const char* hw_it_upper[26] = { static const char* hw_it_digits[10] = { nullptr, "à", "è", "ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }; static const char* hw_it_digits_shift[10] = { nullptr, "À", "È", "Ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr }; +/* Romanian keeps the standard alpha matrix; the number row surfaces the five + * special letters on compact hardware (same trick as Italian's accented vowels). */ +static const char* hw_ro_lower[26] = { + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z" +}; +static const char* hw_ro_upper[26] = { + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z" +}; +static const char* hw_ro_digits[10] = { nullptr, "ă", "â", "î", "ş", "ţ", nullptr, nullptr, nullptr, nullptr }; +static const char* hw_ro_digits_shift[10] = { nullptr, "Ă", "Â", "Î", "Ş", "Ţ", nullptr, nullptr, nullptr, nullptr }; + /* ================================================================ * Runtime state * ================================================================ */ @@ -755,6 +807,7 @@ static const HwPhoneticMap k_hw_maps[KEYBOARD_LAYOUT_COUNT] = { /* DE */ { hw_de_lower, hw_de_upper, hw_de_digits, hw_de_digits_shift }, /* ES */ { hw_es_lower, hw_es_upper, hw_es_digits, hw_es_digits_shift }, /* IT */ { hw_it_lower, hw_it_upper, hw_it_digits, hw_it_digits_shift }, + /* RO */ { hw_ro_lower, hw_ro_upper, hw_ro_digits, hw_ro_digits_shift }, }; const char* keyboardLayoutMapHwKey(KeyboardLayoutId id, int key, bool shifted) { diff --git a/src/ui-touch/KeyboardLayouts.h b/src/ui-touch/KeyboardLayouts.h index 38c9b5f..36215a7 100644 --- a/src/ui-touch/KeyboardLayouts.h +++ b/src/ui-touch/KeyboardLayouts.h @@ -24,6 +24,7 @@ enum class KeyboardLayoutId : uint8_t { DE = 9, // German (QWERTZ) ES = 10, // Spanish (QWERTY + accent popups) IT = 11, // Italian (QWERTY + accent popups) + RO = 12, // Romanian (QWERTY, ă/ş/ţ on the deck; â/î via accent popups) Count }; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 66682f1..955b1cd 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -5449,6 +5449,7 @@ static KeyboardLayoutId kbDefaultLayoutForUiLang(uint8_t lang) { case LANG_SR: return KeyboardLayoutId::SR; case LANG_EL: return KeyboardLayoutId::EL; case LANG_FR: return KeyboardLayoutId::FR; + case LANG_RO: return KeyboardLayoutId::RO; default: return KeyboardLayoutId::EN; } } @@ -5780,8 +5781,8 @@ static void composerAutoGrowCb(lv_event_t* e) { // the extras_* fallback fonts (Latin-1 + Latin-Extended-A), so they render in // the textarea and chat. Keys carry LV_BTNMATRIX_CTRL_NO_REPEAT (KeyboardLayouts // .cpp) so a hold doesn't auto-repeat — it cleanly long-presses instead. -static const char* const kAccA[] = {"à","á","â","ä","ã","å","ą"}; -static const char* const kAccA_u[] = {"À","Á","Â","Ä","Ã","Å","Ą"}; +static const char* const kAccA[] = {"à","á","â","ă","ä","ã","å","ą"}; +static const char* const kAccA_u[] = {"À","Á","Â","Ă","Ä","Ã","Å","Ą"}; static const char* const kAccE[] = {"è","é","ê","ë","ě","ę"}; static const char* const kAccE_u[] = {"È","É","Ê","Ë","Ě","Ę"}; static const char* const kAccI[] = {"ì","í","î","ï"}; @@ -5794,25 +5795,25 @@ static const char* const kAccN[] = {"ñ","ń"}; static const char* const kAccN_u[] = {"Ñ","Ń"}; static const char* const kAccC[] = {"ç","č","ć"}; static const char* const kAccC_u[] = {"Ç","Č","Ć"}; -static const char* const kAccS[] = {"ß","ś","š"}; +static const char* const kAccS[] = {"ß","ś","š","ş"}; static const char* const kAccY[] = {"ý","ÿ"}; // Czech carons / ring — base letters that otherwise carry no Latin-1 accent. -static const char* const kAccT[] = {"ť"}; -static const char* const kAccT_u[] = {"Ť"}; +static const char* const kAccT[] = {"ť","ţ"}; +static const char* const kAccT_u[] = {"Ť","Ţ"}; static const char* const kAccZ[] = {"ž","ż","ź"}; static const char* const kAccZ_u[] = {"Ž","Ż","Ź"}; static const char* const kAccR[] = {"ř"}; static const char* const kAccR_u[] = {"Ř"}; static const char* const kAccL[] = {"ł"}; static const char* const kAccL_u[] = {"Ł"}; -static const char* const kAccS_u[] = {"Ś","Š"}; +static const char* const kAccS_u[] = {"Ś","Š","Ş"}; struct AccentSet { char key; const char* const* v; uint8_t n; }; static const AccentSet kAccentSets[] = { - {'a',kAccA,7},{'A',kAccA_u,7},{'e',kAccE,6},{'E',kAccE_u,6}, + {'a',kAccA,8},{'A',kAccA_u,8},{'e',kAccE,6},{'E',kAccE_u,6}, {'i',kAccI,4},{'I',kAccI_u,4},{'o',kAccO,6},{'O',kAccO_u,6}, {'u',kAccU,5},{'U',kAccU_u,5},{'n',kAccN,2},{'N',kAccN_u,2}, - {'c',kAccC,3},{'C',kAccC_u,3},{'s',kAccS,3},{'S',kAccS_u,2},{'y',kAccY,2}, - {'t',kAccT,1},{'T',kAccT_u,1},{'z',kAccZ,3},{'Z',kAccZ_u,3}, + {'c',kAccC,3},{'C',kAccC_u,3},{'s',kAccS,4},{'S',kAccS_u,3},{'y',kAccY,2}, + {'t',kAccT,2},{'T',kAccT_u,2},{'z',kAccZ,3},{'Z',kAccZ_u,3}, {'l',kAccL,1},{'L',kAccL_u,1},{'r',kAccR,1},{'R',kAccR_u,1}, }; static const AccentSet* accentLookup(const char* key) { @@ -6060,7 +6061,7 @@ static lv_obj_t* s_accbox_ta = nullptr; // the field the box edits // (Alt)+Space jumps in (handleHwKey()); the rotary encoder then walks // s_accbox_cells (updatePagerEncoder()); Enter (the encoder's own click) // confirms via accentNavConfirm(); Backspace cancels. -static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (7, 'a'/'A') +static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (8, 'a'/'A') static lv_obj_t* s_accbox_cells[kAccentNavMax]; static uint8_t s_accbox_cell_n = 0; static bool s_accentnav_active = false; @@ -11890,7 +11891,7 @@ static void buildDeviceSettings(int sec) { non-EN layout or the loop below reads past the end. */ static const char* k_kb_disp[] = { "Bulgarian", "Russian", "Ukrainian", "Serbian", "Greek", "Arabic (experimental)", - "French", "Dutch", "German", "Spanish", "Italian" + "French", "Dutch", "German", "Spanish", "Italian", "Romanian" }; for (int id = 1; id < KEYBOARD_LAYOUT_COUNT; ++id) { int h = settingsRowLabel(body, y, 4, k_kb_disp[id - 1], COLOR_TEXT, &g_font_12, 56); From 17651bcd9070756531a118061385b4ee05c7d801 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 10:54:20 +0200 Subject: [PATCH 36/99] touch: fix blocked senders, muted notification sound, and a latched Wi-Fi scan gate Three community-reported bugs, two of which turned out to share one cause. #177 blocking a channel sender did nothing. Group packets carry no per-sender pubkey (MyMesh's channel branch passes from_pub = nullptr), so the prefix ignore list is never consulted for channel traffic and the only live filter is the name one. But the block path stored a PUBKEY whenever the sender happened to be a saved contact, so the block was recorded, shown in the blocked list, and filtered nothing. Channel blocks now always write the name entry; a saved contact also keeps its pubkey entry so DMs stay blocked as before. Unblock is now paired, so clearing one entry clears its twin instead of leaving the sender half-blocked. #184 lost notification sound until a restart. s_tile_fetch_pending gates all four chime paths and is only ever ++/--, never reset, so one leaked increment mutes the device for the rest of the boot. queueTileForFetch ignored ensureTileFetchTaskRunning()'s result, and that function creates the QUEUE before it can still fail on the worker's 8 KB contiguous-DRAM stack: queue alive, worker dead, every send incremented a counter nothing could drain. Honor the result. Not a beta_50 regression, the sound path was untouched in that release. #171 Wi-Fi never reconnecting after a reboot. Same worker. Opening the Wi-Fi page drops the link and raises the scan gate, which is lowered only when the tile worker reports s_wifiscan_done. If that worker never starts, the gate latches: autoreconnect stays off and main.cpp suppresses its reconnect retry, so Wi-Fi is dead until a reboot. Added a 30 s recovery deadline, far longer than any real sweep, so it can only fire when the normal completion path is genuinely lost. Also corrected the stale comment that described the signal probe as a flood. It is a zero-hop NODE_DISCOVER_REQ that repeaters answer directly and never re-broadcast, and it is skipped entirely when a direct neighbour was heard in the poll window. That wording is where issue #80's "spams the mesh" concern came from. Verified: all 8 S3 envs plus Tanmatsu build clean. Co-Authored-By: Claude Opus 4.8 --- src/helpers/esp32/TouchPrefsStore.cpp | 14 ++++- src/ui-touch/UITask.cpp | 89 +++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index e764933..f683fa1 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -394,9 +394,17 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) { } // --- Mesh signal auto-discover probe (toggle + poll interval) --------------- -// The interval is entered in whole minutes; clamp >1 min (one flood a minute is -// already aggressive on shared airtime) .. 1 day so a bad/blank entry can't make -// the probe hammer the mesh or effectively never run. +// The interval is entered in whole minutes; clamp 1 min .. 1 day so a bad or blank entry +// can't make the probe run hot or effectively never run. +// +// NOT A FLOOD. This comment used to describe the probe as a flood, which is where the +// "wadamesh spams the mesh every 5 minutes" worry came from (issue #80). It is a ZERO-HOP +// CTL_TYPE_NODE_DISCOVER_REQ (MyMesh::uiSendSignalProbe) — the same node-discovery packet +// the other MeshCore GUIs use. Repeaters answer it DIRECTLY and never re-broadcast it, so +// nothing propagates beyond our immediate neighbours; the fallback when no repeater path is +// known is sendAdvert(false), also zero-hop. The caller additionally SKIPS the probe +// whenever a direct neighbour was heard inside the poll window, so the busier the mesh, the +// less this transmits. static const uint16_t SIG_POLL_MIN_MINS = 1; // 1 min = 60 s (the old fixed cadence) static const uint16_t SIG_POLL_MAX_MINS = 1440; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 66682f1..96b3e42 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -13059,6 +13059,9 @@ static void wifiKickScan() { // here (sequential with main.cpp's wifi loop on the same task — no worker race, and // never eraseap, which wedges this radio). Drop the link, let the disassoc settle, // then kick the worker; wifiScanService rejoins once results land. +// Wall-clock deadline for the scan gate below (0 = not armed). See the recovery +// branch in wifiScanMainService for why this exists. +static uint32_t s_wifiscan_guard_ms = 0; static void wifiScanMainService() { if (s_wifiscan_drop_req) { s_wifiscan_drop_req = false; @@ -13067,6 +13070,7 @@ static void wifiScanMainService() { WiFi.setAutoReconnect(false); // don't auto-rejoin mid-sweep WiFi.disconnect(false, false); // leave the BSS (keep cred + stored AP; NO eraseap) s_wifiscan_drop_ms = millis(); + s_wifiscan_guard_ms = millis(); // arm the stuck-gate deadline (recovery below) s_wifiscan_reconnect_after = true; } else { s_wifiscan_request = true; // already disconnected — scan now @@ -13075,6 +13079,26 @@ static void wifiScanMainService() { s_wifiscan_drop_ms = 0; s_wifiscan_request = true; // disassoc settled -> run the sweep } + // RECOVERY: opening the Wi-Fi page drops the link and raises the scan gate, which is + // normally lowered in wifiScanService() once the worker reports s_wifiscan_done. But + // that flag is set ONLY by the tile-fetch worker, and that worker can fail to start at + // all (its 8 KB contiguous-DRAM stack allocation is best-effort) or sit blocked in a + // long tile/LOS/OTA job. If it never reports, the gate LATCHES: autoreconnect is off + // and main.cpp's 10 s reconnect retry stays suppressed (main.cpp gates it on + // !wifiScanIsActive()), so Wi-Fi never comes back until a reboot — a plausible source + // of "connects on a fresh flash, never reconnects after a reboot" (issue #171). The + // deadline is deliberately far longer than any legitimate sweep (the worker's own scan + // path allows several seconds plus retries) so this can only fire when the normal + // completion path is genuinely lost, never mid-scan. + if (s_wifiscan_guard_ms && wifiScanIsActive() && + (uint32_t)(millis() - s_wifiscan_guard_ms) >= 30000UL) { + s_wifiscan_guard_ms = 0; + s_wifiscan_reconnect_after = false; + s_wifiscan_drop_ms = 0; + wifiScanSetActive(false); // un-gate main.cpp's reconnect retry + WiFi.setAutoReconnect(true); + wifiConfigRequestApply(); // re-begin with the stored cred (main task) + } } #endif @@ -13091,6 +13115,7 @@ static void wifiScanService() { #if !defined(HAS_TANMATSU) if (s_wifiscan_reconnect_after) { // we dropped the link for this sweep — rejoin now s_wifiscan_reconnect_after = false; + s_wifiscan_guard_ms = 0; // normal completion — disarm the stuck-gate deadline wifiScanSetActive(false); WiFi.setAutoReconnect(true); wifiConfigRequestApply(); // main.cpp re-begins with the stored cred (main task) @@ -24819,7 +24844,15 @@ static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { #endif if (WiFi.status() != WL_CONNECTED) return; if (tileFetchSeen(z, x, y)) return; - ensureTileFetchTaskRunning(); + // HONOR the result: ensureTileFetchTaskRunning() creates the QUEUE first and can + // still fail afterwards on the worker's 8 KB contiguous-DRAM stack. Ignoring it left + // a queue with no consumer, so every send below incremented s_tile_fetch_pending with + // nothing to ever drain it. That counter is only ever ++/--, never reset, and it gates + // ALL notification sounds (tdeckPlayNotifySlot and friends bail while it is > 0), so a + // single failed spawn silently killed the chime for the rest of the boot — the "lost + // sound until I restart" report (issue #184). No tile is lost by returning here: with + // no worker the fetch could never have happened anyway. + if (!ensureTileFetchTaskRunning()) return; if (!s_tile_fetch_queue) return; TileFetchReq req = {z, x, y}; if (xQueueSend(s_tile_fetch_queue, &req, 0) == pdTRUE) { @@ -39296,18 +39329,47 @@ static void blockedModalClose() { } } static bool blockedModalIsOpen() { return s_blocked_modal != nullptr; } +// Unblock is PAIRED with the block: blocking a channel sender who is also a saved +// contact writes BOTH a name entry (what the channel RX path matches) and a pubkey +// entry (what the DM path matches), so clearing only one would leave the sender +// still silenced on the other path — an "I unblocked them and they are still gone" +// bug. Each callback therefore clears its own entry and its twin. static void blockedUnblockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_MAX) return; - touchPrefsSetIgnored(&s_blocked_snap[idx * TOUCH_IGNORE_KEY_BYTES], false); + uint8_t pub6[TOUCH_IGNORE_KEY_BYTES]; + memcpy(pub6, &s_blocked_snap[idx * TOUCH_IGNORE_KEY_BYTES], sizeof pub6); + touchPrefsSetIgnored(pub6, false); + // Twin: this pubkey's contact name may also hold a name entry (same prefix match + // the modal uses to label the row). + ContactInfo c; + const int nc = the_mesh.getNumContacts(); + for (int j = 0; j < nc; ++j) { + if (the_mesh.getContactByIdx((uint32_t)j, c) && + memcmp(c.id.pub_key, pub6, TOUCH_IGNORE_KEY_BYTES) == 0) { + if (c.name[0]) touchPrefsSetNameIgnored(c.name, false); + break; + } + } openBlockedUsersModal(); // rebuild the list } static void blockedUnblockNameCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx < 0 || idx >= TOUCH_IGNORED_NAMES_MAX) return; - touchPrefsSetNameIgnored(&s_blocked_names_snap[idx * TOUCH_IGNORED_NAME_LEN], false); + char nm[TOUCH_IGNORED_NAME_LEN]; + snprintf(nm, sizeof nm, "%s", &s_blocked_names_snap[idx * TOUCH_IGNORED_NAME_LEN]); + touchPrefsSetNameIgnored(nm, false); + // Twin: a saved contact with this name may also hold a pubkey entry. + ContactInfo c; + const int nc = the_mesh.getNumContacts(); + for (int j = 0; j < nc; ++j) { + if (the_mesh.getContactByIdx((uint32_t)j, c) && strcmp(c.name, nm) == 0) { + touchPrefsSetIgnored(c.id.pub_key, false); + break; + } + } openBlockedUsersModal(); // rebuild the list } static void openBlockedUsersModal() { @@ -44527,7 +44589,14 @@ bool UITask::ignoreSenderInActiveThread(const char* sender_name) { have = true; } } else if (sender_name && sender_name[0]) { - // Channel / room: resolve the sender display name -> a contact pubkey. + // Channel: block by NAME, ALWAYS — this is the only thing the channel RX path + // can actually match. A group packet carries no per-sender pubkey (MyMesh's + // channel branch calls newMsgFromPubWithMeta with from_pub = nullptr), so the + // prefix ignore-list is never consulted for channel traffic and a pubkey-only + // block is a SILENT NO-OP. That was issue #177: the sender showed up in the + // blocked list, yet their posts kept arriving, because a sender who happened to + // be a saved contact took the pubkey path below and never got a name entry. + const bool name_ok = touchPrefsSetNameIgnored(sender_name, true); ContactInfo c; const int n = the_mesh.getNumContacts(); for (int i = 0; i < n; ++i) { @@ -44537,13 +44606,11 @@ bool UITask::ignoreSenderInActiveThread(const char* sender_name) { break; } } - if (!have) { - // Not a saved contact — e.g. a bot posting inside a joined room, whose - // post carries only a display name (no pubkey to put in the prefix - // ignore-list). Block by NAME instead so it can actually be silenced; - // newMsgImpl drops future posts whose parsed sender matches. - return touchPrefsSetNameIgnored(sender_name, true); - } + // Not a saved contact (e.g. a bot posting in a joined room): the name entry is + // the whole block. A saved contact ALSO gets the pubkey entry below, so their + // DMs stay blocked exactly as before — the name entry is purely additive and is + // what silences their channel posts. + if (!have) return name_ok; } if (!have) return false; touchPrefsSetIgnored(pub, true); From acdfd0a45b1285cce0537094c4b8d504201c42c0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 11:09:17 +0200 Subject: [PATCH 37/99] touch: revive three parked UI fixes from the beta_32 stash (#97, #106, #109) These three had fixes written back at beta_32 but the batch was parked and never shipped. The stash no longer applies (twenty betas of drift; ClockFloorRTC.h and UITask.cpp both conflict), so they are ported by hand onto current main. The five other fixes in that stash are deliberately NOT ported: #98/#100/#116 already shipped in beta_48 and #93/#113 are closed, so re-applying them would duplicate or conflict. #97 the crash-restart dialog's text ran down over its own Cancel/OK buttons. showConfirm used a fixed PSC(160) card with a freely-wrapping label, so any long message overflowed; the crash-report prompt is the worst case. The card height now follows the wrapped text, the message gets its own box sized to exactly the space above the buttons (so it physically cannot reach them) and scrolls when the card hits the screen cap. Keeps the old 160 as a floor, so every short dialog renders exactly as before and only the broken long-text case changes. Added a clamp the original port did not have, so the message area cannot invert at Large/Huge UI scale. #109 the settings sheet opened from the Discovered screen looked truncated and could not be scrolled: centring the card and then lifting it a fixed -46 pushed its top off-screen on the 240px-tall T-Deck. Height is clamped to what the screen has and the lift to the leftover slack. #106 room servers were indistinguishable from companion contacts in the contacts list (same person icon). Rooms now get their own glyph. Not ported: #112's RX-activity dot. The logic is trivial but the status bar has since grown a second placement pass for the tall two-row layout (P4 / round-corners) that the stash predates, so the dot would strand itself at its single-row position there, and its fixed offset collides with the BLE icon's hardcoded narrow-screen position. Needs a device to eyeball, not a blind port. Verified: all 8 S3 envs plus the T-Display P4 (which exercises the CAP_LARGE_SCREEN branch of the reworked dialog) build clean. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 59 +++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 96b3e42..47e5894 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9008,8 +9008,18 @@ static void openDiscoveredSettingsSheetCb(lv_event_t* e) { #endif lv_obj_t* card = lv_obj_create(s_disc_settings_root); lv_obj_remove_style_all(card); - lv_obj_set_size(card, card_w, PSC(188)); - lv_obj_align(card, LV_ALIGN_CENTER, 0, -46); // shifted up so the keyboard clears the hop field + // #109: centring the card and then lifting it a fixed -46 pushed its TOP off-screen on + // the 240px-tall T-Deck (only 218px visible under the bar), so the sheet looked truncated + // and could not be scrolled to reach the rest. Clamp the height to what the screen + // actually has, and clamp the lift to the leftover slack so the top can never go negative. + const lv_coord_t dsc_avail = lv_disp_get_ver_res(nullptr) - STATUSBAR_H; + lv_coord_t dsc_h = PSC(188); + if (dsc_h > dsc_avail - 8) dsc_h = dsc_avail - 8; + lv_coord_t dsc_lift = -46; + const lv_coord_t dsc_slack = (dsc_avail - dsc_h) / 2; + if (-dsc_lift > dsc_slack) dsc_lift = (lv_coord_t)-dsc_slack; + lv_obj_set_size(card, card_w, dsc_h); + lv_obj_align(card, LV_ALIGN_CENTER, 0, dsc_lift); // lift (clamped) so the keyboard clears the hop field lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(card, 8, LV_PART_MAIN); @@ -12541,10 +12551,28 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi lv_obj_add_flag(s_confirm_modal, LV_OBJ_FLAG_FLOATING); lv_obj_move_foreground(s_confirm_modal); - // Card + // Card — the height FOLLOWS the wrapped message. It used to be a fixed PSC(160) with a + // freely-wrapping label, so a long message (the crash-report prompt is the worst case) + // simply ran down over the Cancel/OK buttons (#97). Grow to fit the text, keep the old + // 160 as a floor so every short dialog looks exactly as before, and cap to the screen so + // the card can never overflow; the message area below scrolls if the cap bit. lv_obj_t* card = lv_obj_create(s_confirm_modal); lv_obj_remove_style_all(card); - lv_obj_set_size(card, PCW(210), PSC(160)); +#if CAP_LARGE_SCREEN + const lv_coord_t cf_lblw = PSC(186 - 32); +#else + const lv_coord_t cf_lblw = 186 - 32; +#endif + lv_point_t cf_tsz; + lv_txt_get_size(&cf_tsz, TR(msg), &g_font_14, 0, 2, cf_lblw, LV_TEXT_FLAG_NONE); + const lv_coord_t cf_chrome = (lv_coord_t)(PSC(12) * 2 + PSC(14) + PSC(34)); // pads + gap + buttons + lv_coord_t cf_h = (lv_coord_t)(cf_tsz.y + cf_chrome); + if (cf_h < PSC(160)) cf_h = PSC(160); + const lv_coord_t cf_max = lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12; + if (cf_h > cf_max) cf_h = cf_max; + lv_coord_t cf_msgh = (lv_coord_t)(cf_h - cf_chrome); + if (cf_msgh < PSC(20)) cf_msgh = PSC(20); // never let a tall UI scale invert this + lv_obj_set_size(card, PCW(210), cf_h); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 12); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); @@ -12553,16 +12581,17 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); addCloseXBadge(card, confirmCancelEvt); // X behaves like Cancel - lv_obj_t* lbl = lv_label_create(card); + // Message area: its own box occupying exactly the space ABOVE the buttons, so the text + // physically cannot reach them; scrolls vertically when the card hit the screen cap. + // Width is still shortened (cf_lblw) so the first line doesn't slide under the X badge. + lv_obj_t* msg_box = lv_obj_create(card); + lv_obj_remove_style_all(msg_box); + lv_obj_set_size(msg_box, cf_lblw, cf_msgh); + lv_obj_align(msg_box, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_scroll_dir(msg_box, LV_DIR_VER); + lv_obj_t* lbl = lv_label_create(msg_box); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); - // Shrink width so the first line doesn't slide under the top-right X. - // Push the label down 4 px so the X glyph and the start of the text - // baseline don't touch optically. -#if CAP_LARGE_SCREEN - lv_obj_set_width(lbl, PSC(186 - 32)); -#else - lv_obj_set_width(lbl, 186 - 32); -#endif + lv_obj_set_width(lbl, cf_lblw); lv_label_set_text(lbl, TR(msg)); lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN); @@ -32310,7 +32339,9 @@ static void refreshContactsList() { // must render in g_font_16 for the PUA codepoints to resolve. Blocked // contacts get a RED person icon so they stand out at a glance. lv_obj_t* ic = lv_label_create(rb); - lv_label_set_text(ic, e.is_blocked ? TOUCH_SYM_PERSON : (is_rep ? TOUCH_SYM_ANTENNA : TOUCH_SYM_PERSON)); + lv_label_set_text(ic, e.is_blocked ? TOUCH_SYM_PERSON + : (is_rep ? TOUCH_SYM_ANTENNA + : (e.type == ADV_TYPE_ROOM ? LV_SYMBOL_LOOP : TOUCH_SYM_PERSON))); // rooms discernible at a glance (#106) lv_obj_set_style_text_font(ic, &g_font_16, LV_PART_MAIN); lv_obj_set_style_text_color(ic, lv_color_hex(e.is_blocked ? 0xD7574E : COLOR_SUB), LV_PART_MAIN); lv_obj_align(ic, LV_ALIGN_LEFT_MID, icon_x, 0); From e5d541c17042703de9cfa064343735a260280adc Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 11:16:04 +0200 Subject: [PATCH 38/99] touch: blocking someone in a room no longer silences the whole room Found while fixing #177. A room thread is a CONTACT thread, so it took the DM branch of ignoreSenderInActiveThread, which blocks the thread contact's pubkey. For a room that contact is the SERVER, not the person who posted, and newRoomMsgFromPubWithMeta drops on exactly that pubkey. So blocking one noisy member silently killed every message from the entire room, with nothing in the UI explaining why. Room posts carry only an author display name (passed to newMsgImpl as the sender override), so the author is now blocked BY NAME and the room itself is left alone. Tapping a sender whose name is the room's own name still falls through to the pubkey block, so muting a whole room from inside it still works, and the contacts-list block is untouched. Second half of the same bug: the name filter in newMsgImpl was gated on `channel`, which is false for room posts, so a room name-block was stored and then never enforced. It now also applies when a sender override is present, which is exactly the room case. A plain DM has neither flag, so it keeps using the pubkey filter and is deliberately unaffected: a DM contact who happens to share a display name with a blocked room bot must not disappear. Builds clean on T-Deck and Heltec V4 TFT. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 47e5894..357ad76 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -44614,9 +44614,21 @@ bool UITask::ignoreSenderInActiveThread(const char* sender_name) { uint8_t pub[32]; bool have = false; if (!_active_thread_is_channel) { - // DM: the active thread's contact IS the sender. - if (hasContactPub(_ui_threads[_active_thread_idx].mesh_contact_pub)) { - memcpy(pub, _ui_threads[_active_thread_idx].mesh_contact_pub, sizeof(pub)); + // DM *or* ROOM — both are contact threads. For a ROOM the thread's contact is the + // SERVER, not the person who posted, so blocking its pubkey silenced the ENTIRE room + // (newRoomMsgFromPubWithMeta drops on the room pubkey). Blocking one loudmouth should + // never cost you the whole room. Room posts carry only an author display name (passed + // as the sender override), so block the AUTHOR by name and leave the room intact. If + // the tapped sender IS the room's own name, fall through to the old pubkey block so + // muting a whole room from inside it still works. + const uint8_t* tpub = _ui_threads[_active_thread_idx].mesh_contact_pub; + if (sender_name && sender_name[0] && hasContactPub(tpub)) { + ContactInfo* rc = the_mesh.lookupContactByPubKey(tpub, PUB_KEY_SIZE); + if (rc && rc->type == ADV_TYPE_ROOM && strcmp(rc->name, sender_name) != 0) + return touchPrefsSetNameIgnored(sender_name, true); + } + if (hasContactPub(tpub)) { + memcpy(pub, tpub, sizeof(pub)); have = true; } } else if (sender_name && sender_name[0]) { @@ -45425,7 +45437,13 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex // entirely — no bubble, no notification, no chime. Set from the message // long-press "Block" when the sender isn't a saved contact (gubbinsgalore's // self-advertising room bot). - if (channel && touchPrefsIsNameIgnored(sender)) return; + // Also applies to ROOM posts: those arrive with channel == false but WITH a sender + // override (the resolved author), and a room author can only ever be blocked by name + // since the post carries no per-author pubkey. Without have_sender_override here, a + // room name-block was stored but never enforced. A plain DM has neither flag set, so it + // keeps using the pubkey filter and is unaffected — important, because a DM contact + // sharing a display name with a blocked room bot must not vanish. + if ((channel || have_sender_override) && touchPrefsIsNameIgnored(sender)) return; #endif #if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) From 8e6a6d5f64f90e202148541ff4778ce0550ffef3 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 11:31:34 +0200 Subject: [PATCH 39/99] touch: stop the web mirror silently dropping typed characters VE2CCK reported losing "a lot of characters" while typing into the web mirror from a phone, and put it down to his phone's autocomplete not travelling over the link. It is actually our bug. Browser keystrokes land in a 32-entry SPSC ring pushed from the network thread, pushKey DISCARDS the key when that ring is full (silently, no counter, no log), and the UI drained only 16 per loop iteration. A loop iteration can be tens of ms on a T-Deck (LVGL frame, SD write, radio), and an autocomplete or a paste delivers a whole word at once, so a burst overflowed 32 easily and the rest vanished. Typing slowly worked, which is why it read as an autocomplete quirk. Ring 32 -> 256 (512 bytes; the ceiling while _khead/_ktail remain uint8_t, noted in the comment so nobody raises it further without widening them), and both drain sites 16 -> 64 so a burst clears in one pass instead of trickling while newer keys push the tail along. No API or protocol change, and nothing else reads this ring. This matters more than it looks: he is using the mirror to screen-record how-to guides for his radio club, so typing fidelity is the feature. Builds clean on T-Deck and Heltec V4 TFT. Co-Authored-By: Claude Opus 4.8 --- src/helpers/esp32/WebMirror.h | 10 +++++++++- src/ui-touch/UITask.cpp | 8 ++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/helpers/esp32/WebMirror.h b/src/helpers/esp32/WebMirror.h index e01a8eb..c9a2aee 100644 --- a/src/helpers/esp32/WebMirror.h +++ b/src/helpers/esp32/WebMirror.h @@ -116,7 +116,15 @@ private: volatile uint8_t _ppressed = 0; volatile uint8_t _pknown = 0; - static const int KEY_RING = 32; // typed keys awaiting the UI thread (SPSC) + // Typed keys awaiting the UI thread (SPSC). 32 was far too small: the browser pushes from + // the network thread while the UI drains only a handful per loop iteration, and one + // iteration can be tens of ms (LVGL frame, SD write, radio). Anything arriving FASTER than + // that drain overflowed, and pushKey discards the overflow SILENTLY -- which is exactly + // what a phone autocomplete or a paste looks like, because it delivers a whole word at + // once. Reported over the web mirror as "missing a lot of characters while typing". + // 256 entries costs 512 bytes and is the MAXIMUM this ring can hold while _khead/_ktail + // stay uint8_t; anything larger REQUIRES widening those two or the modulo silently wraps. + static const int KEY_RING = 256; uint16_t _keys[KEY_RING]; volatile uint8_t _khead = 0, _ktail = 0; volatile bool _kb_focused = false, _kb_dirty = false; // editable-field focus signal diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 357ad76..b57c93d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -46493,7 +46493,10 @@ void UITask::loop() { // injected via the same path as the hardware keys (printable chars type in; 0x08 = // backspace, 0x0D = enter/send). uint16_t wk; - for (int i = 0; i < 16 && g_web_mirror.popKey(&wk); ++i) + // Drain generously: a paste or a phone autocomplete arrives as one burst, and a low cap + // left the rest sitting in the ring until the next iteration (where it could be pushed + // out by newer keys and silently lost). Each key here is cheap. + for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) handleHwKey((int)wk); #else // On-screen-keyboard boards (V4/RAK): the browser's laptop keyboard should type too, not @@ -46502,7 +46505,8 @@ void UITask::loop() { // on-screen key tap and syncs to the real field on Enter. Enter fires the keyboard's // ready cb (send/next); backspace deletes; other printables type in. uint16_t wk; - for (int i = 0; i < 16 && g_web_mirror.popKey(&wk); ++i) { + // Same generous drain as the physical-keyboard branch above (burst paste / autocomplete). + for (int i = 0; i < 64 && g_web_mirror.popKey(&wk); ++i) { if (!fta) continue; // no editable field focused -> drain + drop if (wk == 0x0D || wk == 0x0A) lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr); else if (wk == 0x08 || wk == 0x7F) lv_textarea_del_char(fta); From 8ae0df94002a4428466de2f4a9f59e5f8bec96cb Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 11:38:36 +0200 Subject: [PATCH 40/99] touch: stop the deferred-login branch eating the companion app's responses (#124) Partial fix for "no ping and telemetry response" reported from the device AND the phone app. onContactResponse's deferred-login branch runs before every pending_* matcher and, while armed, consumed ANY response from that contact and returned. Its comment justifies this with "we have sent no REQ to this contact, so any RESPONSE here is the login reply" -- true of the UI, but the companion app can independently have a STATUS / TELEMETRY / BINARY request in flight to the same node. There is also no distinct login-failure code on the wire (a failure is just "not RESP_SERVER_LOGIN_OK"), so a non-OK frame is indistinguishable from the app's reply, and the app's response was swallowed with the phone left waiting. A LOGIN_OK is unambiguously ours, so it is still always consumed. Otherwise the branch now falls through when the app has a request pending on that same contact, letting its matcher see the frame; the UI's own reply deadline disarms us, which is all the early disarm here ever provided. Behaviour is unchanged whenever the app is not waiting, which is the common case. Deliberately NOT changed: sendStatusPingForUI / sendTelemetryRequestForUI zero the app's pending_status / pending_telemetry on entry. That looks like deliberate arbitration so a UI-initiated request routes to the UI rather than the phone, and removing it could double-handle a response. Wants its own change and a bench test. Note this is unlikely to be the whole of #124: the leading theory remains the repeater's per-client replay high-water mark, which no client-side change can undo. Builds clean on T-Deck and Heltec V4 TFT. Co-Authored-By: Claude Opus 4.8 --- src/MyMesh.cpp | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index fe32a3c..d9ad2e5 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -2241,14 +2241,30 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, if (_ui_login_then && len > 4 && memcmp(&_ui_login_then, contact.id.pub_key, 4) == 0) { const bool login_ok = (data[4] == RESP_SERVER_LOGIN_OK) || (len > 5 && memcmp(&data[4], "OK", 2) == 0); - const UiReqKind k = _ui_login_then_kind; - cancelUIDeferredLogin(); - if (login_ok) { - ContactInfo& rc = const_cast(contact); - if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc); - else sendStatusPingForUI(rc); + // The comment above assumes ANY response arriving while armed must be our login reply. + // That holds for the UI, but the COMPANION APP can have its own STATUS / TELEMETRY / + // BINARY request in flight to this same node, and there is no distinct "login failed" + // code on the wire (a failure is merely "not RESP_SERVER_LOGIN_OK"), so a non-OK frame + // is indistinguishable from the app's reply. Swallowing it here ate the app's response + // and returned, leaving the phone waiting forever — a contributor to the "no ping or + // telemetry response, from the device AND the app" reports (#124). + // A LOGIN_OK is unambiguously ours, so always take it. Otherwise, if the app is waiting + // on this same contact, fall through and let its matcher have the frame; our own reply + // deadline still disarms us, which is all the early disarm here ever bought. + const bool app_waiting = + (pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0) || + (pending_telemetry && memcmp(&pending_telemetry, contact.id.pub_key, 4) == 0) || + (pending_req && memcmp(&pending_req, contact.id.pub_key, 4) == 0); + if (login_ok || !app_waiting) { + const UiReqKind k = _ui_login_then_kind; + cancelUIDeferredLogin(); + if (login_ok) { + ContactInfo& rc = const_cast(contact); + if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc); + else sendStatusPingForUI(rc); + } + return; // login frame consumed (OK fired the REQ; fail disarmed) } - return; // login frame consumed (OK fired the REQ; fail disarmed) } #endif From 162ec43bfaa2928d91fed78d4711db6a59da1177 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 12:47:20 +0200 Subject: [PATCH 41/99] touch: cut the advert-driven SPIFFS churn behind the multi-second mesh stalls Two reporters on card-less Heltec V4s saw the whole UI freeze for 5-60 s at a time with BLE, WiFi and GPS all off and no user activity, and BOTH of a reporter's two devices froze at the same moment. About -> "Loop stalls" named the culprit unambiguously: tag `mesh`, with peaks of 11451 / 12175 / 14592 / 18063 / 19113 ms. That is inside the_mesh.loop(), i.e. the advert receive path, and the cost is SPIFFS garbage collection on a partition kept permanently churned by writes that happen per advert. The simultaneity across two units is the giveaway: the trigger is a flooded advert both of them receive, after which each runs the same deterministic write. This is the residue of beta_40's "card-less contacts-save stall fix", which capped how OFTEN the contacts rewrite happens without reducing its cost, and left the highest-frequency writer untouched. Two frequency reductions, both keeping writes ATOMIC: 1. putBlobByKey now persists an advert blob once per key per boot. The core calls it on EVERY advert (outside the auto-add block, so for already-known contacts too) and each call is a full create+truncate+write. With ~200 known nodes re-advertising that is a flash write every few seconds, forever, purely to keep the raw advert packet fresh for the Share-contact feature. The content is effectively static, and getBlobByKey still reads the file we already wrote, so there is no cache to keep coherent. Steady state on the packet path becomes zero flash I/O. Costs 2 KB of DRAM. 2. A save triggered by an add/remove now has its own 30 s floor. It previously bypassed the 5-minute refresh window entirely, so on a growing mesh every newly-heard node forced its own full 30 KB rewrite (200 contacts x 152 B). A burst of new nodes now coalesces into one rewrite. Kept short on purpose: a new contact still needs to reach flash promptly, and losing one to an unclean power cut only costs a rediscovery on that node's next advert. Deliberately NOT done: making the contacts save incremental (seek + one 152-byte record). It is tempting since the file is a flat fixed-stride array, and it would cut churn far harder, but it abandons the tmp+rename atomicity that beta_46 added to fix real contact data loss. Both reporting devices show "Last reset: Brownout", so non-atomic in-place writes on that hardware would trade a stall for corrupted contact records. Frequency first, atomicity intact. Also seen in those same screenshots and NOT addressed here: ui:gps stalls of 3077 / 3769 / 3848 ms on a device whose owner says GPS is switched off, and ui:lvgl stalls up to 2227 ms. Separate issues, worth their own investigation. Verifiable by the same diagnostic that found it: the `mesh` entries in About -> Loop stalls should stop appearing. Builds clean on Heltec V4 TFT and T-Deck. Co-Authored-By: Claude Opus 4.8 --- src/MyMesh.cpp | 18 ++++++++++++++++++ src/MyMesh.h | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index d9ad2e5..621c690 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -142,6 +142,9 @@ static int s_companion_ota_pinned_reply_target = -1; // that freezes the loop, and re-adverts (last-heard/path refreshes) otherwise churn // it constantly. A change in the contact SET still saves promptly (see MyMesh::loop). #define CONTACTS_REFRESH_SAVE_INTERVAL 300000 +// Minimum gap between contacts saves that were triggered by an ADD/REMOVE (count change). +// Coalesces a burst of newly-heard nodes into one atomic rewrite instead of one per node. +#define CONTACTS_ADD_SAVE_MIN_INTERVAL 30000 // Our self-chosen room-session keep-alive interval (secs). Room servers zero the // legacy suggested-interval field in LOGIN_OK, so the client picks. 128 is the // value upstream itself recommended while the field was live (CLIENT_KEEP_ALIVE_SECS @@ -4088,6 +4091,7 @@ void MyMesh::saveContacts() { // freshly-written contact set + resets the refresh window. _last_saved_contacts_n = getNumContacts(); _next_contacts_refresh_save = futureMillis(CONTACTS_REFRESH_SAVE_INTERVAL); + _next_contacts_add_save = futureMillis(CONTACTS_ADD_SAVE_MIN_INTERVAL); } void MyMesh::enterCLIRescue() { @@ -4550,6 +4554,20 @@ void MyMesh::loop() { && !millisHasNowPassed(_next_contacts_refresh_save)) { defer = true; // no add/remove + still inside the refresh window on GC-prone flash } + // ...but a CHANGED contact count used to bypass the window entirely and rewrite the whole + // table immediately, every time. On a busy mesh that is the common case, not the rare one: + // each newly-heard node auto-added (and every transient/anon slot) bumps the count, so a + // burst of new nodes = a burst of full 30 KB rewrites 5 s apart, which is what drives the + // GC stalls a reporter measured at 11-19 s on two card-less V4s. Give add/remove its own + // short floor so a burst coalesces into ONE rewrite. Deliberately short (30 s, vs 5 min for + // pure refreshes): a new node still needs to reach flash promptly, and an unclean power loss + // inside the window only costs a rediscovery on that node's next advert, whereas the stall + // it prevents freezes the entire UI. The write stays ATOMIC (tmp + rename) — important on + // hardware that browns out, which is exactly what these reporters' boards do. + if (!defer && _store->contactsOnInternalFlash() + && !millisHasNowPassed(_next_contacts_add_save)) { + defer = true; + } #endif if (defer) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // re-check soon; don't rewrite yet diff --git a/src/MyMesh.h b/src/MyMesh.h index 8d43522..9a4e4ff 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -1001,7 +1001,29 @@ private: int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } + // The core calls this on EVERY received advert (BaseChatMesh::onAdvertRecv, outside the + // auto-add block, so for known contacts too) and each call is a full create+truncate+write + // of a small file. On a card-less board that file lives on SPIFFS, so ~200 known nodes + // re-advertising means a flash write every few seconds forever — churn that keeps the + // partition permanently GC-prone and feeds the multi-second "mesh" loop stalls. + // + // The blob is the raw advert packet and its ONLY consumer is Share/export contact + // (BaseChatMesh::shareContact via getBlobByKey). Its content for a given node is + // effectively static — name, type, location. So persist it ONCE PER KEY PER BOOT and skip + // the redundant rewrites: after the first advert from each node the packet path does no + // flash I/O at all. Reads are unaffected because getBlobByKey above still reads the file we + // already wrote, so there is no cache to keep coherent. A node that RENAMES itself keeps its + // old shared blob until the next reboot, which is a fair price for removing the churn. + // Cost: kBlobSeenSlots * 4 bytes of DRAM. Move to PSRAM if static DRAM ever gets tight. bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override { + if (key_len >= 4) { + uint32_t pfx; + memcpy(&pfx, key, 4); + if (pfx == 0) pfx = 1; // 0 is the empty-slot marker + const uint16_t slot = (uint16_t)((pfx ^ (pfx >> 13) ^ (pfx >> 23)) & (kBlobSeenSlots - 1)); + if (_blob_seen[slot] == pfx) return true; // already on flash this boot — skip the write + _blob_seen[slot] = pfx; // collision just costs one extra write later + } return _store->putBlobByKey(key, key_len, src_buf, len); } @@ -1064,6 +1086,14 @@ private: // the first save. Kept in sync by every MyMesh::saveContacts() call. int _last_saved_contacts_n = -1; uint32_t _next_contacts_refresh_save = 0; + // Separate, much SHORTER floor for saves caused by an add/remove. Those used to bypass the + // refresh window completely, so on a growing mesh every newly-heard node forced its own full + // rewrite. See CONTACTS_ADD_SAVE_MIN_INTERVAL. + uint32_t _next_contacts_add_save = 0; + // Pubkey prefixes whose advert blob has already been persisted this boot — see + // putBlobByKey. Sized above a full contact list so the common case never thrashes. + static const uint16_t kBlobSeenSlots = 512; // power of two (mask), 2 KB total + uint32_t _blob_seen[kBlobSeenSlots] = {0}; TransportKey send_scope; TransportKey _chan_scope_saved; // push/popChannelScope stash From d6c0321472a1d4a91ae8f5742e3e481d6cd9c4d9 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 13:19:13 +0200 Subject: [PATCH 42/99] touch: P4 LoRa antenna control, to settle the outbound-signal deficit Field reports say a T-Display P4 transmits far weaker than it receives: one user's repeater hears his P4 at -10 dB while the P4 hears the repeater at +12, and his T-Deck is +12/+12. Decisively, the SAME P4 under other P4 firmware gives symmetric numbers, so it is not his hardware, and that firmware also offers an internal/external antenna choice which we do not. Reading the variant, our whole RF path rests on one never-validated assumption. XL9535 IO1 is labelled "SKY13453 VCTL (LoRa TX/RX path)" with the comment "polarity TBD on-device", and we flip it around every transmit. But this expander has NO other antenna-select line -- all 16 IOs are power rails, screen, touch, ethernet, C6, SD and the SX1262 reset/DIO1. If IO1 is actually the internal/external ANTENNA select, then toggling it per transmit means we send on one antenna and listen on the other, which is exactly a large outbound-only deficit on a unit with an external antenna fitted. A second user reporting symmetric +11.8/+12.0 does not refute it: that was a two-floor link where SNR saturates in both directions and would mask the loss. I will not gamble on that assumption: if IO1 really is a TX/RX switch, pinning it would break transmit or receive outright. So this adds a Radio setting (P4 only): Auto (switch per transmit) - the existing behaviour, and the DEFAULT, so an untouched device is bit-for-bit unchanged Pinned A / Pinned B - hold the line in one state for both TX and RX Whichever pinned option gives symmetric Trace SNR both ways is the correct antenna, and that result also tells us what IO1 actually is. The labels are deliberately A/B rather than Internal/External because we do not yet know which is which. Applied live on change and re-applied at boot, next to the V4 FEM-LNA precedent. Stored as touch-cfg v41, a trailing field defaulting to 0, so existing installs migrate with no behaviour change. Builds clean on all 8 S3 envs plus the T-Display P4. Cannot be verified here: no P4 is on USB, and the answer needs an on-air SNR comparison anyway. Co-Authored-By: Claude Opus 4.8 --- src/helpers/esp32/TouchPrefsStore.cpp | 25 ++++++++++++++- src/helpers/esp32/TouchPrefsStore.h | 5 +++ src/ui-touch/UITask.cpp | 46 +++++++++++++++++++++++++++ variants/tdisplay_p4/Xl9535.h | 25 ++++++++++++++- 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index f683fa1..d448d95 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 40; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never) +static const uint8_t TOUCH_CFG_VER = 41; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4: pin the XL9535 RF/antenna switch instead of toggling it per TX; 0=auto keeps the legacy behaviour) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -112,6 +112,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing) + uint8_t p4_antenna; // T-Display P4 LoRa antenna/RF-switch (XL9535 IO1): 0=auto (legacy per-TX toggle), 1=pin LOW, 2=pin HIGH — v41 (trailing) }; static TouchCfg s_cfg; @@ -158,6 +159,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.lock_color = DEFAULT_LOCK_COLOR; c.accent = DEFAULT_ACCENT; c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback + c.p4_antenna = 0; // 0 = auto: legacy per-TX toggle (unchanged behaviour) c.sig_probe_en = DEFAULT_SIG_PROBE_EN; c.sig_poll_min = DEFAULT_SIG_POLL_MIN; c.tz_zone = 0; // 0 = Europe (CET/CEST) — preserves prior behaviour @@ -408,6 +410,27 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) { static const uint16_t SIG_POLL_MIN_MINS = 1; // 1 min = 60 s (the old fixed cadence) static const uint16_t SIG_POLL_MAX_MINS = 1440; +// --- T-Display P4 LoRa antenna / RF-switch mode ------------------------------ +// XL9535 IO1 drives the board's SKY13453. Our variant assumes it is a TX/RX path switch and +// toggles it around every transmit (target.h onBeforeTransmit/onAfterTransmit), but that was +// never validated on hardware ("polarity TBD on-device") and the expander has NO separate +// internal/external antenna line -- while other P4 firmware DOES offer an internal/external +// choice. If IO1 is in fact the ANTENNA select, toggling it per TX means transmitting on one +// antenna and receiving on the other, which matches a field report of -10 dB at the repeater +// against +12 dB inbound on a unit with an external antenna fitted (symmetric on the same +// hardware under other firmware). 0 keeps the legacy toggle so nothing changes by default; +// 1/2 pin the line so both directions use one antenna and the user can find out which is which. +uint8_t touchPrefsGetP4Antenna() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.p4_antenna > 2 ? 0 : s_cfg.p4_antenna; +} +bool touchPrefsSetP4Antenna(uint8_t mode) { + if (mode > 2) mode = 0; + if (!s_begun) touchPrefsBegin(); + s_cfg.p4_antenna = mode; + return cfgFlush(); +} + bool touchPrefsGetSigProbeEnabled() { if (!s_begun) touchPrefsBegin(); return s_cfg.sig_probe_en != 0; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 4e24e5b..027cb4a 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -552,6 +552,11 @@ bool touchPrefsSetGpsBaud(uint32_t baud); bool touchPrefsGetSigProbeEnabled(); bool touchPrefsSetSigProbeEnabled(bool on); uint16_t touchPrefsGetSigPollMins(); + +/** T-Display P4 LoRa antenna / RF-switch mode: 0 = auto (legacy per-TX toggle of XL9535 IO1), + * 1 = pin the line LOW, 2 = pin it HIGH. See the notes in the .cpp. */ +uint8_t touchPrefsGetP4Antenna(); +bool touchPrefsSetP4Antenna(uint8_t mode); bool touchPrefsSetSigPollMins(uint16_t mins); #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b57c93d..a5b4ac4 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9549,6 +9549,15 @@ static void radioScopeDirectToggleCb(lv_event_t* e) { touchPrefsSetScopeDirect(on); the_mesh.setScopeDirectFloods(on); } +#if defined(HAS_TDISPLAY_P4) +// T-Display P4 LoRa antenna / RF-switch mode. Persists + applies live (no reboot). +static void radioP4AntennaSelectCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + const uint8_t m = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e)); + touchPrefsSetP4Antenna(m); + xl9535.setAntennaMode(m); // takes effect on the very next transmit +} +#endif #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 high-gain FEM LNA (~17 dB external receive amp). Persists + applies live. static void radioFemLnaToggleCb(lv_event_t* e) { @@ -9826,6 +9835,38 @@ static void buildRadioSettings() { mk_section("SIGNAL"); +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: the SKY13453 on XL9535 IO1. The firmware assumes it is a TX/RX path switch and + // flips it around every transmit, but that was never confirmed on hardware and this board has no + // other antenna-select line — while other P4 firmware offers an internal/external choice. If IO1 + // IS the antenna select, toggling it per transmit means sending on one antenna and listening on + // the other, which matches reports of a ~20 dB outbound deficit with a healthy inbound signal. + // Pinning the line makes both directions use ONE antenna. Try each and keep whichever gives + // roughly equal SNR in both directions (Trace SNR to a nearby repeater). + mk_label("LoRa antenna (P4)"); + { + lv_obj_t* dd = lv_dropdown_create(body); + lv_obj_set_size(dd, lv_pct(100), SC(34)); + lv_obj_set_pos(dd, 2, y); + lv_dropdown_set_options(dd, TR("Auto (switch per transmit)\nPinned A\nPinned B")); + lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); + lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN); + lv_obj_t* antlist = lv_dropdown_get_list(dd); + lv_obj_set_style_bg_color(antlist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); + lv_obj_set_style_text_color(antlist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_set_style_text_font(antlist, &g_font_12, LV_PART_MAIN); + lv_dropdown_set_selected(dd, touchPrefsGetP4Antenna()); + lv_obj_add_event_cb(dd, radioP4AntennaSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); + y += SC(44); + y += settingsRowLabel(body, y, 0, + TR("If sending is much weaker than receiving, try Pinned A / B and compare Trace SNR both ways."), + COLOR_SUB, &g_font_12, 0) + 2; + } +#endif + #if defined(HELTEC_LORA_V4_TFT) // Heltec V4.3 only: the external FEM's high-gain receive amplifier (~17 dB). Bypassed by // default; a big win in quiet/remote sites, but can desensitize in noisy areas. This is @@ -44371,6 +44412,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // matching the hardware). No-op on a V4.2 board (femLnaControllable() == false). if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna()); #endif +#if defined(HAS_TDISPLAY_P4) + // T-Display P4 LoRa antenna / RF-switch: apply the saved mode at boot. Default 0 = auto, + // which is the original per-transmit toggle, so an untouched device behaves exactly as before. + xl9535.setAntennaMode(touchPrefsGetP4Antenna()); +#endif // Buffered LoRa receive (experimental, default OFF): apply the saved opt-in. if (touchPrefsGetRxQueue()) radio_driver.rxQueueEnable(true); diff --git a/variants/tdisplay_p4/Xl9535.h b/variants/tdisplay_p4/Xl9535.h index 2ee13c3..49d3648 100644 --- a/variants/tdisplay_p4/Xl9535.h +++ b/variants/tdisplay_p4/Xl9535.h @@ -53,11 +53,34 @@ public: // Convenience for the SX1262 glue (RESET + DIO1 live here). void sx1262Reset(); // active-low pulse on IO_SX1262_RST bool sx1262Dio1() { return read(IO_SX1262_DIO1); } - void rfSwitchTx(bool tx) { write(IO_RF_SWITCH, tx); } // polarity TBD on-device + // IO1 drives the board's SKY13453. We ASSUME it is a TX/RX path switch and flip it around every + // transmit, but that was never validated on hardware, and this expander has NO separate + // internal/external antenna line — while other P4 firmware DOES offer an internal/external + // choice. If IO1 is really the ANTENNA select, toggling it per TX means transmitting on one + // antenna and receiving on the other, which is exactly what a field report of -10 dB outbound + // against +12 dB inbound looks like on a unit with an external antenna fitted (and symmetric on + // that same hardware under other firmware). Until it is confirmed on a device, let the user PIN + // the line instead of toggling it: + // 0 = auto -> legacy behaviour, toggle per TX (default; nothing changes) + // 1 = LOW -> hold low for both TX and RX + // 2 = HIGH -> hold high for both TX and RX + // Whichever pinned setting gives symmetric SNR in both directions is the right antenna — and + // that answer also tells us what IO1 actually is. + void setAntennaMode(uint8_t mode) { + _ant_mode = (mode > 2) ? 0 : mode; + if (_ant_mode) write(IO_RF_SWITCH, _ant_mode == 2); // pinned: set once, never toggled again + else write(IO_RF_SWITCH, false); // auto: back to the idle state + } + uint8_t antennaMode() const { return _ant_mode; } + void rfSwitchTx(bool tx) { + if (_ant_mode) return; // pinned — the path must NOT differ between TX and RX + write(IO_RF_SWITCH, tx); // auto (legacy): assumes a TX/RX switch; polarity unverified + } private: uint8_t _addr = 0x20; bool _ok = false; + uint8_t _ant_mode = 0; // 0 = auto/legacy per-TX toggle, 1 = pinned LOW, 2 = pinned HIGH uint8_t _out[2] = {0xFF, 0xFF}; // shadow of output regs (port0, port1) uint8_t _cfg[2] = {0xFF, 0xFF}; // shadow of config regs (1=input) void writeReg(uint8_t reg, uint8_t val); From fe87ff02c519b93a37f81cc52629a84b2559c25d Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 13:39:08 +0200 Subject: [PATCH 43/99] =?UTF-8?q?touch:=20fix=20the=20P4=20clock=20?= =?UTF-8?q?=E2=80=94=20mirror=20the=20mesh=20RTC=20into=20the=20ESP32=20sy?= =?UTF-8?q?stem=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI reads the ESP32 *system* clock for every displayed timestamp (status bar, chat bubbles) while protocol timestamps come from ClockFloorRTC. On a board with no RTC chip those are the same clock, so they cannot disagree. The T-Display P4 (and the ThinkNode M9) carry a PCF8563, and AutoDiscoverRTCClock::setCurrentTime is an if/else chain: with a chip present it writes ONLY the chip and never reaches the ESP32RTCClock fallback. So the system clock kept ESP32RTCClock::begin()'s power-on seed forever. That seed is 1715770351 == exactly ClockFloorRTC::MIN_VALID_EPOCH, which explains both halves of the P4 field report: - the UI showed 15 May 2024 while sent messages carried the correct time - "Sync clock from system" then poisoned the good clock: it fed that seed back in as a real set and cleared the MIN check by being precisely equal to it (the old `t < 100000` guard only caught a clock counting up from 0) Fixed in the one funnel every time source already goes through: - ClockFloorRTC::setCurrentTime mirrors every ACCEPTED value into the system clock, so NTP-via-C6, GPS and the phone app all correct the display too. Validation runs first, so 1902/2043-class garbage never reaches it either. - new seedSystemClock(), called at boot right after seedFloor(): a battery-backed chip already knows the time, so the UI reads correctly with no network and no GPS fix. After seedFloor, so a dead chip yields the persisted floor rather than the seed. - the sync button rejects an untouched seed and now reports honestly instead of always claiming "Clock synced". No-op on the seven chipless boards: the fallback already wrote the same value through settimeofday. GPS re-syncs are rate-capped to ~30 min, so the mirror adds no measurable work. Builds clean on all 8 S3 envs + the P4 IDF target; compile-verified only, the P4 is still off the USB bus pending a power cycle. Co-Authored-By: Claude Opus 4.8 --- src/helpers/ClockFloorRTC.h | 35 +++++++++++++++++++++++++++++++++++ src/ui-touch/UITask.cpp | 26 ++++++++++++++++++++------ src/ui-touch/UITask.h | 3 ++- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/helpers/ClockFloorRTC.h b/src/helpers/ClockFloorRTC.h index 7163786..934e985 100644 --- a/src/helpers/ClockFloorRTC.h +++ b/src/helpers/ClockFloorRTC.h @@ -1,6 +1,7 @@ #pragma once #include +#include // Monotonic clock floor for outgoing protocol timestamps (issue #89 follow-up). // @@ -60,9 +61,43 @@ public: void setCurrentTime(uint32_t time) override { if (time < MIN_VALID_EPOCH || time > MAX_PLAUSIBLE_EPOCH) return; // garbage set — ignore, whatever the source AutoDiscoverRTCClock::setCurrentTime(time); + pushSystemClock(time); if (_floor > time + TRUSTED_BACK_CAP) _floor = time; } + // The UI reads the ESP32 *system* clock for every displayed time (status bar, chat + // bubbles, logs — see the note at addMessage()), while protocol timestamps come from + // this class. On a board with NO RTC chip they are the same clock: AutoDiscoverRTCClock + // falls through to ESP32RTCClock, whose set IS settimeofday(), so the two can never + // disagree. With a chip present (the T-Display P4 and the ThinkNode M9 both carry a + // PCF8563 at 0x51 and call rtc_clock.begin(); the T-LoRa Pager has one too but + // deliberately skips begin(), since its PCF85063A is register-incompatible with the + // core's 8563 driver) the base class writes ONLY the chip, so the system clock keeps + // ESP32RTCClock::begin()'s power-on seed forever. That seed is + // exactly MIN_VALID_EPOCH, which is why the P4 showed 15 May 2024 in the UI while sent + // messages carried the correct time — and why "Sync clock from system" then poisoned + // the good clock: it fed that seed back in as a real set, clearing the MIN check by + // being precisely equal to it. + // + // So mirror every ACCEPTED value into the system clock. The validation above has + // already run, so 1902/2043-class garbage never reaches the display either. + static void pushSystemClock(uint32_t t) { + struct timeval tv; + tv.tv_sec = (time_t)t; + tv.tv_usec = 0; + settimeofday(&tv, nullptr); + } + + // Boot seed. An RTC chip is battery-backed, so it already knows the time while the + // system clock is still on the power-on seed — pull it across once so the UI reads + // correctly with no network and no GPS fix. Goes through getCurrentTime(), so the + // garbage-future hatch and the persisted floor both apply. No-op on the chipless + // boards (it reads the system clock and writes the same value back). + void seedSystemClock() { + const uint32_t t = getCurrentTime(); + if (t >= MIN_VALID_EPOCH && t <= MAX_PLAUSIBLE_EPOCH) pushSystemClock(t); + } + // Boot-time seed from the persisted floor (only ever raises), and the getter // the persister reads back. Benign u32 races: both cores may touch _floor. // A poisoned persisted value (saved while the hw clock asserted garbage) is refused. diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index a5b4ac4..759b8a3 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -8596,8 +8596,8 @@ static void saveExperimentalCb(lv_event_t* e) { static void syncClockCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return; - g_lv.task->setDeviceTimeFromSystemClock(); - g_lv.task->showAlert(TR("Clock synced"), 900); + const bool ok = g_lv.task->setDeviceTimeFromSystemClock(); + g_lv.task->showAlert(ok ? TR("Clock synced") : TR("No system time yet"), 900); } // Chat-save fallback dropdown (Settings -> General): selection index -> number @@ -43667,6 +43667,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // backwards (server replay guards silently drop those). Seed before the UI // generates traffic; written back rate-capped in loop() + on shutdown(). rtc_clock.seedFloor(touchPrefsGetClockFloor()); + // ...then push the resulting time into the ESP32 system clock, which is what every + // displayed timestamp reads. On a board with a battery-backed RTC chip (P4) the chip + // knows the time but the system clock is still on its power-on seed, so the UI would + // otherwise show 15 May 2024 until a network/GPS sync landed. Order matters: after + // seedFloor(), so a dead chip still yields the persisted floor instead of the seed. + rtc_clock.seedSystemClock(); #endif // GPS resume: initBasicGPS() always leaves the module stopped at boot, so a @@ -45096,13 +45102,21 @@ bool UITask::setWifiRadio(bool on) { return false; } -void UITask::setDeviceTimeFromSystemClock() { +bool UITask::setDeviceTimeFromSystemClock() { #if defined(ESP32) - time_t t = time(nullptr); - if (t < 100000) return; - the_mesh.getRTCClock()->setCurrentTime((uint32_t)t); + const uint32_t t = (uint32_t)time(nullptr); + // ESP32RTCClock::begin() seeds the system clock to exactly MIN_VALID_EPOCH on power-on, + // so "still at the seed" means never synced. Pushing that into the mesh clock is a + // two-year step backwards that setCurrentTime()'s own MIN check cannot catch, because + // the value IS the constant it compares against — that is how a P4 (whose RTC chip left + // the system clock on the seed) could sync 2024 over a perfectly good clock. The old + // `t < 100000` guard only caught a clock counting up from zero, which never happens here. + if (t <= ClockFloorRTC::MIN_VALID_EPOCH) return false; + the_mesh.getRTCClock()->setCurrentTime(t); + return true; #else the_mesh.getRTCClock()->setCurrentTime((uint32_t)(millis() / 1000)); + return true; #endif } diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index d52a4f2..a2ec856 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -464,7 +464,8 @@ public: bool enableBle(); void disableBle() { if (_serial) _serial->disableBle(); } int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; } - void setDeviceTimeFromSystemClock(); + /** Push the ESP32 system clock into the mesh RTC. false = never synced, mesh clock untouched. */ + bool setDeviceTimeFromSystemClock(); /** Mark recent user input — call when touch / hw button is detected. */ void noteUserInput(); /** Get / set the screen-off-after-idle timeout (0 = never). Persists in NVS. */ From b066c379c0ec108d187bfea435ec2977a7235874 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 13:51:15 +0200 Subject: [PATCH 44/99] =?UTF-8?q?touch:=20P4=20antenna=20=E2=80=94=20inter?= =?UTF-8?q?nal=20on=20every=20boot,=20confirm=20before=20going=20external?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the P4 antenna setting so the failure mode it protects against is impossible by default. Transmitting into an external connector with nothing fitted is what destroys a PA, so the state you get for free after any power cycle, crash or OTA has to be the on-board antenna. - The on-board antenna is forced at EVERY boot, in two places: the park in Xl9535::powerOnSequence() (before the radio object even exists) and a re-assert in UITask::begin() in case anything touched IO1 in between. - The choice is now session-only and is never written to flash. That is the whole point: if "external" could survive a reboot, the safety property is gone. touchPrefsGet/SetP4Antenna are removed; the p4_antenna byte stays in the struct as reserved so TOUCH_CFG_VER does not have to rewind. - Selecting External raises a confirmation naming the MMCX socket and the risk. showConfirm has no cancel hook, so the dropdown reverts immediately and is only re-selected from the confirm handler — a dismissed dialog therefore leaves both the UI and the hardware on internal. - The dropdown reads the live xl9535.antennaMode() rather than a stored pref, so it can never claim an antenna the hardware is not actually on. Also renames the modes from the opaque Auto/Pinned A/Pinned B to Internal / External / Auto (legacy). The reasoning for treating IO1 as an antenna select rather than a TX/RX path switch is written up on Xl9535.h: a per-transmit switch must settle within microseconds of the PA ramping, and an I2C expander write on a bus shared with touch, the RTC and the fuel gauge cannot do that, while the SX1262 has DIO2 for exactly that job. Which LEVEL is which antenna is still unconfirmed against LilyGo's schematic, but the SAFE level is known from the field: the legacy mode sits LOW to receive and every P4 receives fine at +12 dB, so LOW demonstrably radiates into something connected, while HIGH was only ever used for transmit and is the suspect. One named constant (INTERNAL_LEVEL) flips the mapping if the schematic ever says otherwise. Builds clean on the P4 IDF target plus the V4 and T-Deck S3 envs (the shared TouchPrefsStore change); compile-verified only, the P4 is off the USB bus. Co-Authored-By: Claude Opus 4.8 --- src/helpers/esp32/TouchPrefsStore.cpp | 35 ++++++--------- src/helpers/esp32/TouchPrefsStore.h | 6 +-- src/ui-touch/UITask.cpp | 61 +++++++++++++++++++-------- variants/tdisplay_p4/Xl9535.cpp | 5 ++- variants/tdisplay_p4/Xl9535.h | 46 ++++++++++++-------- 5 files changed, 90 insertions(+), 63 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index d448d95..fe1ec02 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 41; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4: pin the XL9535 RF/antenna switch instead of toggling it per TX; 0=auto keeps the legacy behaviour) +static const uint8_t TOUCH_CFG_VER = 41; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -112,7 +112,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing) - uint8_t p4_antenna; // T-Display P4 LoRa antenna/RF-switch (XL9535 IO1): 0=auto (legacy per-TX toggle), 1=pin LOW, 2=pin HIGH — v41 (trailing) + uint8_t p4_antenna; // RESERVED (was: T-Display P4 antenna select). The antenna choice is session-only by design and is never stored — see the note further down — v41 (trailing) }; static TouchCfg s_cfg; @@ -159,7 +159,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.lock_color = DEFAULT_LOCK_COLOR; c.accent = DEFAULT_ACCENT; c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback - c.p4_antenna = 0; // 0 = auto: legacy per-TX toggle (unchanged behaviour) + c.p4_antenna = 0; // reserved, unused: the P4 antenna choice is never persisted c.sig_probe_en = DEFAULT_SIG_PROBE_EN; c.sig_poll_min = DEFAULT_SIG_POLL_MIN; c.tz_zone = 0; // 0 = Europe (CET/CEST) — preserves prior behaviour @@ -410,26 +410,15 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) { static const uint16_t SIG_POLL_MIN_MINS = 1; // 1 min = 60 s (the old fixed cadence) static const uint16_t SIG_POLL_MAX_MINS = 1440; -// --- T-Display P4 LoRa antenna / RF-switch mode ------------------------------ -// XL9535 IO1 drives the board's SKY13453. Our variant assumes it is a TX/RX path switch and -// toggles it around every transmit (target.h onBeforeTransmit/onAfterTransmit), but that was -// never validated on hardware ("polarity TBD on-device") and the expander has NO separate -// internal/external antenna line -- while other P4 firmware DOES offer an internal/external -// choice. If IO1 is in fact the ANTENNA select, toggling it per TX means transmitting on one -// antenna and receiving on the other, which matches a field report of -10 dB at the repeater -// against +12 dB inbound on a unit with an external antenna fitted (symmetric on the same -// hardware under other firmware). 0 keeps the legacy toggle so nothing changes by default; -// 1/2 pin the line so both directions use one antenna and the user can find out which is which. -uint8_t touchPrefsGetP4Antenna() { - if (!s_begun) touchPrefsBegin(); - return s_cfg.p4_antenna > 2 ? 0 : s_cfg.p4_antenna; -} -bool touchPrefsSetP4Antenna(uint8_t mode) { - if (mode > 2) mode = 0; - if (!s_begun) touchPrefsBegin(); - s_cfg.p4_antenna = mode; - return cfgFlush(); -} +// --- T-Display P4 LoRa antenna select — deliberately NOT persisted ----------- +// XL9535 IO1 drives the board's SKY13453 antenna switch (full reasoning in Xl9535.h). The +// getter/setter that used to live here are gone on purpose: the choice is session-only, so +// there is nothing to store. Every boot forces the on-board antenna, in two places — the park +// in Xl9535::powerOnSequence() and the re-assert in UITask::begin() — because the external +// MMCX may have no antenna fitted, and keying a PA into an open connector damages it. That +// safety property only holds if a power cycle cannot restore "external", which means the +// choice must never reach flash. p4_antenna stays as a reserved trailing byte: dropping it +// would rewind TOUCH_CFG_VER on devices already carrying a v41 blob, for no gain. bool touchPrefsGetSigProbeEnabled() { if (!s_begun) touchPrefsBegin(); diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 027cb4a..5d3b993 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -553,10 +553,8 @@ bool touchPrefsGetSigProbeEnabled(); bool touchPrefsSetSigProbeEnabled(bool on); uint16_t touchPrefsGetSigPollMins(); -/** T-Display P4 LoRa antenna / RF-switch mode: 0 = auto (legacy per-TX toggle of XL9535 IO1), - * 1 = pin the line LOW, 2 = pin it HIGH. See the notes in the .cpp. */ -uint8_t touchPrefsGetP4Antenna(); -bool touchPrefsSetP4Antenna(uint8_t mode); +/* NB: there is deliberately no touchPrefsGet/SetP4Antenna(). The T-Display P4 antenna choice is + * session-only so that every boot comes up on the on-board antenna — see the note in the .cpp. */ bool touchPrefsSetSigPollMins(uint16_t mins); #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 759b8a3..6b95b17 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9550,12 +9550,35 @@ static void radioScopeDirectToggleCb(lv_event_t* e) { the_mesh.setScopeDirectFloods(on); } #if defined(HAS_TDISPLAY_P4) -// T-Display P4 LoRa antenna / RF-switch mode. Persists + applies live (no reboot). +// T-Display P4 LoRa antenna select. Applies live (no reboot) but is deliberately SESSION-ONLY: +// every boot re-forces the on-board antenna (see the boot-apply in begin() and the park in +// Xl9535::powerOnSequence). Transmitting into an external connector with no antenna fitted is +// what kills a PA, so the safe state has to be the one you get for free after a power cycle, +// and picking the external antenna has to be a fresh, explicit decision each time. +static lv_obj_t* s_p4_ant_dd = nullptr; // showConfirm takes a bare callback, so stash the widget + +static void radioP4AntennaExternalApply() { + xl9535.setAntennaMode(Xl9535::ANT_EXTERNAL); // takes effect on the very next transmit + if (s_p4_ant_dd) lv_dropdown_set_selected(s_p4_ant_dd, Xl9535::ANT_EXTERNAL); + if (g_lv.task) g_lv.task->showAlert(TR("External antenna selected"), 1200); +} + static void radioP4AntennaSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; - const uint8_t m = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e)); - touchPrefsSetP4Antenna(m); - xl9535.setAntennaMode(m); // takes effect on the very next transmit + lv_obj_t* dd = lv_event_get_target(e); + const uint8_t m = (uint8_t)lv_dropdown_get_selected(dd); + if (m == Xl9535::ANT_EXTERNAL) { + // Revert the widget FIRST and only re-select it from the confirm handler: showConfirm has no + // cancel callback, so a dismissed dialog must leave both the UI and the hardware on internal. + s_p4_ant_dd = dd; + lv_dropdown_set_selected(dd, xl9535.antennaMode()); + showConfirm(TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " + "to the MMCX socket first. Transmitting with nothing attached can damage the " + "radio.\n\nResets to the on-board antenna on every reboot."), + TR("Switch"), radioP4AntennaExternalApply); + return; + } + xl9535.setAntennaMode(m); } #endif #if defined(HELTEC_LORA_V4_TFT) @@ -9836,19 +9859,18 @@ static void buildRadioSettings() { mk_section("SIGNAL"); #if defined(HAS_TDISPLAY_P4) - // T-Display P4: the SKY13453 on XL9535 IO1. The firmware assumes it is a TX/RX path switch and - // flips it around every transmit, but that was never confirmed on hardware and this board has no - // other antenna-select line — while other P4 firmware offers an internal/external choice. If IO1 - // IS the antenna select, toggling it per transmit means sending on one antenna and listening on - // the other, which matches reports of a ~20 dB outbound deficit with a healthy inbound signal. - // Pinning the line makes both directions use ONE antenna. Try each and keep whichever gives - // roughly equal SNR in both directions (Trace SNR to a nearby repeater). + // T-Display P4 antenna select: the SKY13453 on XL9535 IO1 (see the long note on Xl9535.h for why + // this is an antenna switch and not the TX/RX path). Internal is the default and is re-forced at + // every boot; external is session-only and gated behind a confirmation, because keying the PA + // into an empty MMCX is how you destroy one. "Auto" keeps the old per-transmit toggle purely so + // the two can be compared — Trace SNR to a nearby repeater should read roughly equal in both + // directions on the correct setting, and lopsided on the wrong one. mk_label("LoRa antenna (P4)"); { lv_obj_t* dd = lv_dropdown_create(body); lv_obj_set_size(dd, lv_pct(100), SC(34)); lv_obj_set_pos(dd, 2, y); - lv_dropdown_set_options(dd, TR("Auto (switch per transmit)\nPinned A\nPinned B")); + lv_dropdown_set_options(dd, TR("Internal (on-board)\nExternal (MMCX)\nAuto (legacy, per transmit)")); lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN); lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); @@ -9857,12 +9879,13 @@ static void buildRadioSettings() { lv_obj_set_style_bg_color(antlist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_text_color(antlist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(antlist, &g_font_12, LV_PART_MAIN); - lv_dropdown_set_selected(dd, touchPrefsGetP4Antenna()); + lv_dropdown_set_selected(dd, xl9535.antennaMode()); // live state, not a stored pref lv_obj_add_event_cb(dd, radioP4AntennaSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); y += SC(44); y += settingsRowLabel(body, y, 0, - TR("If sending is much weaker than receiving, try Pinned A / B and compare Trace SNR both ways."), + TR("Always starts on the on-board antenna after a reboot. Only pick External " + "with an antenna fitted to the MMCX socket."), COLOR_SUB, &g_font_12, 0) + 2; } #endif @@ -44419,9 +44442,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna()); #endif #if defined(HAS_TDISPLAY_P4) - // T-Display P4 LoRa antenna / RF-switch: apply the saved mode at boot. Default 0 = auto, - // which is the original per-transmit toggle, so an untouched device behaves exactly as before. - xl9535.setAntennaMode(touchPrefsGetP4Antenna()); + // T-Display P4 antenna select: force the on-board antenna on EVERY boot, deliberately + // ignoring whatever was chosen last session. The external MMCX may have nothing screwed + // onto it, and transmitting into an open connector is what damages the PA — so the state + // you get for free after any power cycle, crash or OTA has to be the safe one. Choosing + // external is a per-session, confirmed action (radioP4AntennaSelectCb). This re-asserts + // what powerOnSequence() already parked, in case anything touched IO1 in between. + xl9535.setAntennaMode(Xl9535::ANT_INTERNAL); #endif // Buffered LoRa receive (experimental, default OFF): apply the saved opt-in. diff --git a/variants/tdisplay_p4/Xl9535.cpp b/variants/tdisplay_p4/Xl9535.cpp index b7bbc39..17b44ed 100644 --- a/variants/tdisplay_p4/Xl9535.cpp +++ b/variants/tdisplay_p4/Xl9535.cpp @@ -99,7 +99,10 @@ void Xl9535::powerOnSequence() { // OFF and made every mount fail (0x107 OCR timeout / 0x109 CRC). Drive it LOW, always. write(IO_SD_EN, false); // SD slot power ON (active-low) write(IO_SX1262_RST, true); // park high; radio_init pulses it - write(IO_RF_SWITCH, false); // RX path + // Antenna select: park on the on-board antenna before the radio exists, so the first transmit + // after any boot cannot key the PA into a possibly-empty external MMCX. Choosing the external + // antenna is a deliberate, confirmed, session-only action from Settings -> Radio. + write(IO_RF_SWITCH, INTERNAL_LEVEL); write(IO_TOUCH_RST, true); // release touch (HI8561 driver probes it) // NB: SCREEN_RST is intentionally NOT touched here — RM69A10Display::begin() sequences it with // the DSI-PHY LDO (HIGH->LOW->HIGH, 200 ms each), exactly as the working Meck-P4 app_main does. diff --git a/variants/tdisplay_p4/Xl9535.h b/variants/tdisplay_p4/Xl9535.h index 49d3648..27bb1ae 100644 --- a/variants/tdisplay_p4/Xl9535.h +++ b/variants/tdisplay_p4/Xl9535.h @@ -53,28 +53,38 @@ public: // Convenience for the SX1262 glue (RESET + DIO1 live here). void sx1262Reset(); // active-low pulse on IO_SX1262_RST bool sx1262Dio1() { return read(IO_SX1262_DIO1); } - // IO1 drives the board's SKY13453. We ASSUME it is a TX/RX path switch and flip it around every - // transmit, but that was never validated on hardware, and this expander has NO separate - // internal/external antenna line — while other P4 firmware DOES offer an internal/external - // choice. If IO1 is really the ANTENNA select, toggling it per TX means transmitting on one - // antenna and receiving on the other, which is exactly what a field report of -10 dB outbound - // against +12 dB inbound looks like on a unit with an external antenna fitted (and symmetric on - // that same hardware under other firmware). Until it is confirmed on a device, let the user PIN - // the line instead of toggling it: - // 0 = auto -> legacy behaviour, toggle per TX (default; nothing changes) - // 1 = LOW -> hold low for both TX and RX - // 2 = HIGH -> hold high for both TX and RX - // Whichever pinned setting gives symmetric SNR in both directions is the right antenna — and - // that answer also tells us what IO1 actually is. + // IO1 drives the board's SKY13453 SP2T. It is almost certainly the ANTENNA select (on-board vs + // the external MMCX), NOT a TX/RX path switch: a per-transmit switch has to settle within + // microseconds of the PA ramping, and an I2C expander write takes hundreds of microseconds on a + // bus shared with the touch panel, the RTC and the fuel gauge — it physically cannot keep up. + // The SX1262 has DIO2 for exactly that job. So the original per-TX toggle transmitted on one + // antenna while listening on the other, which is what a field report of -10 dB outbound against + // +12 dB inbound looks like (and symmetric on the same hardware under other firmware). + // + // Which LEVEL is which antenna is still not confirmed against LilyGo's schematic. But the SAFE + // level IS known, from the field rather than the datasheet: in the legacy per-TX mode the line + // sits LOW to receive, and every P4 receives perfectly well at +12 dB, so LOW is demonstrably a + // path that radiates into something connected. HIGH was only ever used for transmit and is the + // suspect one. That fits LOW = the on-board antenna (permanently attached, always safe to + // transmit into) and HIGH = the external MMCX (an open circuit when nothing is fitted, which is + // what you must never key a PA into). If the schematic ever says otherwise, flip this ONE line. + static constexpr bool INTERNAL_LEVEL = false; + + enum AntMode : uint8_t { + ANT_INTERNAL = 0, // pinned to the on-board antenna — the safe default, re-forced at every boot + ANT_EXTERNAL = 1, // pinned to the external MMCX — needs an antenna fitted, never persisted + ANT_AUTO = 2, // legacy per-TX toggle — diagnostic only, kept for A/B comparison + }; + void setAntennaMode(uint8_t mode) { - _ant_mode = (mode > 2) ? 0 : mode; - if (_ant_mode) write(IO_RF_SWITCH, _ant_mode == 2); // pinned: set once, never toggled again - else write(IO_RF_SWITCH, false); // auto: back to the idle state + _ant_mode = (mode > ANT_AUTO) ? ANT_INTERNAL : mode; + if (_ant_mode == ANT_EXTERNAL) write(IO_RF_SWITCH, !INTERNAL_LEVEL); + else write(IO_RF_SWITCH, INTERNAL_LEVEL); // internal, and auto's idle/RX state } uint8_t antennaMode() const { return _ant_mode; } void rfSwitchTx(bool tx) { - if (_ant_mode) return; // pinned — the path must NOT differ between TX and RX - write(IO_RF_SWITCH, tx); // auto (legacy): assumes a TX/RX switch; polarity unverified + if (_ant_mode != ANT_AUTO) return; // pinned — the path must NOT differ between TX and RX + write(IO_RF_SWITCH, tx); // legacy only: assumes a TX/RX switch, polarity unverified } private: From a3550d21902e4b960826369fc2f250c717513bae Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 14:02:52 +0200 Subject: [PATCH 45/99] =?UTF-8?q?touch:=20P4=20antenna=20=E2=80=94=20gate?= =?UTF-8?q?=20the=20legacy=20auto=20mode=20behind=20the=20same=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto was still one tap away with no prompt, which contradicts the safety model in b066c37: the legacy per-TX toggle writes the external level on EVERY single transmit, so it keys the PA into the MMCX socket more often than "External" does, not less. Both non-internal modes now require an explicit confirmation, with wording that says what each one actually does to the transmit path. Internal stays promptless (it is the safe state), and the revert-then-reapply shape is unchanged, so a dismissed dialog still leaves the UI and the hardware where they were. The pending mode moves into a static because showConfirm takes a bare callback with no user data. Verified on the device (T-Display P4, MAC ...e1:c2:a7): clean boot, only rst:0x1 (POWERON) in the log, "[XL9535] power-on sequence done" so the antenna park runs, and "[C6-AT] SNTP -> rtc 1785412930" decoding to 2026-07-30 12:02:10 UTC, which also confirms the ClockFloorRTC system-clock mirror from fe87ff0 firing on real hardware. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 44 ++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6b95b17..6e8d591 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9556,29 +9556,41 @@ static void radioScopeDirectToggleCb(lv_event_t* e) { // what kills a PA, so the safe state has to be the one you get for free after a power cycle, // and picking the external antenna has to be a fresh, explicit decision each time. static lv_obj_t* s_p4_ant_dd = nullptr; // showConfirm takes a bare callback, so stash the widget +static uint8_t s_p4_ant_pending = Xl9535::ANT_INTERNAL; -static void radioP4AntennaExternalApply() { - xl9535.setAntennaMode(Xl9535::ANT_EXTERNAL); // takes effect on the very next transmit - if (s_p4_ant_dd) lv_dropdown_set_selected(s_p4_ant_dd, Xl9535::ANT_EXTERNAL); - if (g_lv.task) g_lv.task->showAlert(TR("External antenna selected"), 1200); +static void radioP4AntennaConfirmApply() { + xl9535.setAntennaMode(s_p4_ant_pending); // takes effect on the very next transmit + if (s_p4_ant_dd) lv_dropdown_set_selected(s_p4_ant_dd, s_p4_ant_pending); + if (g_lv.task) { + g_lv.task->showAlert(s_p4_ant_pending == Xl9535::ANT_EXTERNAL ? TR("External antenna selected") + : TR("Per-transmit switching on"), 1200); + } } static void radioP4AntennaSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; lv_obj_t* dd = lv_event_get_target(e); const uint8_t m = (uint8_t)lv_dropdown_get_selected(dd); - if (m == Xl9535::ANT_EXTERNAL) { - // Revert the widget FIRST and only re-select it from the confirm handler: showConfirm has no - // cancel callback, so a dismissed dialog must leave both the UI and the hardware on internal. - s_p4_ant_dd = dd; - lv_dropdown_set_selected(dd, xl9535.antennaMode()); - showConfirm(TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " - "to the MMCX socket first. Transmitting with nothing attached can damage the " - "radio.\n\nResets to the on-board antenna on every reboot."), - TR("Switch"), radioP4AntennaExternalApply); - return; - } - xl9535.setAntennaMode(m); + if (m == Xl9535::ANT_INTERNAL) { xl9535.setAntennaMode(m); return; } // always safe, no prompt + + // BOTH other modes put the transmitter on the external MMCX: external pins it there, and auto + // (the legacy per-TX toggle) switches to it for every single send — so auto is if anything the + // worse of the two, since it keys the PA into that connector on every transmit rather than only + // while deliberately selected. Neither may be entered without an explicit confirmation. + // Revert the widget FIRST and only re-select from the confirm handler: showConfirm has no cancel + // callback, so a dismissed dialog must leave both the UI and the hardware where they were. + s_p4_ant_dd = dd; + s_p4_ant_pending = m; + lv_dropdown_set_selected(dd, xl9535.antennaMode()); + showConfirm(m == Xl9535::ANT_EXTERNAL + ? TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " + "to the MMCX socket first. Transmitting with nothing attached can damage the " + "radio.\n\nResets to the on-board antenna on every reboot.") + : TR("Turn on legacy per-transmit switching?\n\nThis is a diagnostic mode. It sends " + "on the external MMCX socket and listens on the on-board antenna, so it needs " + "an antenna fitted and it will report a weak outbound signal.\n\nResets to the " + "on-board antenna on every reboot."), + TR("Switch"), radioP4AntennaConfirmApply); } #endif #if defined(HELTEC_LORA_V4_TFT) From ecb506694d4f9e96aef87653b21191227d6928f4 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 14:19:48 +0200 Subject: [PATCH 46/99] =?UTF-8?q?touch:=20P4=20antenna=20=E2=80=94=20FIX?= =?UTF-8?q?=20INVERTED=20POLARITY,=20internal=20is=20HIGH=20not=20LOW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b066c37 guessed the antenna mapping from field symptoms and guessed it BACKWARDS, so "Internal" was selecting the external socket and the boot park pinned the external connector — the exact hazard the change was meant to remove. Corrected against LilyGo's own sources, which document this pin explicitly: lilygo_device_driver src/device/t_display_p4/t_display_p4_config.h inline constexpr auto kSky13453Vctl = cpp_bus_driver::Xl95x5::Pin::kIo1; lilygobox-espidf main/hal/device/t_display_p4/t_display_p4_device.cpp const uint8_t antenna_level = config.antenna == AntennaType::kExternal ? 0 : 1; GpioWrite(gpio::xl9535::kSky13453Vctl, antenna_level); ... logged as kExternal ? "RF2" : "RF1" So HIGH = RF1 = on-board antenna, LOW = RF2 = external socket. INTERNAL_LEVEL flips false -> true. The one-constant escape hatch written into b066c37 is what made this a one-line correction. Two things this settles beyond the polarity. The vendor's AntennaType enum is literally kInternal/kExternal, so the line IS an antenna select and not a TX/RX path — the architectural half of the earlier reasoning was right. And LilyGo preloads VCTL = 1 in their own XL9535 init as their stated safe state, so booting on the on-board antenna is the vendor's choice too, not just ours. It also explains the field report properly: the legacy per-TX toggle idled LOW to receive (= EXTERNAL, where the reporter's antenna is fitted, hence a healthy +12 dB inbound) and drove HIGH to transmit (= the on-board antenna, hence ~22 dB down at the repeater). It transmits internal and listens external, which is the opposite of what a3550d2 claimed. So auto is NOT a PA hazard after all — its transmit lands on the permanently attached on-board antenna — it is just guaranteed lopsided. Its confirmation text now says that instead of warning about damage, and only External keeps the damage warning. UI strings say "external antenna socket" rather than "MMCX", since the board has two MMCX connectors and only one of them is the LoRa path. Verified on hardware (T-Display P4, MAC ...e1:c2:a7): clean boot, only rst:0x1 (POWERON), antenna park runs, SNTP -> rtc 1785413952. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 51 ++++++++++++++++--------------- variants/tdisplay_p4/Xl9535.cpp | 7 +++-- variants/tdisplay_p4/Xl9535.h | 54 ++++++++++++++++++++------------- 3 files changed, 63 insertions(+), 49 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6e8d591..95576f3 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9573,23 +9573,23 @@ static void radioP4AntennaSelectCb(lv_event_t* e) { const uint8_t m = (uint8_t)lv_dropdown_get_selected(dd); if (m == Xl9535::ANT_INTERNAL) { xl9535.setAntennaMode(m); return; } // always safe, no prompt - // BOTH other modes put the transmitter on the external MMCX: external pins it there, and auto - // (the legacy per-TX toggle) switches to it for every single send — so auto is if anything the - // worse of the two, since it keys the PA into that connector on every transmit rather than only - // while deliberately selected. Neither may be entered without an explicit confirmation. - // Revert the widget FIRST and only re-select from the confirm handler: showConfirm has no cancel - // callback, so a dismissed dialog must leave both the UI and the hardware where they were. + // External moves BOTH directions onto the external socket, so it needs an antenna actually fitted + // there — that is the one real hardware risk and it gets the damage warning. Auto (the legacy + // per-TX toggle) transmits on the on-board antenna and only listens on the external socket, so it + // is not a PA hazard, just permanently lopsided; it gets an accurate performance warning instead + // of a scary one. Revert the widget FIRST and only re-select from the confirm handler: showConfirm + // has no cancel callback, so a dismissed dialog must leave UI and hardware where they were. s_p4_ant_dd = dd; s_p4_ant_pending = m; lv_dropdown_set_selected(dd, xl9535.antennaMode()); showConfirm(m == Xl9535::ANT_EXTERNAL ? TR("Switch to the external antenna?\n\nMake sure an antenna is actually connected " - "to the MMCX socket first. Transmitting with nothing attached can damage the " - "radio.\n\nResets to the on-board antenna on every reboot.") - : TR("Turn on legacy per-transmit switching?\n\nThis is a diagnostic mode. It sends " - "on the external MMCX socket and listens on the on-board antenna, so it needs " - "an antenna fitted and it will report a weak outbound signal.\n\nResets to the " - "on-board antenna on every reboot."), + "to the external antenna socket first. Transmitting with nothing attached can " + "damage the radio.\n\nResets to the on-board antenna on every reboot.") + : TR("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."), TR("Switch"), radioP4AntennaConfirmApply); } #endif @@ -9871,12 +9871,12 @@ static void buildRadioSettings() { mk_section("SIGNAL"); #if defined(HAS_TDISPLAY_P4) - // T-Display P4 antenna select: the SKY13453 on XL9535 IO1 (see the long note on Xl9535.h for why - // this is an antenna switch and not the TX/RX path). Internal is the default and is re-forced at - // every boot; external is session-only and gated behind a confirmation, because keying the PA - // into an empty MMCX is how you destroy one. "Auto" keeps the old per-transmit toggle purely so - // the two can be compared — Trace SNR to a nearby repeater should read roughly equal in both - // directions on the correct setting, and lopsided on the wrong one. + // T-Display P4 antenna select: the SKY13453 on XL9535 IO1. Confirmed against LilyGo's own driver + // (HIGH = RF1 = on-board, LOW = RF2 = external socket) — see the note on Xl9535.h. Internal is the + // default and is re-forced at every boot; external is session-only and gated behind a confirmation, + // because keying the PA into an empty socket is how you destroy one. "Auto" keeps the old + // per-transmit toggle purely for comparison: it sends internal and listens external, so Trace SNR + // reads lopsided on it and roughly equal on a correctly pinned antenna. mk_label("LoRa antenna (P4)"); { lv_obj_t* dd = lv_dropdown_create(body); @@ -9897,7 +9897,7 @@ static void buildRadioSettings() { y += SC(44); y += settingsRowLabel(body, y, 0, TR("Always starts on the on-board antenna after a reboot. Only pick External " - "with an antenna fitted to the MMCX socket."), + "with an antenna fitted to the external socket."), COLOR_SUB, &g_font_12, 0) + 2; } #endif @@ -44454,12 +44454,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna()); #endif #if defined(HAS_TDISPLAY_P4) - // T-Display P4 antenna select: force the on-board antenna on EVERY boot, deliberately - // ignoring whatever was chosen last session. The external MMCX may have nothing screwed - // onto it, and transmitting into an open connector is what damages the PA — so the state - // you get for free after any power cycle, crash or OTA has to be the safe one. Choosing - // external is a per-session, confirmed action (radioP4AntennaSelectCb). This re-asserts - // what powerOnSequence() already parked, in case anything touched IO1 in between. + // T-Display P4 antenna select: force the on-board antenna (RF1 / IO1 HIGH) on EVERY boot, + // deliberately ignoring whatever was chosen last session. The external socket may have + // nothing screwed onto it, and transmitting into an open connector is what damages the PA — + // so the state you get for free after any power cycle, crash or OTA has to be the safe one. + // LilyGo's own driver preloads the same level for the same reason. Choosing external is a + // per-session confirmed action; this re-asserts powerOnSequence()'s park in case anything + // touched IO1 in between. xl9535.setAntennaMode(Xl9535::ANT_INTERNAL); #endif diff --git a/variants/tdisplay_p4/Xl9535.cpp b/variants/tdisplay_p4/Xl9535.cpp index 17b44ed..6b4fa67 100644 --- a/variants/tdisplay_p4/Xl9535.cpp +++ b/variants/tdisplay_p4/Xl9535.cpp @@ -99,9 +99,10 @@ void Xl9535::powerOnSequence() { // OFF and made every mount fail (0x107 OCR timeout / 0x109 CRC). Drive it LOW, always. write(IO_SD_EN, false); // SD slot power ON (active-low) write(IO_SX1262_RST, true); // park high; radio_init pulses it - // Antenna select: park on the on-board antenna before the radio exists, so the first transmit - // after any boot cannot key the PA into a possibly-empty external MMCX. Choosing the external - // antenna is a deliberate, confirmed, session-only action from Settings -> Radio. + // Antenna select: park on the on-board antenna (RF1) before the radio exists, so the first + // transmit after any boot cannot key the PA into a possibly-empty external socket. LilyGo's own + // XL9535 init preloads this same level as its safe state. Choosing the external antenna is a + // deliberate, confirmed, session-only action from Settings -> Radio. write(IO_RF_SWITCH, INTERNAL_LEVEL); write(IO_TOUCH_RST, true); // release touch (HI8561 driver probes it) // NB: SCREEN_RST is intentionally NOT touched here — RM69A10Display::begin() sequences it with diff --git a/variants/tdisplay_p4/Xl9535.h b/variants/tdisplay_p4/Xl9535.h index 27bb1ae..ddd0b06 100644 --- a/variants/tdisplay_p4/Xl9535.h +++ b/variants/tdisplay_p4/Xl9535.h @@ -53,38 +53,50 @@ public: // Convenience for the SX1262 glue (RESET + DIO1 live here). void sx1262Reset(); // active-low pulse on IO_SX1262_RST bool sx1262Dio1() { return read(IO_SX1262_DIO1); } - // IO1 drives the board's SKY13453 SP2T. It is almost certainly the ANTENNA select (on-board vs - // the external MMCX), NOT a TX/RX path switch: a per-transmit switch has to settle within - // microseconds of the PA ramping, and an I2C expander write takes hundreds of microseconds on a - // bus shared with the touch panel, the RTC and the fuel gauge — it physically cannot keep up. - // The SX1262 has DIO2 for exactly that job. So the original per-TX toggle transmitted on one - // antenna while listening on the other, which is what a field report of -10 dB outbound against - // +12 dB inbound looks like (and symmetric on the same hardware under other firmware). + // IO1 drives the board's SKY13453 SP2T. It is the LoRa ANTENNA select — on-board antenna vs the + // external socket — and NOT a TX/RX path switch. CONFIRMED against LilyGo's own sources (the + // vendor names this exact pin kSky13453Vctl on IO1): + // lilygo_device_driver src/device/t_display_p4/t_display_p4_config.h + // lilygobox-espidf main/hal/device/t_display_p4/t_display_p4_device.cpp + // In ActivateRadio() they set it once per radio bring-up from an AntennaType enum: + // const uint8_t antenna_level = config.antenna == radio::AntennaType::kExternal ? 0 : 1; + // ... GpioWrite(gpio::xl9535::kSky13453Vctl, antenna_level); + // and log it as kExternal ? "RF2" : "RF1". So: // - // Which LEVEL is which antenna is still not confirmed against LilyGo's schematic. But the SAFE - // level IS known, from the field rather than the datasheet: in the legacy per-TX mode the line - // sits LOW to receive, and every P4 receives perfectly well at +12 dB, so LOW is demonstrably a - // path that radiates into something connected. HIGH was only ever used for transmit and is the - // suspect one. That fits LOW = the on-board antenna (permanently attached, always safe to - // transmit into) and HIGH = the external MMCX (an open circuit when nothing is fitted, which is - // what you must never key a PA into). If the schematic ever says otherwise, flip this ONE line. - static constexpr bool INTERNAL_LEVEL = false; + // HIGH (1) = RF1 = INTERNAL / on-board antenna + // LOW (0) = RF2 = EXTERNAL socket + // + // LilyGo also preloads VCTL = 1 in their XL9535 init as the deliberate safe state, i.e. the + // vendor boots on the internal antenna too. Their driver NEVER toggles this line per transmit — + // it cannot be a TX/RX switch anyway, since such a switch must settle within microseconds of the + // PA ramping and an I2C expander write takes hundreds of microseconds on a bus shared with the + // touch panel, the RTC and the fuel gauge (the SX1262 has DIO2 for that job). + // + // This also explains the field report of -10 dB outbound against +12 dB inbound exactly: our old + // per-TX toggle idled LOW to receive (= EXTERNAL, where the user's antenna is fitted, hence the + // healthy +12 dB in) and drove HIGH to transmit (= the tiny on-board antenna, hence ~22 dB down + // at the repeater). It was sending on the internal antenna and listening on the external one. + static constexpr bool INTERNAL_LEVEL = true; enum AntMode : uint8_t { - ANT_INTERNAL = 0, // pinned to the on-board antenna — the safe default, re-forced at every boot - ANT_EXTERNAL = 1, // pinned to the external MMCX — needs an antenna fitted, never persisted - ANT_AUTO = 2, // legacy per-TX toggle — diagnostic only, kept for A/B comparison + ANT_INTERNAL = 0, // RF1, on-board antenna — always attached, the safe default, forced every boot + ANT_EXTERNAL = 1, // RF2, external socket — needs an antenna fitted; session-only, never persisted + ANT_AUTO = 2, // legacy per-TX toggle: transmits internal, receives external. Diagnostic only. }; void setAntennaMode(uint8_t mode) { _ant_mode = (mode > ANT_AUTO) ? ANT_INTERNAL : mode; if (_ant_mode == ANT_EXTERNAL) write(IO_RF_SWITCH, !INTERNAL_LEVEL); - else write(IO_RF_SWITCH, INTERNAL_LEVEL); // internal, and auto's idle/RX state + else write(IO_RF_SWITCH, INTERNAL_LEVEL); // internal, and auto's idle state } uint8_t antennaMode() const { return _ant_mode; } void rfSwitchTx(bool tx) { - if (_ant_mode != ANT_AUTO) return; // pinned — the path must NOT differ between TX and RX - write(IO_RF_SWITCH, tx); // legacy only: assumes a TX/RX switch, polarity unverified + if (_ant_mode != ANT_AUTO) return; // pinned — the antenna must NOT differ between TX and RX + // Legacy behaviour, preserved verbatim for comparison: HIGH on transmit, LOW at rest. Now that + // the mapping is known that reads as "send on the on-board antenna, listen on the external one", + // which is the bug, not a feature. Not a PA hazard (transmit lands on the attached on-board + // antenna) but it guarantees a weak outbound signal whenever an external antenna is fitted. + write(IO_RF_SWITCH, tx); } private: From 471a522d8fde26a9d975ab091bd054abf80333d3 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 15:08:18 +0200 Subject: [PATCH 47/99] touch: put Airtime + Snake on the Spectrum app-page pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were built from the same template and carried the same three defects, none of which were really about their own code: they hand-rolled the page chrome that every other app page gets from the shared machinery. New src/ui-touch/AppPage.h exports that machinery (appPageCreateRoot / Begin / End / DeleteRootAsync) so a self-contained module can build the same page the in-file tool pages do, and both modules now use it. 1. GEOMETRY. Both positioned their overlay against a local `kTopBar = 22`. STATUSBAR_H is a RUNTIME value: SC(22) once the UI scale is above 100%, and SB_TOP_PAD + SB_ROW*2 on the T-Display P4's two-row bar. On those boards the page sat too high and its own title + close button landed UNDERNEATH the real status bar, which is what "the airtime app has issues" looked like. Geometry now comes from the live bar height, so a page cannot disagree with the bar. 2. NAVIGATION. Neither installed s_apppage_title / s_apppage_close, so each needed its own case in statusBarTapCb and its own clause in the bar-foreground gate, and neither reached statusBarReaderBackCb — the PRESSED fallback that exists because the cap-touch swipe detector can drop the lone CLICKED. On a touch-only board that left no reliable exit at all. They now install the standard hook, get the tall "< Airtime" / "< Snake" bar, and both special cases in UITask are deleted. 3. TEARDOWN. closeCb ran `lv_obj_del(root_)` and `delete this` synchronously from a child button's own event callback, freeing the object LVGL was still dispatching to. Teardown is now async throughout: lv_obj_del_async for the root, lv_async_call for the instance, one single dismiss() path. Snake's canvas buffer moves to a destructor so it is freed after the queued root delete released the canvas. Also fixes what the rework exposed: applySwipeGesture had swallow-guards for Snake, sliders, chats, popups, the control centre and settings sheets, but none for app pages — so a horizontal swipe inside Airtime reached the tab switcher and jumped to the Map (reported on device). An opaque CLICKABLE root only blocks CLICKS; that detector reads the touch hardware directly and never consults LVGL hit-testing. One guard now covers every full-screen page, Spectrum and the rest included, which had the same bug. Bar titles are copied into module-owned storage rather than holding TR()'s pointer: TR returns the static table cell for a plain key but a recycled 4-slot ring for an icon-prefixed one, and s_apppage_title keeps the pointer for the page's lifetime. Builds clean on V4, T-Deck and the P4 IDF target; flashed + boot-verified on the P4. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/AppPage.h | 47 ++++++++++++++++++++ src/ui-touch/ChannelUtil.cpp | 85 +++++++++++++++++++----------------- src/ui-touch/ChannelUtil.h | 11 +++-- src/ui-touch/SnakeGame.cpp | 84 ++++++++++++++++++++--------------- src/ui-touch/SnakeGame.h | 9 +++- src/ui-touch/UITask.cpp | 73 ++++++++++++++++++++++++++----- 6 files changed, 217 insertions(+), 92 deletions(-) create mode 100644 src/ui-touch/AppPage.h diff --git a/src/ui-touch/AppPage.h b/src/ui-touch/AppPage.h new file mode 100644 index 0000000..d95f253 --- /dev/null +++ b/src/ui-touch/AppPage.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include + +// Shared chrome for full-screen "app pages" — Spectrum, RF Monitor, Discover, Airtime, +// Snake, VNC, Remote, Reader. Every one of them must look and behave identically: +// +// * the GLOBAL status bar goes tall and paints "< "; a tap on it closes the page +// (on both CLICKED and PRESSED, so the cap-touch swipe detector dropping a lone +// CLICKED can't trap the user on a touch-only board) +// * the page root covers everything BELOW that bar, opaque and clickable, so the tab +// bar and whatever was on screen underneath cannot be touched through the page +// * the root is deleted ASYNCHRONOUSLY, never synchronously from inside an event +// callback on one of its own children +// +// These exist because the two self-contained modules (ChannelUtil, SnakeGame) hand-rolled +// the chrome against a hardcoded `kTopBar = 22`. STATUSBAR_H is a runtime value — SC(22) +// once the UI scale is above 100%, and SB_TOP_PAD + SB_ROW*2 on the T-Display P4's +// two-row bar — so on those boards the pages were positioned too high, which put their +// own title and close button UNDERNEATH the real status bar with no reachable exit. Going +// through here instead means a page can never disagree with the bar about its own height. + +/** y of the first usable content row: immediately under the (possibly tall) status bar. */ +lv_coord_t appPageContentTop(); + +/** Usable content height below the status bar. */ +lv_coord_t appPageContentH(); + +/** + * Create a page root: full width, from just under the status bar to the bottom of the + * screen, on lv_layer_top, opaque `bg_color`, non-scrollable and CLICKABLE so no press + * reaches the UI underneath. + */ +lv_obj_t* appPageCreateRoot(uint32_t bg_color); + +/** Take the status bar over: tall, "< title", tap anywhere on it calls close_fn. */ +void appPageBegin(const char* title, void (*close_fn)()); + +/** + * Hand the status bar back — but only if `close_fn` is still the installed hook, so a + * page closing after another one already opened cannot steal the new page's chrome. + * Safe to call more than once (idempotent). + */ +void appPageEnd(void (*close_fn)()); + +/** Queue `root` for deletion after the current event finishes. Null-safe. */ +void appPageDeleteRootAsync(lv_obj_t* root); diff --git a/src/ui-touch/ChannelUtil.cpp b/src/ui-touch/ChannelUtil.cpp index 2c1f77b..abae218 100644 --- a/src/ui-touch/ChannelUtil.cpp +++ b/src/ui-touch/ChannelUtil.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ChannelUtil.h" +#include "AppPage.h" // shared app-page chrome: root, tall "< Airtime" bar, async teardown #include "i18n.h" #include <Arduino.h> // millis() @@ -13,10 +14,11 @@ extern MyMesh& the_mesh; // radio_driver is declared `extern WRAPPER_CLASS radio_driver;` in the board's // target.h, reached transitively through MyMesh.h above. -// Mirrors the app's status-bar height; the overlay starts below it so the bar -// stays visible (and can dismiss us on the big-screen boards). Local so this -// module needs nothing from UITask. -static constexpr lv_coord_t kTopBar = 22; +// The page geometry + the tall "< Airtime" bar come from AppPage (shared with Spectrum +// and every other tool page). This module used to mirror the bar height with a local +// `kTopBar = 22`, which is wrong on any board where STATUSBAR_H isn't 22 — the P4's +// two-row bar, or any UI scale above 100% — and left this page's own title and X +// underneath the real bar with no way out. static constexpr uint32_t kTickMs = 1000; // 1 Hz sample static constexpr int kBusyPct = 40; // congestion threshold line / verdict pivot @@ -41,15 +43,22 @@ void ChannelUtil::launch() { if (!c->open()) { s_active = nullptr; delete c; } } +void ChannelUtil::destroyAsync(void* p) { delete static_cast<ChannelUtil*>(p); } + void ChannelUtil::dismiss() { - // Guaranteed exit from outside (a status-bar tap): the in-page X can sit under - // a taller status bar on big-screen boards. Mirrors closeCb's teardown; safe - // to delete synchronously here (the triggering target is the status bar). + // THE single close path — the status bar's back hook, the backdrop tap and any future + // caller all land here, so teardown happens exactly once and in one order. + // + // Both halves are deferred on purpose. close() queues the root with lv_obj_del_async, + // and the instance itself is freed from an lv_async_call: dismiss() runs from inside an + // LVGL event callback, and the old code deleted both the root AND `this` synchronously + // from a child button's own handler — freeing the object LVGL was still dispatching to. + // Async calls run FIFO after the event completes, so the root dies first, then us. if (!s_active) return; ChannelUtil* c = s_active; - s_active = nullptr; + s_active = nullptr; // isOpen() reads false from here on c->close(); - delete c; + lv_async_call(destroyAsync, c); } static uint32_t utilColor(int pct) { @@ -180,35 +189,29 @@ void ChannelUtil::tick() { bool ChannelUtil::open() { const lv_coord_t sw = lv_disp_get_hor_res(nullptr); - const lv_coord_t sh = lv_disp_get_ver_res(nullptr); const bool narrow = sw < 280; - root_ = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(root_); - lv_obj_set_size(root_, sw, sh - kTopBar); - lv_obj_set_pos(root_, 0, kTopBar); - lv_obj_set_style_bg_color(root_, lv_color_hex(kColBg), LV_PART_MAIN); - lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE); + // Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing + // under it can be touched, sized/positioned from the LIVE status-bar height, and the + // global bar carries "< Airtime" as the one and only way back. + root_ = appPageCreateRoot(kColBg); lv_obj_set_style_pad_all(root_, 6, LV_PART_MAIN); + lv_obj_add_event_cb(root_, backdropCb, LV_EVENT_CLICKED, this); +// The bar title is stored by POINTER for as long as the page is up, so it must own its +// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled +// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string +// later can't turn the status-bar title into a dangling read. + static char s_bar_title[24]; + snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Airtime")); + appPageBegin(s_bar_title, &ChannelUtil::dismiss); - // Title. - lv_obj_t* title = lv_label_create(root_); - lv_label_set_text(title, TR("Channel Utilization")); - lv_obj_set_style_text_color(title, lv_color_hex(kColDim), LV_PART_MAIN); - lv_obj_align(title, LV_ALIGN_TOP_LEFT, 2, 2); - - // Close (X) + Reset buttons, top-right. - lv_obj_t* x = lv_btn_create(root_); - lv_obj_set_size(x, 30, 24); - lv_obj_align(x, LV_ALIGN_TOP_RIGHT, 0, -2); - lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this); - lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); + // No in-page title or X any more — the tall bar provides both. That chrome was the bug: + // it was laid out against a 22 px bar assumption and disappeared under the real one. + // Reset button, top-right of the content area. lv_obj_t* rst = lv_btn_create(root_); lv_obj_set_size(rst, 30, 24); - lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, -36, -2); + lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, 0, 0); lv_obj_add_event_cb(rst, resetCb, LV_EVENT_CLICKED, this); lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_center(rl); @@ -263,8 +266,10 @@ bool ChannelUtil::open() { } void ChannelUtil::close() { - if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } - if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes all children + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop sampling before the UI goes + appPageEnd(&ChannelUtil::dismiss); // hand the status bar back + appPageDeleteRootAsync(root_); // never a sync del from a callback + root_ = nullptr; headline_ = nullptr; chart_ = nullptr; ser_util_ = nullptr; ser_thresh_ = nullptr; metrics_ = nullptr; verdict_ = nullptr; } @@ -280,10 +285,12 @@ void ChannelUtil::resetCb(lv_event_t* e) { self->snapshotBaseline(); if (self->chart_ && self->ser_util_) lv_chart_set_all_value(self->chart_, self->ser_util_, 0); } -void ChannelUtil::closeCb(lv_event_t* e) { - auto* self = static_cast<ChannelUtil*>(lv_event_get_user_data(e)); - if (!self) return; - self->close(); - if (s_active == self) s_active = nullptr; - delete self; +// Backdrop tap = back, exactly as on the Spectrum page. Only a press on the root itself +// closes; taps on children (Reset) are theirs. wait_release swallows the rest of the +// gesture so the press can't land on whatever the page was covering. +void ChannelUtil::backdropCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; + lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a); + dismiss(); } diff --git a/src/ui-touch/ChannelUtil.h b/src/ui-touch/ChannelUtil.h index b403a3e..1644d27 100644 --- a/src/ui-touch/ChannelUtil.h +++ b/src/ui-touch/ChannelUtil.h @@ -30,8 +30,12 @@ class ChannelUtil { public: static void launch(); // open (no-op if already open) - static bool isOpen(); // UITask: gate the tab bar + raise the status bar - static void dismiss(); // close from outside (status-bar tap) — guaranteed exit + static bool isOpen(); // UITask: is this page up? + // THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall + // "< Airtime" bar closes the page; also used by the backdrop tap. Signature is a plain + // void() on purpose — that is what appPageBegin takes. Safe from an event callback: + // both the root and the instance are torn down asynchronously. + static void dismiss(); private: static constexpr int kPoints = 60; // chart history (~60 s at 1 Hz) @@ -70,6 +74,7 @@ private: static ChannelUtil* s_active; // the one live instance (or nullptr) static void timerCb(lv_timer_t* t); - static void closeCb(lv_event_t* e); + static void backdropCb(lv_event_t* e); // tap the page background = back static void resetCb(lv_event_t* e); + static void destroyAsync(void* p); // lv_async_call target: delete the instance }; diff --git a/src/ui-touch/SnakeGame.cpp b/src/ui-touch/SnakeGame.cpp index e8d16d1..a4a73ce 100644 --- a/src/ui-touch/SnakeGame.cpp +++ b/src/ui-touch/SnakeGame.cpp @@ -1,14 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "SnakeGame.h" +#include "AppPage.h" // shared app-page chrome: root, tall "< Snake" bar, async teardown #include "i18n.h" #include <Arduino.h> // random() #include <string.h> // memmove +#include <stdio.h> // snprintf (bar title) #include <LvglPsramAlloc.h> // PSRAM-preferred buffer for the canvas -// Mirrors the app's status-bar height; the overlay starts below it so the bar -// stays visible. Kept local so this module needs nothing from UITask. -static constexpr lv_coord_t kTopBar = 22; +// Page geometry + the tall "< Snake" bar come from AppPage (shared with Spectrum and the +// other tool pages). This used to mirror the bar height with a local `kTopBar = 22`, which +// is wrong wherever STATUSBAR_H isn't 22 (the P4's two-row bar, any UI scale above 100%) +// and buried the game's own X under the real bar with no reachable exit. static constexpr uint32_t kStepMs = 300; // tick period (a touch slower than before) // Palette (hex literals so the module is self-contained). @@ -37,16 +40,30 @@ void SnakeGame::launch() { if (!g->open()) { s_active = nullptr; delete g; } } +void SnakeGame::destroyAsync(void* p) { delete static_cast<SnakeGame*>(p); } + void SnakeGame::dismiss() { - // Close from outside the game (a status-bar tap). The in-game X can sit UNDER a taller status - // bar on big-screen boards (the P4's bar is set taller at boot + raised over the game), leaving - // no reachable exit — this is the guaranteed way out. Mirrors closeCb's teardown. Safe to del - // synchronously here: the triggering event target is the status bar, not a Snake object. + // THE single close path — the tall bar's back hook (installed via appPageBegin) and any + // other caller land here, so teardown happens once, in one order. + // + // Both halves are deferred deliberately. close() queues the root with lv_obj_del_async and + // the instance is freed from an lv_async_call: this can run from inside an LVGL event + // callback, and the old code deleted the root AND `this` synchronously from a child + // button's own handler — freeing what LVGL was still dispatching to. Async calls run FIFO, + // so the root (and with it the canvas) dies before ~SnakeGame() frees the canvas buffer. if (!s_active) return; SnakeGame* g = s_active; s_active = nullptr; g->close(); - delete g; + lv_async_call(destroyAsync, g); +} + +// The canvas pixel buffer must outlive the canvas object. close() only QUEUES the root for +// deletion, so freeing buf_ there would leave a live canvas pointing at freed PSRAM for one +// more frame. It is freed here instead: the destructor runs from the lv_async_call queued +// after lv_obj_del_async, by which point the canvas is really gone. +SnakeGame::~SnakeGame() { + if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } } void SnakeGame::placeFood() { @@ -138,20 +155,24 @@ void SnakeGame::togglePause() { bool SnakeGame::open() { const lv_coord_t sw = lv_disp_get_hor_res(nullptr); const lv_coord_t sh = lv_disp_get_ver_res(nullptr); - root_ = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(root_); - lv_obj_set_size(root_, sw, sh - kTopBar); - lv_obj_set_pos(root_, 0, kTopBar); - lv_obj_set_style_bg_color(root_, lv_color_hex(0x0E1216), LV_PART_MAIN); - lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE); + // Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing + // underneath can be touched, geometry from the LIVE status-bar height, and the global bar + // carries "< Snake" as the way back. NOTE: no backdrop-tap-to-close here (unlike Airtime) + // — a tap on the playfield restarts after a game over, so the bar is the only exit. + root_ = appPageCreateRoot(0x0E1216); lv_obj_add_event_cb(root_, gestureCb, LV_EVENT_GESTURE, this); lv_obj_add_event_cb(root_, tapCb, LV_EVENT_CLICKED, this); +// The bar title is stored by POINTER for as long as the page is up, so it must own its +// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled +// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string +// later can't turn the status-bar title into a dangling read. + static char s_bar_title[24]; + snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Snake")); + appPageBegin(s_bar_title, &SnakeGame::dismiss); - // Fill the unoccupied screen below a 24-px score/close row. Whole cells only. + // Fill the unoccupied screen below a 24-px score row. Whole cells only. const int avail_w = (int)sw; - const int avail_h = (int)(sh - kTopBar) - 24; + const int avail_h = (int)appPageContentH() - 24; cols_ = avail_w / kCellPx; if (cols_ > kMaxCols) cols_ = kMaxCols; if (cols_ < 6) cols_ = 6; rows_ = avail_h / kCellPx; if (rows_ > kMaxRows) rows_ = kMaxRows; if (rows_ < 6) rows_ = 6; const int pw = cols_ * kCellPx, ph = rows_ * kCellPx; @@ -165,16 +186,12 @@ bool SnakeGame::open() { lv_obj_set_style_text_color(score_, lv_color_hex(kColText), LV_PART_MAIN); lv_obj_align(score_, LV_ALIGN_TOP_LEFT, 6, 4); - lv_obj_t* x = lv_btn_create(root_); - lv_obj_set_size(x, 30, 24); - lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0); - lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this); - lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl); - - // Pause/resume toggle, left of the X. Hidden until the game starts. + // No in-page X any more — the tall bar's "<" is the exit. That button was the bug: laid + // out against a 22 px bar assumption, it vanished under the real one on the P4. + // Pause/resume toggle, top-right. Hidden until the game starts. pause_btn_ = lv_btn_create(root_); lv_obj_set_size(pause_btn_, 30, 24); - lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -40, 0); + lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -4, 0); lv_obj_add_event_cb(pause_btn_, pauseCb, LV_EVENT_CLICKED, this); pause_lbl_ = lv_label_create(pause_btn_); lv_label_set_text(pause_lbl_, LV_SYMBOL_PAUSE); lv_obj_center(pause_lbl_); lv_obj_add_flag(pause_btn_, LV_OBJ_FLAG_HIDDEN); @@ -194,11 +211,13 @@ bool SnakeGame::open() { } void SnakeGame::close() { - if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } - if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes canvas + button children - if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } // free AFTER the canvas is gone + if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop stepping before the UI goes + appPageEnd(&SnakeGame::dismiss); // hand the status bar back + appPageDeleteRootAsync(root_); // never a sync del from a callback + root_ = nullptr; canvas_ = nullptr; score_ = nullptr; start_btn_ = nullptr; pause_btn_ = nullptr; pause_lbl_ = nullptr; + // buf_ is freed in ~SnakeGame(), after the queued root delete has taken the canvas with it. } // ---- LVGL C-callback trampolines (user_data = the instance) ---- @@ -229,10 +248,3 @@ void SnakeGame::pauseCb(lv_event_t* e) { auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e)); if (self) self->togglePause(); } -void SnakeGame::closeCb(lv_event_t* e) { - auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e)); - if (!self) return; - self->close(); - if (s_active == self) s_active = nullptr; - delete self; -} diff --git a/src/ui-touch/SnakeGame.h b/src/ui-touch/SnakeGame.h index b096dbf..426ffe7 100644 --- a/src/ui-touch/SnakeGame.h +++ b/src/ui-touch/SnakeGame.h @@ -17,9 +17,14 @@ public: static void launch(); // open (no-op if already open) static bool isOpen(); // UITask: gate the trackball + tab bar static void steer(int dx, int dy); // UITask: trackball motion -> direction - static void dismiss(); // close from outside (status-bar tap) — guaranteed exit + // THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall + // "< Snake" bar closes the game. Plain void() on purpose — that is what appPageBegin + // takes. Safe from an event callback: root and instance are torn down asynchronously. + static void dismiss(); private: + ~SnakeGame(); // frees the canvas buffer, after the queued root delete released the canvas + // The playfield grid is sized to the screen at open() (cols_ x rows_), capped // by these maxima so the body arrays are fixed-size. static constexpr int kCellPx = 14; // px per cell @@ -62,7 +67,7 @@ private: static void timerCb(lv_timer_t* t); static void gestureCb(lv_event_t* e); static void tapCb(lv_event_t* e); - static void closeCb(lv_event_t* e); static void startCb(lv_event_t* e); static void pauseCb(lv_event_t* e); + static void destroyAsync(void* p); // lv_async_call target: delete the instance }; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 95576f3..5e0dda0 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -107,6 +107,7 @@ #include <LvglPsramAlloc.h> // PSRAM-preferred alloc helpers for the map tile cache #include "SnakeGame.h" // Apps → Snake (self-contained game module) #include "ChannelUtil.h" // Apps → Airtime (self-contained channel-utilization tool) +#include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) #if defined(HAS_TOUCH_UI) #include <lvgl.h> @@ -1297,6 +1298,46 @@ static void statusBarSetTall(bool tall) { // (updateGlobalStatusBar drives this every tick + refreshes the left zone; it must // NOT be called from here — it calls back into statusBarSetTall = recursion.) } + +// ---- AppPage: the shared full-screen app-page chrome (see AppPage.h) -------------- +// Thin wrappers over the machinery just above, exported so the self-contained app +// modules (ChannelUtil = Airtime, SnakeGame) build the same page as the in-file tool +// pages instead of hand-rolling it against a hardcoded bar height. +lv_coord_t appPageContentTop() { return STATUSBAR_H; } +lv_coord_t appPageContentH() { return (lv_coord_t)(lv_disp_get_ver_res(nullptr) - STATUSBAR_H); } + +lv_obj_t* appPageCreateRoot(uint32_t bg_color) { + lv_obj_t* root = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(root); + lv_obj_set_size(root, lv_disp_get_hor_res(nullptr), appPageContentH()); + lv_obj_set_pos(root, 0, appPageContentTop()); + lv_obj_set_style_bg_color(root, lv_color_hex(bg_color), LV_PART_MAIN); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); + // CLICKABLE is what makes the page modal: every press lands here instead of falling + // through to the tab bar / list underneath. + lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); + return root; +} + +void appPageBegin(const char* title, void (*close_fn)()) { + s_apppage_title = title; + s_apppage_close = close_fn; + statusBarSetTall(true); + updateGlobalStatusBar(); +} + +void appPageEnd(void (*close_fn)()) { + if (s_apppage_close != close_fn) return; // a different page owns the bar now — leave it + s_apppage_title = nullptr; + s_apppage_close = nullptr; + statusBarSetTall(false); + updateGlobalStatusBar(); +} + +void appPageDeleteRootAsync(lv_obj_t* root) { + if (root) lv_obj_del_async(root); +} static const char* tsBlockReason(); // fwd decl (defined near idle-sleep hooks) static const char* tsWakeReasonStr(touchSleep::WakeReason r); // fwd decl (defined near idle-sleep hooks) static void batteryTapCb(lv_event_t* e); // fwd decl (defined near the battery chart) — Settings "Battery" button reuses it @@ -5198,6 +5239,15 @@ static void applySwipeGesture(int8_t swipe_x, int8_t swipe_y) { else if (swipe_y != 0) SnakeGame::steer(0, swipe_y < 0 ? -1 : 1); return; } + // Every OTHER full-screen app page (Airtime, Spectrum, RF Monitor, Discover, VNC, Remote, + // Reader) also owns the screen, so swallow the swipe instead of letting it reach the tab + // switcher behind the page. Making the page root opaque + CLICKABLE only stops CLICKS: + // this detector reads the per-board touch hardware directly and never consults LVGL + // hit-testing, which is why a swipe inside Airtime still jumped to the Map tab. Settings + // sub-sheets borrow s_apppage_title for their back chevron too, but they always open from + // inside a category and have their own rightward swipe-back further down, so exclude them. + // (Vertical swipes are a no-op at the end of this function, so this only kills tab swipes.) + if (s_apppage_title && s_settings_open_cat < 0) return; // A slider was just being dragged — its horizontal drag must NOT be read as a // tab/back swipe (e.g. raising the volume slider rightward kept triggering the // settings "swipe right = back"). Ignore swipes briefly after any slider touch. @@ -37098,12 +37148,11 @@ static void statusBarHoldCb(lv_event_t* e) { static void statusBarTapCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; - // While Snake is up the bar is raised to the FRONT of lv_layer_top (see the - // edge-trigger in updateGlobalStatusBar), so it stays tappable over the game. The - // game's own X can sit UNDER a taller status bar on big-screen boards (the P4), - // leaving no reachable exit — so a bar tap closes Snake here (the guaranteed way out). - if (SnakeGame::isOpen()) { SnakeGame::dismiss(); return; } - if (ChannelUtil::isOpen()) { ChannelUtil::dismiss(); return; } // guaranteed exit if the X sits under a tall bar + // Snake and Airtime used to need their own cases here because they hand-rolled their + // chrome; they now install s_apppage_close via appPageBegin like every other app page, + // so the generic hook below covers them — including the PRESSED fallback in + // statusBarReaderBackCb, which they never got and which is the only reliable exit on a + // cap-touch board that drops the lone CLICKED. if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold // A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back // chevron + title, so a tap goes back (closes the page) just like settings. @@ -37754,12 +37803,12 @@ static void updateGlobalStatusBar() { // is the Snake overlay — the bar swallows taps over the game. { static bool s_bar_fg = false; - // Raise the bar to the FRONT of lv_layer_top for (a) Snake (it swallows taps over the - // game) and (b) a tool page (RF Monitor / Spectrum) — those roots are also on - // lv_layer_top and moved foreground, so without this the TALL bar's lower half (back - // chevron + title) is covered by the page. Settings sheets sit on lv_scr_act so they - // never need this. - const bool want_fg = SnakeGame::isOpen() || ChannelUtil::isOpen() || (s_apppage_title != nullptr); + // Raise the bar to the FRONT of lv_layer_top for any app page (Spectrum, RF Monitor, + // Snake, Airtime, ...): those roots are also on lv_layer_top and moved foreground, so + // without this the TALL bar's lower half (back chevron + title) is covered by the page. + // Settings sheets sit on lv_scr_act so they never need this. One condition covers every + // page now that Snake and Airtime set s_apppage_title too. + const bool want_fg = (s_apppage_title != nullptr); if (want_fg) { // Keep the bar the TOPMOST lv_layer_top child so its back tap is always hittable — // re-assert every tick (a page opened over a chat can otherwise let the chat's From 7aaff807420ff5af5b21b5927c41e34816c3d109 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 15:35:09 +0200 Subject: [PATCH 48/99] =?UTF-8?q?touch:=20P4=20=E2=80=94=20move=20the=20fl?= =?UTF-8?q?ush=20upscale=20scratch=20out=20of=20PSRAM=20(#167=20teal=20fla?= =?UTF-8?q?sh=20on=20RX)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both P4 SKUs render at half resolution and nearest-neighbour-upscale each LVGL band into a scratch buffer before handing it to the panel. That scratch was allocated with MALLOC_CAP_SPIRAM, which put it on the worst possible bus. These are DPI/DSI panels with num_fbs = 1: the ~1.4 MB framebuffer can only live in PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. Every draw_bitmap is a copy INTO that framebuffer, so with the scratch also in PSRAM a single flush put three streams on one bus at once — the scratch read, the framebuffer write, and the DSI's own read. Anything else wanting PSRAM in that window can starve the DSI read, and a DPI underrun presents as a whole-screen colour flash for one frame. That is the reported "screen flashes teal on every RX" (#167 flicker): it happens on ANY screen and was there from the start of the port, because it is a display-bus problem and has nothing to do with what is being drawn. The RX correlation is the priority-10 "lora_rx" drain task, which wakes on every received packet and is ON by default (rx_queue defaults to 1). Allocating the scratch in internal DMA RAM removes the scratch read from the PSRAM bus entirely. The cost is bounded and small: one LVGL band is LV_DRAW_BUF_LINES = 24 rows, so AMOLED 284x24 -> 568*48*2 = ~53 KB and LCD 270x24 -> 540*48*2 = ~51 KB, against a 348 KiB internal region. PSRAM stays as the fallback so a fragmented heap degrades to the previous behaviour rather than dropping to the unscaled draw path. Not done here: lowering the drain task's priority. It is created in the vendored core (RadioLibWrappers.cpp), which is not tracked in this repo, so a change there cannot ship from wadamesh — it needs a monorepo core change plus a core-* tag bump. It is also the weaker lever: the core pins that task to core 0 while LVGL and loop run on core 1, so it is not preempting the UI thread, and the contention this fixes is the memory bus. Builds clean and boot-verified on the device (T-Display P4, MAC ...e1:c2:a7); no allocation failures, only rst:0x1 (POWERON). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- variants/tdisplay_p4/HI8561Display.cpp | 12 +++++++++++- variants/tdisplay_p4/RM69A10Display.cpp | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/variants/tdisplay_p4/HI8561Display.cpp b/variants/tdisplay_p4/HI8561Display.cpp index ba6d390..6fbe2fc 100644 --- a/variants/tdisplay_p4/HI8561Display.cpp +++ b/variants/tdisplay_p4/HI8561Display.cpp @@ -185,11 +185,21 @@ void HI8561Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes // an HI_UI_SCALE x HI_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). const int S = HI_UI_SCALE, dw = w * S, dh = h * S; + // INTERNAL DMA RAM, not PSRAM — same reasoning as the AMOLED SKU (long note in + // RM69A10Display::writePixelsRGB565). This is a DPI/DSI panel whose single framebuffer must + // live in PSRAM and is streamed to the glass continuously, so keeping the upscale scratch in + // PSRAM too put three streams on one bus: the scratch read, the framebuffer write, and the + // DSI's own read. Any other PSRAM consumer — the priority-10 "lora_rx" drain task fires on + // every received packet — could then starve the DSI, and a DPI underrun shows as a + // whole-screen colour flash for one frame (#167). One band is 270x24 upscaled 2x = + // 540*48*2 = ~51 KB out of 768 KB SRAM. PSRAM stays the fallback. static uint16_t* s_up = nullptr; static size_t s_up_px = 0; size_t need = (size_t)dw * dh; if (need > s_up_px) { if (s_up) heap_caps_free(s_up); - s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); + s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + if (!s_up) s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); s_up_px = s_up ? need : 0; } if (s_up) { diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp index 4deece1..79e2bf6 100644 --- a/variants/tdisplay_p4/RM69A10Display.cpp +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -149,11 +149,26 @@ void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_ // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes // an RM_UI_SCALE x RM_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). const int S = RM_UI_SCALE, dw = w * S, dh = h * S; + // The scratch band lives in INTERNAL DMA RAM, not PSRAM. This panel is a DPI/DSI one with a + // single ~1.4 MB framebuffer that can only live in PSRAM, and the DSI DMA streams that + // framebuffer out to the glass CONTINUOUSLY. Every draw_bitmap below is a copy INTO that + // framebuffer, so with the scratch also in PSRAM a flush put three streams on the same bus at + // once: the scratch read, the framebuffer write, and the DSI's own read. Anything else that + // wants PSRAM at that moment — notably the priority-10 "lora_rx" drain task, which wakes on + // every received packet — can then starve the DSI read, and a DPI underrun shows up as a + // whole-screen colour flash for one frame. That is the P4 "flicker on every RX" report + // (issue #167): it appears on any screen, because it is a display-bus problem and nothing to + // do with what is being drawn. Internal RAM removes the scratch read from the PSRAM bus. + // Cost is bounded and small: one LVGL band is 284x24 (LV_DRAW_BUF_LINES), so upscaled 2x this + // is 568*48*2 = ~53 KB out of the P4's 768 KB SRAM. PSRAM stays as the fallback so a + // fragmented heap degrades to the old behaviour instead of dropping to the unscaled path. static uint16_t* s_up = nullptr; static size_t s_up_px = 0; size_t need = (size_t)dw * dh; if (need > s_up_px) { if (s_up) heap_caps_free(s_up); - s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); + s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + if (!s_up) s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); s_up_px = s_up ? need : 0; } if (s_up) { From 3705a66288202000391f8b30de87d0e3a949b2a1 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 16:07:38 +0200 Subject: [PATCH 49/99] =?UTF-8?q?touch:=20P4=20=E2=80=94=20opt-in=20flush?= =?UTF-8?q?=20tracer=20for=20whole-screen=20colour-flash=20reports=20(#167?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic scaffolding kept from chasing the "screen flashes teal on every message" report, behind -DTDP4_FLUSH_TRACE so it is compiled out of normal builds (verified: no FLUSHTRACE string in the shipped binary). It logs any large, mostly-single-colour flush band with its RGB565 value. That answers the one question that was otherwise unanswerable from the outside: is the flash something LVGL painted, or something below it? A UI-painted flash appears as a burst of bands carrying that colour; if the screen visibly flashes and nothing logs, the cause is in the panel/DSI layer and hunting through UI widgets is wasted effort. Two notes recorded in the comment because both cost time here. One LVGL band on this board is 284x24 = 6816 px, so a size gate has to sit well below that — an earlier 8000 px threshold silently disabled the whole tracer and looked like "nothing is drawing". And the per-band uniformity scan measurably slows the flush path, so it can mask a timing-sensitive bug: any fix must be confirmed on a build with the tracer OFF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- variants/tdisplay_p4/RM69A10Display.cpp | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp index 79e2bf6..cdcad22 100644 --- a/variants/tdisplay_p4/RM69A10Display.cpp +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -145,6 +145,36 @@ bool RM69A10Display::begin() { void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) { if (!_panel || !pixels || w <= 0 || h <= 0) return; +#if defined(TDP4_FLUSH_TRACE) + // Opt-in flush tracer for whole-screen colour-flash reports (#167). OFF unless the build adds + // -DTDP4_FLUSH_TRACE, so it costs nothing normally. Logs any large, mostly-single-colour band + // with its RGB565 value: if the flash is LVGL painting something you see a burst carrying that + // colour (brand teal 0x15B6A6 -> 0x15B4); if the screen visibly flashes and nothing logs, the + // cause is below LVGL (panel/DSI) and hunting in the UI is wasted effort. Note when using it + // that the per-band uniformity scan slows the flush path, which can mask a timing-sensitive + // bug — always confirm a fix on a build WITHOUT this enabled. + { + const long px = (long)w * h; + if (px > 1500) { // one full LVGL band is 284*24 = 6816 px + const uint16_t c0 = pixels[0]; + long same = 0; + for (long i = 0; i < px; i += 37) { if (pixels[i] == c0) ++same; } // sparse uniformity probe + const long probes = (px + 36) / 37; + // Log the colour whenever a large band is mostly ONE colour. A whole-screen flash shows + // up as a burst of these in the same millisecond range, all carrying the flash colour + // (brand teal 0x15B6A6 -> rgb565 0x15B4). Rate-limited so a normal repaint storm can't + // flood the console and perturb what we are measuring. + static uint32_t s_last_log = 0; static int s_burst = 0; + const uint32_t now_ms = (uint32_t)millis(); + if (now_ms - s_last_log > 500) { s_burst = 0; s_last_log = now_ms; } + if (same * 10 >= probes * 9 && s_burst < 12) { + ++s_burst; + Serial.printf("[FLUSHTRACE] %lu x=%d y=%d w=%d h=%d rgb565=0x%04X\n", + (unsigned long)now_ms, x, y, w, h, (unsigned)c0); + } + } + } +#endif #if RM_UI_SCALE > 1 // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes // an RM_UI_SCALE x RM_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). From 4058f8dcd940d0d58ae932074e2b9437b4e6cc93 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 17:13:05 +0200 Subject: [PATCH 50/99] touch: stop the inbox rescanning the whole message ring per thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: a public channel that had accumulated 3800 messages made the interface "slow down drastically", and clearing it restored normal speed. The reporter also noticed it was still noticeable at only ~300 messages and asked whether something loops over every message. It does. threadHasMessageHistory() answered "does this thread have any stored message?" by walking the ring via getThreadMessageIndexes(). That scan stops early once it finds a match, so a thread WITH recent messages is cheap — but a thread with none walks every record in the ring doing a strncmp per record, and "has no history" is precisely what the callers are testing for. getCombinedInboxCount() and getUnreadTotal() both call it once PER THREAD, so a full ring turned every inbox refresh into threads x messages string compares. That is the reported slowdown, and it explains why it is felt well below a full ring. Now cached per thread: - an append sets the owning thread's flag directly, so the common path never scans at all; - anything that REMOVES messages (ring eviction, clearThreadHistory, removeThread, any history load) marks the cache dirty, and the next reader rebuilds every thread's flag in ONE ring pass instead of one pass per thread, stopping as soon as all live threads are resolved. clearThreadHistory clears just its own flag (no other thread is touched), so the common "clear one chat" action stays O(ring) once rather than forcing a rebuild. Behaviour is unchanged; this only removes repeated work. The separate request from the same thread — a configurable per-channel history limit with a lower default and a warning — is NOT in this commit. Builds clean on V4 + T-Deck; flashed to the T-Deck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 37 +++++++++++++++++++++++++++++++++++-- src/ui-touch/UITask.h | 17 +++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5e0dda0..b0051da 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41914,6 +41914,7 @@ bool UITask::loadMsgsFromStorage() { f.close(); _ui_msg_count = 0; _ui_msg_head = 0; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast<int>(hdr.msgcount); return true; } @@ -41954,6 +41955,7 @@ bool UITask::loadMsgsFromStorage() { _ui_msg_count = n_keep; // ring is now linear: slots 0..n_keep-1, oldest first _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast<int>(hdr.msgcount); return true; #else @@ -42075,6 +42077,7 @@ bool UITask::loadMsgsFromSegments() { _ui_msg_count = n_keep; _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _ui_seq_next = max_seq + 1; s_seg_flushed_seq = max_seq; // everything loaded IS on disk by definition // _msgcount isn't stored in segments; the threads-file header carries it and @@ -43115,6 +43118,7 @@ int UITask::findOrCreateThread(const char* name, bool channel) { bool UITask::removeThread(int idx) { if (idx < 0 || idx >= MAX_UI_THREADS) return false; if (!_ui_threads[idx].used) return false; + _thread_hist_dirty = true; // slot is being freed + its ring records dropped // Match the thread by stored name (channel/DM) before wiping the slot so // we can drop any ring messages that belonged to it. Without this the // entries would still occupy the bounded _ui_msgs ring and confuse a @@ -43387,6 +43391,11 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te m.sender[MAX_SENDER_NAME] = '\0'; strncpy(m.text, text ? text : "", MAX_MSG_TEXT); m.text[MAX_MSG_TEXT] = '\0'; + // An append is the ONLY mutation that needs no scan: this thread now demonstrably has + // history. A full ring also EVICTED the oldest record above, which may have been some other + // thread's last message, so the cached flags have to be rebuilt before they are trusted again. + if (_ui_msg_count == _ui_msg_cap) _thread_hist_dirty = true; + _thread_hist[t_idx] = true; if (_ui_msg_count < _ui_msg_cap) ++_ui_msg_count; _ui_msg_head = (_ui_msg_head + 1) % _ui_msg_cap; _ui_threads[t_idx].last_ts = m.ts; @@ -44651,10 +44660,33 @@ int UITask::getThreadCount(bool channel_mode, int out_indexes[], int max_out) co return copy_n; } +// One ring pass that answers "has any message?" for EVERY thread at once (see the note on +// _thread_hist). Stops as soon as every live thread is accounted for, so a device whose +// threads all have recent traffic barely reads the ring at all. +void UITask::rebuildThreadHistoryFlags() const { + for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_hist[i] = false; + int unresolved = 0; + for (int i = 0; i < MAX_UI_THREADS; ++i) if (_ui_threads[i].used) ++unresolved; + for (int i = 0; i < _ui_msg_count && unresolved > 0; ++i) { + const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[idx]; + for (int t = 0; t < MAX_UI_THREADS; ++t) { + if (!_ui_threads[t].used || _thread_hist[t]) continue; + if (m.channel == _ui_threads[t].channel && + strncmp(m.thread, _ui_threads[t].name, MAX_THREAD_NAME) == 0) { + _thread_hist[t] = true; + --unresolved; + break; + } + } + } + _thread_hist_dirty = false; +} + bool UITask::threadHasMessageHistory(int thread_idx) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return false; - int tmp[8]; - return getThreadMessageIndexes(thread_idx, tmp, 8, false) > 0; + if (_thread_hist_dirty) rebuildThreadHistoryFlags(); + return _thread_hist[thread_idx]; } int UITask::getCombinedInboxCount(int out_indexes[], int max_out) const { @@ -44736,6 +44768,7 @@ int UITask::clearThreadHistory(int thread_idx) { } _ui_threads[thread_idx].unread = 0; _ui_threads[thread_idx].has_mention = false; + _thread_hist[thread_idx] = false; // emptied, and no other thread was touched _threads_dirty = true; if (cleared) _msgs_dirty = true; return cleared; diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index a2ec856..b432495 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -147,6 +147,23 @@ private: * else MAX_UI_MESSAGES. Fixed for the whole boot (chosen before the PSRAM * alloc in begin()); the loader linearizes files written under another cap. */ int _ui_msg_cap = MAX_UI_MESSAGES; + /** "Does this thread have any stored message?", answered in O(1). + * + * threadHasMessageHistory() used to answer it by walking the message ring looking for a + * match, and the inbox/unread paths call it once PER THREAD. A thread that HAS recent + * messages exits that scan early; a thread with none scans every record in the ring doing + * a strncmp each — and "no history" is exactly what those callers are testing for. With a + * busy public channel filling the ring (a field report: 3800 messages) each inbox refresh + * became tens of thousands of string compares, which is the reported "interface slows down + * drastically", and why it was still felt at only ~300 messages. + * + * Appends set the owning thread's flag directly, so the common path never scans. Anything + * that REMOVES messages (ring eviction, delete, clear, a fresh load) can only invalidate + * the flags, so it just marks them dirty and the next reader rebuilds all threads in ONE + * ring pass instead of one pass per thread. */ + mutable bool _thread_hist[MAX_UI_THREADS] = { false }; + mutable bool _thread_hist_dirty = true; + void rebuildThreadHistoryFlags() const; unsigned long _next_thread_seed; UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin() UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin() From b846dc62fc180ec52890032f509f6bc915d4d9f9 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 17:22:16 +0200 Subject: [PATCH 51/99] touch: chat-list preview stuck on an old message (T-Display P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on the P4: the preview line under a channel in the chats list showed an older message instead of the latest, and never moved on. getThreadLastMessage() chose the message with the highest m.ts, and m.ts is the ESP32 SYSTEM clock. That clock is not monotonic across reboots — it restarts from ESP32RTCClock::begin()'s power-on seed on every boot and then only climbs with uptime until a real time source arrives. So a message received hours into an earlier boot carries a LARGER timestamp than one received a minute into this boot, wins the max(ts) comparison forever, and pins the preview to itself. It showed up on the P4 because that board has an RTC chip, so the core wrote time to the chip and never to the system clock, which sat on the seed indefinitely (fixed separately in fe87ff0 by mirroring the mesh RTC into the system clock). Any board can hit a milder version of this after a reboot with no time source, so the selection itself is what needed fixing. Now the newest message is taken by RING ORDER — walk back from the head and take the first match. Ring order is the real arrival order and cannot be skewed by a wrong clock. It is also faster: it exits on the first hit instead of scanning every slot in the ring, which matters on the same busy-channel devices as the inbox-scan fix in 4058f8d. Builds clean on V4 + T-Deck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.h | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index b432495..4cc380b 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -381,18 +381,26 @@ public: if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_msgs) return false; const bool ch = _ui_threads[idx].channel; const char* nm = _ui_threads[idx].name; - int best = -1; uint32_t best_ts = 0; - for (int i = 0; i < _ui_msg_cap; ++i) { - const UIMessage& m = _ui_msgs[i]; + // Newest by RING ORDER, not by timestamp. m.ts is taken from the ESP32 system clock, + // which is not monotonic across reboots: it restarts from ESP32RTCClock::begin()'s + // power-on seed every boot and only climbs with uptime until a real time source lands. + // Picking max(ts) therefore let a message received hours into an EARLIER boot outrank + // every message from this one, so the chat-list preview stuck on an old message and + // never updated (reported on the T-Display P4, whose system clock stayed on that seed + // because it has an RTC chip — see the mirror in ClockFloorRTC). Ring order is the + // actual arrival order and cannot be wrong, so walk back from the head and take the + // first match; that also exits immediately instead of scanning the whole ring. + for (int i = 0; i < _ui_msg_count; ++i) { + const int slot = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[slot]; if (!m.text[0] || m.channel != ch) continue; if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue; - if (best < 0 || m.ts >= best_ts) { best = i; best_ts = m.ts; } // >= : a later slot wins ts ties (newer in the ring) + if (sender && sender_cap) { strncpy(sender, m.sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; } + if (text && text_cap) { strncpy(text, m.text, text_cap - 1); text[text_cap - 1] = '\0'; } + if (outgoing) *outgoing = m.outgoing; + return true; } - if (best < 0) return false; - if (sender && sender_cap) { strncpy(sender, _ui_msgs[best].sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; } - if (text && text_cap) { strncpy(text, _ui_msgs[best].text, text_cap - 1); text[text_cap - 1] = '\0'; } - if (outgoing) *outgoing = _ui_msgs[best].outgoing; - return true; + return false; } int threadScroll() const { return _thread_scroll; } void setThreadScroll(int v) { _thread_scroll = v; } From 4942fcf5aab37fb3257f3b14a65e7e92dff5adc0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 17:27:32 +0200 Subject: [PATCH 52/99] touch: configurable per-chat history limit, default 250, warns when turned off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the "large message history slows the interface down" report. The first half (4058f8d) removed the repeated ring scan; this bounds how much history a single chat can accumulate in the first place, which is what was asked for in the channel: a configurable limit, with a lower default, and a warning. Until now the message store was one shared ring with no per-chat bound, so a busy public channel could occupy essentially all of it. That both starved every other chat of its history and kept the ring permanently full, which is the condition the slow paths were suffering under. The reporter had 3800 messages in one channel. - New pref hist_per_chat (touch-cfg v42), default 250 messages per chat. Chosen to be deep enough to scroll back through a conversation while staying far below the 5000-record SD ring, so no single chat can dominate it. - Enforced on append: enforceHistoryCap() blanks that chat's oldest records until it is back within the limit. It walks from the oldest end, so for the chat that is actually over the cap (the one filling the ring) the records it wants are found immediately rather than after a full scan. - Settings -> Chats: "Keep per chat (messages)" — 100 / 250 / 500 / 1000 / 2000 / No limit, with a line explaining that older messages are dropped past the limit. - "No limit" is a deliberate choice, not a silent one: it raises a confirmation saying a busy channel can then fill the whole store and make things slower. The dropdown reverts first and only commits from the confirm handler, so dismissing the dialog leaves the previous limit in place. The thread-history cache from 4058f8d becomes a per-thread COUNT rather than a bool, since the cap needs to know how many records a chat holds; "has history" is just count > 0, so the O(1) inbox behaviour is unchanged. Existing installs pick up the 250 default and will trim on the next message in an over-long chat. Anyone who wants the old unbounded behaviour can select No limit. Builds clean on V4 + T-Deck; flashed to the T-Deck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/helpers/esp32/TouchPrefsStore.cpp | 19 ++++- src/helpers/esp32/TouchPrefsStore.h | 6 ++ src/ui-touch/UITask.cpp | 104 +++++++++++++++++++++++--- src/ui-touch/UITask.h | 7 +- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index fe1ec02..5dd014f 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 41; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna) +static const uint8_t TOUCH_CFG_VER = 42; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -112,6 +112,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing) uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing) uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing) + uint16_t hist_per_chat; // max stored messages per chat/thread; 0 = no per-chat cap (ring only) - v42 (trailing) uint8_t p4_antenna; // RESERVED (was: T-Display P4 antenna select). The antenna choice is session-only by design and is never stored — see the note further down — v41 (trailing) }; @@ -159,6 +160,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.lock_color = DEFAULT_LOCK_COLOR; c.accent = DEFAULT_ACCENT; c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback + c.hist_per_chat = 250; // keep the newest 250 per chat: enough to scroll back, small enough to stay quick c.p4_antenna = 0; // reserved, unused: the P4 antenna choice is never persisted c.sig_probe_en = DEFAULT_SIG_PROBE_EN; c.sig_poll_min = DEFAULT_SIG_POLL_MIN; @@ -420,6 +422,21 @@ static const uint16_t SIG_POLL_MAX_MINS = 1440; // choice must never reach flash. p4_antenna stays as a reserved trailing byte: dropping it // would rewind TOUCH_CFG_VER on devices already carrying a v41 blob, for no gain. +// --- Per-chat history cap ----------------------------------------------------- +// A single busy channel could previously fill the entire shared message ring, which both +// starved every other chat of history and made the inbox slow (see the thread-history cache +// in UITask). This bounds each chat independently. 0 = no per-chat cap, i.e. the old +// behaviour, which the settings UI warns about rather than hiding. +uint16_t touchPrefsGetHistPerChat() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.hist_per_chat; +} +bool touchPrefsSetHistPerChat(uint16_t n) { + if (!s_begun) touchPrefsBegin(); + s_cfg.hist_per_chat = n; + return cfgFlush(); +} + bool touchPrefsGetSigProbeEnabled() { if (!s_begun) touchPrefsBegin(); return s_cfg.sig_probe_en != 0; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 5d3b993..aac04cb 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -109,6 +109,12 @@ bool touchPrefsSetMapNight(bool on); uint8_t touchPrefsGetHistSyncAfter(); bool touchPrefsSetHistSyncAfter(uint8_t n); +/** Max stored messages kept per chat; 0 = no per-chat cap. Default 250. + * Without a cap one busy channel can fill the whole shared ring, starving every other + * chat of history and slowing the UI down as the ring fills. */ +uint16_t touchPrefsGetHistPerChat(); +bool touchPrefsSetHistPerChat(uint16_t n); + /** Last map zoom level, persisted so the map reopens where the user left it. * 0 = unset (let the auto-snap pick a level for the available tile pack). */ uint8_t touchPrefsGetMapZoom(); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b0051da..5496003 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -8653,6 +8653,42 @@ static void syncClockCb(lv_event_t* e) { // Chat-save fallback dropdown (Settings -> General): selection index -> number // of consecutive failed background history writes before the flush retries // synchronously on the UI task (0 = background retries only, never hitch). +// Per-chat history cap (Settings -> Chats). "No limit" is last and warns, because an +// uncapped busy channel is exactly what fills the shared ring and slows the UI down. +static const uint16_t k_hist_cap_opts[] = { 100, 250, 500, 1000, 2000, 0 }; +static lv_obj_t* s_hist_cap_dd = nullptr; +static uint16_t s_hist_cap_pending = 0; + +static void histCapApplyUncapped() { + touchPrefsSetHistPerChat(0); + if (s_hist_cap_dd) lv_dropdown_set_selected(s_hist_cap_dd, sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]) - 1); + if (g_lv.task) g_lv.task->showAlert(TR("History limit off"), 1200); +} + +static void histCapSelectCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; + lv_obj_t* dd = lv_event_get_target(e); + const uint16_t sel = lv_dropdown_get_selected(dd); + if (sel >= sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0])) return; + const uint16_t v = k_hist_cap_opts[sel]; + if (v == 0) { + // Revert first and only commit from the confirm handler: showConfirm has no cancel + // callback, so a dismissed dialog must leave the old limit in place. + s_hist_cap_dd = dd; + uint16_t cur = touchPrefsGetHistPerChat(); + uint16_t cur_sel = 1; + for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) + if (k_hist_cap_opts[i] == cur) { cur_sel = i; break; } + lv_dropdown_set_selected(dd, cur_sel); + showConfirm(TR("Turn the history limit off?\n\nA busy channel can then fill the whole message " + "store, which uses more space and makes the chat list and app noticeably slower. " + "Only do this if you really need the full backlog."), + TR("Turn off"), histCapApplyUncapped); + return; + } + touchPrefsSetHistPerChat(v); +} + static const uint8_t k_hist_sync_opts[] = { 0, 1, 2, 3, 5, 8 }; static void histSyncSelectCb(lv_event_t* e) { const uint16_t sel = lv_dropdown_get_selected(lv_event_get_target(e)); @@ -11711,6 +11747,29 @@ static void buildDeviceSettings(int sec) { hitches for its duration — users who prefer a never-hitching UI pick Off (background retries only; the RAM ring keeps everything meanwhile). */ { + // Per-chat history limit. Sits above the save-fallback control because it is the one + // people actually need: without it a single busy channel fills the shared message store, + // which is what makes the chat list crawl. + y += settingsRowLabel(body, y, 0, TR("Keep per chat (messages)"), COLOR_SUB, &g_font_12, 0) + 2; + { + lv_obj_t* cd = lv_dropdown_create(body); + lv_dropdown_set_options(cd, TR("100\n250\n500\n1000\n2000\nNo limit")); + const uint16_t cur_cap = touchPrefsGetHistPerChat(); + uint16_t cap_sel = 1; // index of 250 — the default when the stored value isn't listed + for (uint16_t i = 0; i < sizeof(k_hist_cap_opts) / sizeof(k_hist_cap_opts[0]); ++i) + if (k_hist_cap_opts[i] == cur_cap) { cap_sel = i; break; } + lv_dropdown_set_selected(cd, cap_sel); + lv_obj_set_width(cd, lv_pct(100)); + lv_obj_set_pos(cd, 2, y); + lv_obj_add_event_cb(cd, histCapSelectCb, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(cd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr); + y += SC(44); + y += settingsRowLabel(body, y, 0, + TR("Older messages in a chat are dropped past this. A very large or " + "unlimited history makes the chat list slower."), + COLOR_SUB, &g_font_12, 0) + 6; + } + y += settingsRowLabel(body, y, 0, TR("Chat save fallback (failed tries)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* dd = lv_dropdown_create(body); lv_dropdown_set_options(dd, TR("Off\n1\n2\n3\n5\n8")); @@ -43395,10 +43454,11 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te // history. A full ring also EVICTED the oldest record above, which may have been some other // thread's last message, so the cached flags have to be rebuilt before they are trusted again. if (_ui_msg_count == _ui_msg_cap) _thread_hist_dirty = true; - _thread_hist[t_idx] = true; + if (!_thread_hist_dirty && _thread_msgs[t_idx] < 0xFFFF) ++_thread_msgs[t_idx]; if (_ui_msg_count < _ui_msg_cap) ++_ui_msg_count; _ui_msg_head = (_ui_msg_head + 1) % _ui_msg_cap; _ui_threads[t_idx].last_ts = m.ts; + enforceHistoryCap(t_idx); // keep this chat within the per-chat cap (Settings -> Chats) if (mark_unread) _ui_threads[t_idx].unread++; if (mark_unread && channel && !outgoing && textMentionsMe(text)) { _ui_threads[t_idx].has_mention = true; // blue @ on the inbox row @@ -44664,18 +44724,16 @@ int UITask::getThreadCount(bool channel_mode, int out_indexes[], int max_out) co // _thread_hist). Stops as soon as every live thread is accounted for, so a device whose // threads all have recent traffic barely reads the ring at all. void UITask::rebuildThreadHistoryFlags() const { - for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_hist[i] = false; - int unresolved = 0; - for (int i = 0; i < MAX_UI_THREADS; ++i) if (_ui_threads[i].used) ++unresolved; - for (int i = 0; i < _ui_msg_count && unresolved > 0; ++i) { + for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_msgs[i] = 0; + for (int i = 0; i < _ui_msg_count; ++i) { const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; const UIMessage& m = _ui_msgs[idx]; + if (!m.thread[0]) continue; // blanked by a clear / cap trim for (int t = 0; t < MAX_UI_THREADS; ++t) { - if (!_ui_threads[t].used || _thread_hist[t]) continue; + if (!_ui_threads[t].used) continue; if (m.channel == _ui_threads[t].channel && strncmp(m.thread, _ui_threads[t].name, MAX_THREAD_NAME) == 0) { - _thread_hist[t] = true; - --unresolved; + if (_thread_msgs[t] < 0xFFFF) ++_thread_msgs[t]; break; } } @@ -44683,10 +44741,36 @@ void UITask::rebuildThreadHistoryFlags() const { _thread_hist_dirty = false; } +// Trim this thread back to the per-chat cap by blanking its OLDEST records. Walking from the +// oldest end finds them immediately for the chat that is actually over the cap (it is the one +// filling the ring), so the common case is a couple of steps, not a full scan. +void UITask::enforceHistoryCap(int thread_idx) { + const uint16_t cap = touchPrefsGetHistPerChat(); + if (!cap || thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return; + if (_thread_hist_dirty) rebuildThreadHistoryFlags(); + if (_thread_msgs[thread_idx] <= cap) return; + const bool ch = _ui_threads[thread_idx].channel; + const char* nm = _ui_threads[thread_idx].name; + int trimmed = 0; + for (int i = _ui_msg_count - 1; i >= 0 && _thread_msgs[thread_idx] > cap; --i) { + const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; // i = oldest .. 0 = newest + UIMessage& m = _ui_msgs[idx]; + if (!m.thread[0] || m.channel != ch) continue; + if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue; + m.thread[0] = '\0'; + m.text[0] = '\0'; + m.sender[0] = '\0'; + segMarkSeqDirty(m.seq); + --_thread_msgs[thread_idx]; + ++trimmed; + } + if (trimmed) _msgs_dirty = true; +} + bool UITask::threadHasMessageHistory(int thread_idx) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return false; if (_thread_hist_dirty) rebuildThreadHistoryFlags(); - return _thread_hist[thread_idx]; + return _thread_msgs[thread_idx] > 0; } int UITask::getCombinedInboxCount(int out_indexes[], int max_out) const { @@ -44768,7 +44852,7 @@ int UITask::clearThreadHistory(int thread_idx) { } _ui_threads[thread_idx].unread = 0; _ui_threads[thread_idx].has_mention = false; - _thread_hist[thread_idx] = false; // emptied, and no other thread was touched + _thread_msgs[thread_idx] = 0; // emptied, and no other thread was touched _threads_dirty = true; if (cleared) _msgs_dirty = true; return cleared; diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 4cc380b..7efbecf 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -161,9 +161,12 @@ private: * that REMOVES messages (ring eviction, delete, clear, a fresh load) can only invalidate * the flags, so it just marks them dirty and the next reader rebuilds all threads in ONE * ring pass instead of one pass per thread. */ - mutable bool _thread_hist[MAX_UI_THREADS] = { false }; - mutable bool _thread_hist_dirty = true; + mutable uint16_t _thread_msgs[MAX_UI_THREADS] = { 0 }; + mutable bool _thread_hist_dirty = true; void rebuildThreadHistoryFlags() const; + /** Drop this thread's oldest stored messages until it is back within the per-chat cap + * (touchPrefsGetHistPerChat; 0 = uncapped). Called after an append. */ + void enforceHistoryCap(int thread_idx); unsigned long _next_thread_seed; UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin() UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin() From 1863ebdd2c19f755259a6c9d23f976fc02f34d51 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Thu, 30 Jul 2026 17:39:18 +0200 Subject: [PATCH 53/99] touch: beta_53 release notes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- release-notes/beta_53.txt | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 release-notes/beta_53.txt diff --git a/release-notes/beta_53.txt b/release-notes/beta_53.txt new file mode 100644 index 0000000..2af6992 --- /dev/null +++ b/release-notes/beta_53.txt @@ -0,0 +1,36 @@ +# beta_53 - speed with a big chat history, blocking that actually works, T-Display P4 fixes +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Read this first ------------------------------------------------------------------- +HEADS UP - chats now keep the newest 250 messages each by default. If you have a chat with more than that, the older ones are dropped the next time it receives a message. Change it or turn it off under Settings > Chats > "Keep per chat (messages)" BEFORE you let a busy channel run, if you want to keep a deep backlog. + +# --- Speed ---------------------------------------------------------------------------- +Fixed the big one: a large chat history no longer slows the whole interface down. With a busy channel the chat list was re-scanning the entire message store once for every chat, every time it refreshed - so the more history you had, the slower everything got. A device with 3800 messages in one channel was crawling. That work is now done once instead of once per chat. +New setting: "Keep per chat (messages)" under Settings > Chats. Choose 100, 250, 500, 1000, 2000 or No limit. The default is 250. Older messages in a chat are dropped past the limit, which stops one busy channel filling the whole message store and starving your other chats of history. +Turning the limit off asks you to confirm first, because an unlimited history is exactly what made things slow. + +# --- Blocking ------------------------------------------------------------------------- +Blocking someone now actually works. Three separate bugs: blocking a sender in a channel never saved properly so it did not stick, blocking a person inside a room server silenced the ENTIRE room instead of that one person, and blocked names were only filtered in channels so room posts still came through. Thanks to D-Melhede for reporting. +Unblocking now fully clears a block. Blocks were stored in two places and only one was being removed, so someone could stay silently blocked after you unblocked them. + +# --- Chats ---------------------------------------------------------------------------- +Fixed the chat list preview showing an old message and never updating. It picked the "newest" message by timestamp, which goes wrong on a device whose clock restarts at boot - so a message from an earlier session could outrank everything new forever. It now uses the actual arrival order. Mostly seen on the T-Display P4. +Notification sound works again. A failed map-tile fetch could leave an internal counter stuck, and that counter was silently muting every chime. Thanks to ropafiro for reporting (#184). + +# --- Stability + connection ------------------------------------------------------------ +Fixed multi-second freezes on Heltec V4 units in a busy mesh. Every advert heard was triggering a redundant write to internal flash, and contact saves were not rate-limited, so the interface could lock up for seconds at a time with nothing obviously happening. Thanks to Raph for the detailed report and video. +Wi-Fi scanning no longer gets stuck off. If the connection dropped at the wrong moment the scan gate stayed shut for good; it now clears itself and re-enables auto-reconnect (#171). +The phone app gets its replies again. Ping and telemetry responses could be swallowed before reaching the app (#124). +Typing into the web control panel no longer drops characters. Thanks to VE2CCK for reporting. + +# --- Apps ----------------------------------------------------------------------------- +The Airtime and Snake apps now work like the Spectrum app: a proper "< back" title bar, and you can no longer accidentally tap or swipe through to the screen behind them. They also no longer risk a crash when closing. +Swiping inside any full-screen app (Airtime, Spectrum, RF Monitor, Discover, Web, VNC, Remote) no longer jumps you to another tab. +Fixed three older reports from dreirund: the crash-restart dialog text overlapping its buttons (#97), the settings sheet opened from "Discovered" being cut off (#109), and room servers now showing their own icon in the contacts list (#106). + +# --- T-Display P4 ---------------------------------------------------------------------- +The clock is fixed. The P4 showed the wrong time on screen while sent messages carried the correct one, and using "Sync clock from system" then broke the good clock too. Its real-time-clock chip was being set but the system clock never was. +New: LoRa antenna setting under Settings > Radio. The on-board antenna is selected on every boot, and switching to the external socket asks you to confirm an antenna is actually connected first - transmitting into an empty connector can damage the radio. +If sending has been much weaker than receiving on your P4, this is why: the firmware was transmitting on the on-board antenna while listening on the external one. Pick the antenna you actually have fitted. +Reduced a source of the whole-screen flicker (#167) by keeping the display's working buffer off the shared memory bus. Please report whether you still see it. +Text size and the control-centre layout fixes from earlier betas also apply. From 64308664db1da04eb0a7255294101f9c67daa791 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 09:27:11 +0200 Subject: [PATCH 54/99] =?UTF-8?q?touch:=20P4=20=E2=80=94=20make=20the=20GP?= =?UTF-8?q?S=20boot=20diagnostic=20tell=20the=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line here claimed "[GPS] L76K DETECTED (NMEA on GPIO22)" but never looked at the UART. It reported whether the "gps" SETTING had been registered, and on this board ENV_SKIP_GPS_DETECT forces that true unconditionally (we set it because the P4's heavy boot misses the core's 1 s detect window, and a false negative hides the GPS toggle entirely). So it printed DETECTED on every boot whether or not a single byte had ever arrived, and it survived several rounds of "GPS still isn't working" while proving nothing. Replaced with a real 3 s drain of Serial1 that reports byte count, complete NMEA sentence count, the configured baud and RX pin, and the first sentence seen. That splits the two cases that were being conflated: bytes arriving means pins, rails, wake and baud are all correct and any stuck "acquiring" is a FIX problem (antenna or sky view); zero bytes means the link itself is dead. First run on hardware answered the open question immediately: [GPS] UART probe: 1985 bytes, 48 lines in 3s @ 9600 baud, module TX -> GPIO22 [GPS] first sentence: $GLGSV,1,1,00,0*79 1985 bytes of well-formed NMEA proves the link is good, and GLGSV ",00," is zero satellites in view, i.e. an antenna/sky-view issue and not firmware. For the record, the config was already identical to Meck-P4's working port (components/meshcore/variant.h: PIN_GPS_TX 22 / PIN_GPS_RX 23, wake on XL9535 IO11), and their L76K driver confirms both our other values: begin() defaults to 9600 baud, and sleep(false) drives the wake pin HIGH. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- tdisplay_p4/main/main.cpp | 43 +++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index eb1e27f..adf0324 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -237,11 +237,46 @@ static void wadameshSetup() { // here — the P4 has RAM to spare, and GPS detection itself needs an intact first second. Serial1.setRxBufferSize(4096); sensors.begin(); - { // GPS detect visibility (the "gps" setting is only registered when NMEA was heard) - bool has_gps = false; + { // ---- GPS reality check ------------------------------------------------------------- + // The line that used to be here reported whether the "gps" SETTING got registered, which on + // this board is FORCED true by ENV_SKIP_GPS_DETECT (we set that because the P4's heavy boot + // misses the core's 1 s detect window, and a false negative hides the GPS toggle entirely). + // So it printed DETECTED unconditionally and proved nothing, including across several rounds + // of "GPS still isn't working". Read the UART directly instead and report what arrives. + bool has_setting = false; for (int i = 0; i < sensors.getNumSettings(); i++) - if (strcmp(sensors.getSettingName(i), "gps") == 0) has_gps = true; - printf("[GPS] L76K %s\n", has_gps ? "DETECTED (NMEA on GPIO22)" : "NOT detected (no NMEA in the 1s window)"); + if (strcmp(sensors.getSettingName(i), "gps") == 0) has_setting = true; + + // Drain up to 3 s of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and a + // stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead. + uint32_t bytes = 0, lines = 0; + char first[96]; first[0] = '\0'; + char cur[96]; size_t cl = 0; + const uint32_t until = millis() + 3000; + while ((int32_t)(millis() - until) < 0) { + while (Serial1.available() > 0) { + const int c = Serial1.read(); + if (c < 0) break; + ++bytes; + if (c == '\n' || c == '\r') { + if (cl) { + cur[cl] = '\0'; + ++lines; + if (!first[0] && cur[0] == '$') { strncpy(first, cur, sizeof first - 1); first[sizeof first - 1] = '\0'; } + cl = 0; + } + } else if (cl < sizeof(cur) - 1) { + cur[cl++] = (char)c; + } + } + delay(5); + } + printf("[GPS] setting_registered=%d (forced by ENV_SKIP_GPS_DETECT, proves nothing)\n", (int)has_setting); + printf("[GPS] UART probe: %lu bytes, %lu lines in 3s @ %d baud, module TX -> GPIO%d\n", + (unsigned long)bytes, (unsigned long)lines, (int)GPS_BAUD_RATE, (int)PIN_GPS_TX); + if (first[0]) printf("[GPS] first sentence: %s\n", first); + else if (bytes) printf("[GPS] bytes but no complete '$' sentence -> wrong baud or line noise\n"); + else printf("[GPS] NOTHING on the UART -> check wake (XL9535 IO11 HIGH), rails, RX pin\n"); } ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); board.onBootComplete(); From bdc0c7ffb65102b1143c5245820b2f5423415d64 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 09:37:42 +0200 Subject: [PATCH 55/99] =?UTF-8?q?touch:=20P4=20GPS=20probe=20=E2=80=94=20p?= =?UTF-8?q?er-constellation=20view,=20and=20trim=20the=20boot=20cost=20to?= =?UTF-8?q?=201.2s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 6430866. Two changes. Print one example of each distinct NMEA sentence type, not just the first line. A single GxGSV is not enough to judge satellite visibility: the first boot showed "$GLGSV,1,1,00" and I read that as zero satellites, but GLGSV is GLONASS ONLY and says nothing about GPS, Galileo or BeiDou. The full set showed 10 GPS + 5 GLONASS in view with SNR 33/35/23, plus "$GPTXT,...,ANTENNA OK" from the module's own antenna check, i.e. a healthy receiver mid-acquisition rather than a fault. Shorten the drain from 3s to 1.2s. It runs on EVERY boot and it consumes bytes the NMEA parser would otherwise see, so it only needs to be long enough to catch one of each sentence type (the L76K repeats the full set about once a second). 1.2s still captured all nine types on the verification run. Confirmed working on hardware: a later boot reported $GNGGA,073620.000,5050.80018,N,00452.95932,E,1,05,2.2,36.0,M $GNGSA,A,3,08,22,32,...,2.2 i.e. a valid 3D fix on 5 satellites at HDOP 2.2, down from no fix / HDOP 25.5 a few minutes earlier. The GPS path was never broken; the old diagnostic just could not tell anyone that, because it reported a flag ENV_SKIP_GPS_DETECT forces true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- tdisplay_p4/main/main.cpp | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index adf0324..3156f48 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -247,12 +247,18 @@ static void wadameshSetup() { for (int i = 0; i < sensors.getNumSettings(); i++) if (strcmp(sensors.getSettingName(i), "gps") == 0) has_setting = true; - // Drain up to 3 s of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and a - // stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead. + // Drain a SHORT window of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and + // a stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead. + // 1.2 s, not longer: this runs on every boot and it CONSUMES bytes the NMEA parser would + // otherwise see, so it is deliberately just long enough to catch one of each sentence type + // (the L76K repeats the full set about once a second). uint32_t bytes = 0, lines = 0; char first[96]; first[0] = '\0'; char cur[96]; size_t cl = 0; - const uint32_t until = millis() + 3000; + // Keep one example of each distinct GSV/GGA/RMC talker so every constellation is represented + // without dumping the whole stream (the same 5 sentence types repeat every second). + char kept[10][96]; int kept_n = 0; + const uint32_t until = millis() + 1200; while ((int32_t)(millis() - until) < 0) { while (Serial1.available() > 0) { const int c = Serial1.read(); @@ -263,6 +269,12 @@ static void wadameshSetup() { cur[cl] = '\0'; ++lines; if (!first[0] && cur[0] == '$') { strncpy(first, cur, sizeof first - 1); first[sizeof first - 1] = '\0'; } + // Keep the first of each sentence TYPE (talker + type, e.g. "$GPGSV", "$GNGGA"). + if (cur[0] == '$' && cl >= 6 && kept_n < 10) { + bool seen = false; + for (int k = 0; k < kept_n; ++k) if (strncmp(kept[k], cur, 6) == 0) { seen = true; break; } + if (!seen) { strncpy(kept[kept_n], cur, sizeof kept[0] - 1); kept[kept_n][sizeof kept[0] - 1] = '\0'; ++kept_n; } + } cl = 0; } } else if (cl < sizeof(cur) - 1) { @@ -272,11 +284,20 @@ static void wadameshSetup() { delay(5); } printf("[GPS] setting_registered=%d (forced by ENV_SKIP_GPS_DETECT, proves nothing)\n", (int)has_setting); - printf("[GPS] UART probe: %lu bytes, %lu lines in 3s @ %d baud, module TX -> GPIO%d\n", + printf("[GPS] UART probe: %lu bytes, %lu lines in 1.2s @ %d baud, module TX -> GPIO%d\n", (unsigned long)bytes, (unsigned long)lines, (int)GPS_BAUD_RATE, (int)PIN_GPS_TX); if (first[0]) printf("[GPS] first sentence: %s\n", first); else if (bytes) printf("[GPS] bytes but no complete '$' sentence -> wrong baud or line noise\n"); else printf("[GPS] NOTHING on the UART -> check wake (XL9535 IO11 HIGH), rails, RX pin\n"); + // Satellite visibility across ALL constellations, plus the fix line. One GxGSV is not enough: + // GLGSV is GLONASS only, so "00" there says nothing about GPS/Galileo/BeiDou. GSV field 3 is + // sats-in-view for that constellation; GGA field 6 is fix quality (0 = no fix) and field 7 the + // sats used. Non-zero in-view with quality 0 means it IS hearing satellites and just needs + // time or better sky. All-zero in-view points at the antenna or shielding. + if (kept_n) { + printf("[GPS] --- satellite view (%d sentences) ---\n", kept_n); + for (int i = 0; i < kept_n; ++i) printf("[GPS] %s\n", kept[i]); + } } ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); board.onBootComplete(); From 5c9f9cffefa6e7631eafb49cf268acf1d034f695 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 09:53:52 +0200 Subject: [PATCH 56/99] touch: GPS page says what it is actually doing, not just "acquiring..." The GPS settings page showed "GPS: acquiring..." and nothing else until a fix landed. That single string covers two completely different situations: a receiver happily tracking satellites that needs another minute, and a receiver that is not talking at all. They look identical to the user, so there is no way to tell a working device from a broken one by looking at it. That ambiguity is what turned a healthy T-Display P4 GPS into a long investigation. While acquiring, the page now shows: - how long it has been searching, so a stuck receiver is obvious from elapsed time alone rather than from staring at an unchanging word; - whether the receiver has decoded satellite TIME. That is the useful tell: a GPS decodes time/date off the air well before it has enough satellites to solve a position, so valid time with no fix is positive proof the antenna and the UART are fine and it is genuinely mid-acquisition. The wording then points at sky view, which is the actual remedy; - and when there is no satellite data at all, it says so plainly instead of implying progress. On a fix it now also shows altitude alongside the satellite count and position, and the count is only printed when non-zero (it is satellites USED, so a bare "0 sats" read as a fault). Not included, and worth being clear about: satellites IN VIEW and per-satellite SNR are NOT reachable from this repo. The core's MicroNMEALocationProvider keeps its MicroNMEA object private and LocationProvider exposes only fix / sats-used / lat / lon / alt / timestamp, so a full sky view needs a monorepo core change plus a core-* tag bump. The boot probe added in bdc0c7f prints that data over serial in the meantime. Builds clean on V4, T-Deck and the P4; flashed and boot-verified on the P4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 70 +++++++++++++++++++++++++++++++++++------ src/ui-touch/UITask.h | 7 +++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5496003..afd4110 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2237,18 +2237,51 @@ static bool settingsModalIsOpen() { return g_set_modal.root != nullptr; } // Compact one-line GPS status for the Device settings panel + control center. // TR("GPS: off") / "GPS: searching · N sats" / "GPS: fix · N sats <lat>, <lon>". +// Stamped when acquisition starts so the page can show how long it has been searching; a stuck +// receiver is then obvious from the elapsed time alone. Cleared on fix and when GPS is off. +static uint32_t s_gps_acq_since = 0; + static const char* gpsStatusStr() { - static char s[72]; - if (!g_lv.task || !g_lv.task->getGPSState()) { snprintf(s, sizeof s, TR("GPS: off")); return s; } - // satellitesCount() is satellites USED IN THE FIX (0 until a lock), so during - // cold acquisition it stays 0 — show "acquiring…" rather than a misleading - // "0 sats". On lock, show the sat count + the (auto-populated) coordinates. - if (!g_lv.task->getGpsFix()) { snprintf(s, sizeof s, TR("GPS: acquiring...")); return s; } + static char s[200]; + if (!g_lv.task || !g_lv.task->getGPSState()) { + s_gps_acq_since = 0; + snprintf(s, sizeof s, TR("GPS: off")); + return s; + } + // satellitesCount() is satellites USED IN THE FIX, so it stays 0 through a cold acquisition and + // a bare "0 sats" would read as broken. The old line therefore said only "acquiring...", which + // is just as unhelpful: it cannot distinguish a receiver happily tracking satellites that needs + // another minute from one that is not talking at all. Both looked identical to the user, and + // that ambiguity is exactly what cost a long P4 investigation. + // + // The tell is TIME. A receiver decodes satellite time/date well before it has enough satellites + // to solve a position, so "no fix but valid GPS time" is positive proof that the antenna and the + // link are fine and it is genuinely mid-acquisition. + if (!g_lv.task->getGpsFix()) { + const uint32_t now = millis(); + if (!s_gps_acq_since) s_gps_acq_since = now; + const uint32_t secs = (now - s_gps_acq_since) / 1000u; + const bool has_time = g_lv.task->getGpsTime() != 0; + int n = snprintf(s, sizeof s, TR("GPS: searching")); + if (n < (int)sizeof s) { + if (secs < 600) n += snprintf(s + n, sizeof s - n, " %lum%02lus", (unsigned long)(secs / 60), (unsigned long)(secs % 60)); + else n += snprintf(s + n, sizeof s - n, " %lum", (unsigned long)(secs / 60)); + } + if (n < (int)sizeof s) { + snprintf(s + n, sizeof s - n, "\n%s", has_time + ? TR("Receiver OK, satellite time decoded. Waiting for enough satellites - needs a clear view of the sky.") + : TR("No satellite data yet. Outdoors with a clear view of the sky it should appear within a few minutes.")); + } + return s; + } + s_gps_acq_since = 0; const int sats = g_lv.task->getGpsSats(); + const int alt = g_lv.task->getGpsAltitude(); int n = snprintf(s, sizeof s, TR("GPS: fix")); - if (sats >= 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d sats", sats); + if (sats > 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d sats", sats); + if (alt && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d m", alt); if (n < (int)sizeof s) - snprintf(s + n, sizeof s - n, " %.5f, %.5f", + snprintf(s + n, sizeof s - n, "\n%.5f, %.5f", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); return s; } @@ -11498,7 +11531,8 @@ static void buildDeviceSettings(int sec) { lv_obj_set_style_text_font(g_set_modal.gps_status, &g_font_12, LV_PART_MAIN); lv_obj_set_pos(g_set_modal.gps_status, 4, y); lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); - y += SC(22); + lv_obj_update_layout(g_set_modal.gps_status); + y += LV_MAX(SC(22), lv_obj_get_height(g_set_modal.gps_status) + SC(6)); // status is 2 lines now // (Expansion Kit moved to the Sensors page — see the DSEC_SENSORS block below.) @@ -45223,6 +45257,24 @@ int UITask::getGpsSats() { return lp ? (int)lp->satellitesCount() : -1; } +uint32_t UITask::getGpsTime() { + if (!_sensors) return 0; + LocationProvider* lp = _sensors->getLocationProvider(); + if (!lp) return 0; + const long t = lp->getTimestamp(); + // The provider builds this from the NMEA date/time fields, which read as year 0 (epoch far in + // the past) until the receiver has actually decoded them off the air. Anything plausibly recent + // means real satellite time. + return (t > 1700000000L) ? (uint32_t)t : 0; +} + +int UITask::getGpsAltitude() { + if (!_sensors) return 0; + LocationProvider* lp = _sensors->getLocationProvider(); + if (!lp || !lp->isValid()) return 0; + return (int)(lp->getAltitude() / 1000); // provider reports millimetres +} + // Keep the node's advertised location synced to GPS once we have a fix, and // persist it occasionally so a fix survives reboot ("had a fix at least once"). // Called every UITask::loop(). No-op on boards without GPS (provider == NULL). diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 7efbecf..e7acb2c 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -459,6 +459,13 @@ public: bool getGpsFix(); /** Satellites currently in view, or -1 if unknown / no GPS hardware. */ int getGpsSats(); + /** UTC epoch the GPS itself has decoded, or 0 if it has none yet. A receiver decodes TIME from + * the satellite stream well before it can solve a POSITION, so a valid time with no fix is + * positive proof the module is alive and tracking, not dead. That is the distinction the GPS + * page could not previously show, and it is what "acquiring..." was hiding. */ + uint32_t getGpsTime(); + /** Altitude in metres from the last fix (0 when there is no fix). */ + int getGpsAltitude(); /** True once a valid fix has been seen this session. */ bool getGpsHadFix() const { return _gps_had_fix; } double getNodeLat() const { return _sensors ? _sensors->node_lat : 0.0; } From 55e9484234d1930cd3e6f760228f3275c75ac62c Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 10:11:48 +0200 Subject: [PATCH 57/99] touch: keep the GPS line one-line in the top-bar dropdown, and stop over-claiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to 5c9f9cf, both reported from the device. 1. The dropdown got taller. gpsStatusStr() is shared: the top-bar control centre uses it as well as the GPS settings page, so making it two lines pushed the whole dropdown out. It now takes a `compact` flag — the dropdown gets one line (and drops the altitude), the settings page keeps the full text. 2. "No satellite data yet" was a claim we cannot support. It was gated on the receiver having decoded GPS time, on the reasoning that a receiver decodes time before it can solve a position. That reasoning is right in general but wrong for this parser: MicroNMEA reads RMC left to right and returns at the EMPTY latitude field of an unfixed sentence, so it never reaches parseDate and the time stays unset until a fix already exists. The branch was therefore always false while acquiring, and the page told a user with a perfectly healthy receiver that there was no satellite data. Removed. What is left is what we can actually stand behind: how long it has been searching, and that a cold start needs sky view and some minutes. Recording the limitation properly in the comment, because it has now cost time twice: from the UI layer a tracking receiver and an unpowered one are indistinguishable. LocationProvider exposes only fix / sats-used / lat / lon / alt / timestamp, all empty until a fix, and the core keeps its MicroNMEA object private. Satellites-in-view, SNR and HDOP need a monorepo core change plus a core-* tag bump. Until then the serial boot probe (bdc0c7f) is the way to see the real sky view. Builds clean on V4, T-Deck and P4; flashed to the P4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 43 ++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index afd4110..5c7c7c3 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2241,47 +2241,50 @@ static bool settingsModalIsOpen() { return g_set_modal.root != nullptr; } // receiver is then obvious from the elapsed time alone. Cleared on fix and when GPS is off. static uint32_t s_gps_acq_since = 0; -static const char* gpsStatusStr() { +// compact = the top-bar dropdown, which must stay ONE line (it shares this string with the GPS +// settings page, and a two-line version pushed the whole dropdown taller). The settings page asks +// for the full text, where there is room to explain what is happening. +static const char* gpsStatusStr(bool compact = false) { static char s[200]; if (!g_lv.task || !g_lv.task->getGPSState()) { s_gps_acq_since = 0; snprintf(s, sizeof s, TR("GPS: off")); return s; } - // satellitesCount() is satellites USED IN THE FIX, so it stays 0 through a cold acquisition and - // a bare "0 sats" would read as broken. The old line therefore said only "acquiring...", which - // is just as unhelpful: it cannot distinguish a receiver happily tracking satellites that needs - // another minute from one that is not talking at all. Both looked identical to the user, and - // that ambiguity is exactly what cost a long P4 investigation. + // satellitesCount() is satellites USED IN THE FIX, so it reads 0 for the whole cold acquisition + // and a bare "0 sats" looks like a fault. All the other reachable fields (lat/lon/alt/valid) are + // likewise empty until a fix lands, so from up here a receiver that is happily tracking + // satellites and one that is not powered look EXACTLY the same. // - // The tell is TIME. A receiver decodes satellite time/date well before it has enough satellites - // to solve a position, so "no fix but valid GPS time" is positive proof that the antenna and the - // link are fine and it is genuinely mid-acquisition. + // That is a real limitation, not an oversight: the core's MicroNMEALocationProvider keeps its + // MicroNMEA object private and LocationProvider exposes no satellites-in-view, no SNR and no + // HDOP. Deducing "is it alive" from the decoded GPS time does NOT work either — MicroNMEA parses + // RMC left to right and bails out at the EMPTY latitude field of an unfixed sentence, so it + // never reaches parseDate and the time stays unset until there is already a fix. So do not claim + // to know whether data is arriving; report the honest thing (how long it has been trying) and + // point at the actual remedy. The serial boot probe prints the real sky view meanwhile. if (!g_lv.task->getGpsFix()) { const uint32_t now = millis(); if (!s_gps_acq_since) s_gps_acq_since = now; const uint32_t secs = (now - s_gps_acq_since) / 1000u; - const bool has_time = g_lv.task->getGpsTime() != 0; int n = snprintf(s, sizeof s, TR("GPS: searching")); if (n < (int)sizeof s) { if (secs < 600) n += snprintf(s + n, sizeof s - n, " %lum%02lus", (unsigned long)(secs / 60), (unsigned long)(secs % 60)); else n += snprintf(s + n, sizeof s - n, " %lum", (unsigned long)(secs / 60)); } - if (n < (int)sizeof s) { - snprintf(s + n, sizeof s - n, "\n%s", has_time - ? TR("Receiver OK, satellite time decoded. Waiting for enough satellites - needs a clear view of the sky.") - : TR("No satellite data yet. Outdoors with a clear view of the sky it should appear within a few minutes.")); - } + if (!compact && n < (int)sizeof s) + snprintf(s + n, sizeof s - n, "\n%s", + TR("No position yet. A cold start needs a clear view of the sky and can take several minutes.")); return s; } s_gps_acq_since = 0; const int sats = g_lv.task->getGpsSats(); const int alt = g_lv.task->getGpsAltitude(); int n = snprintf(s, sizeof s, TR("GPS: fix")); - if (sats > 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d sats", sats); - if (alt && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " · %d m", alt); + if (sats > 0 && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d sats", sats); + if (!compact && alt && n < (int)sizeof s) n += snprintf(s + n, sizeof s - n, " \xc2\xb7 %d m", alt); if (n < (int)sizeof s) - snprintf(s + n, sizeof s - n, "\n%.5f, %.5f", + snprintf(s + n, sizeof s - n, "%s%.5f, %.5f", compact ? " " : "\n", g_lv.task->getNodeLat(), g_lv.task->getNodeLon()); return s; } @@ -36307,7 +36310,7 @@ static void openControlCenter() { s_cc_gps_label = lv_label_create(card); lv_label_set_long_mode(s_cc_gps_label, LV_LABEL_LONG_DOT); lv_obj_set_width(s_cc_gps_label, card_w - 20); - lv_label_set_text(s_cc_gps_label, gpsStatusStr()); + lv_label_set_text(s_cc_gps_label, gpsStatusStr(true)); // one line — keeps the dropdown short lv_obj_set_style_text_font(s_cc_gps_label, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(s_cc_gps_label, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_align(s_cc_gps_label, LV_ALIGN_TOP_LEFT, 0, gps_y); @@ -38605,7 +38608,7 @@ static void refreshStatusLabels() { // CPU/RAM/PSRAM/IP line current — e.g. the IP appears/clears as Wi-Fi // connects/drops, without having to close and reopen the panel. if (s_cc_root) { - if (s_cc_gps_label) setLabelIfChanged(s_cc_gps_label, gpsStatusStr()); + if (s_cc_gps_label) setLabelIfChanged(s_cc_gps_label, gpsStatusStr(true)); #if defined(HAS_EXPANSION_KIT) if (s_cc_env_label) setLabelIfChanged(s_cc_env_label, localEnvStatusStr()); #endif From 1001ff7ce9aec2140d77196d3599106067205e22 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 10:27:04 +0200 Subject: [PATCH 58/99] touch: GPS settings page never refreshed while open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the GPS page only updated when you closed and reopened it. refreshSettingsSectionSubtitles() gated the GPS label update on g_set_modal.kind == SettingsModalKind::Device. That stopped being true when the settings reorg moved the GPS block into its own PAGE (CAT_GPS -> buildDeviceSettings(DSEC_GPS)) — the label is created there, but the refresh condition still described the old Device modal, so it matched nothing and the text was written once at build time and never again. Everything on that page was therefore frozen at whatever was true the instant it opened, including the elapsed-search timer added in 5c9f9cf. That is worse than useless for GPS specifically: a receiver that is acquiring normally looks completely stuck, which is exactly the "8 minutes, still nothing" reading, since the seconds counter could not move. Now refreshes when either host is open, guarded with lv_obj_is_valid so a stale pointer left behind by a closed page can never be written. Builds clean on V4, T-Deck and P4; flashed to the P4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5c7c7c3..736df7e 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -35574,11 +35574,19 @@ static void refreshSettingsSectionSubtitles() { } } - // Live GPS fix status on the Device-settings line while it's open. (The - // control-center drop-down's GPS + system line are refreshed from the periodic - // refreshStatusLabels tick instead — this function only runs while Settings is.) - if (g_set_modal.root && g_set_modal.gps_status && - g_set_modal.kind == SettingsModalKind::Device) { + // Live GPS status while the GPS page (or the legacy Device modal) is open. (The control-center + // drop-down's GPS + system line are refreshed from the periodic refreshStatusLabels tick + // instead — this function only runs while Settings is.) + // + // This used to require kind == SettingsModalKind::Device, which stopped being true when the + // settings reorg moved the GPS block into its own PAGE (CAT_GPS -> buildDeviceSettings(DSEC_GPS)). + // The condition then never matched, so the label was written once at build time and never again: + // the page looked frozen and only "updated" when you closed and reopened it — including the + // elapsed-search timer, which made a working receiver look stuck. Accept either host, and guard + // with lv_obj_is_valid so a stale pointer from a closed page can never be written. + if (g_set_modal.gps_status && lv_obj_is_valid(g_set_modal.gps_status) && + (s_settings_open_cat == CAT_GPS || + (g_set_modal.root && g_set_modal.kind == SettingsModalKind::Device))) { lv_label_set_text(g_set_modal.gps_status, gpsStatusStr()); } From e3c0d98967e0928c32340db8aa0ae6bceb98f401 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 10:52:01 +0200 Subject: [PATCH 59/99] touch: P4 never serviced the sensor manager, so GPS could never fix Root cause of "GPS on the P4 stays on searching forever", reported repeatedly and chased through pins, baud, wake polarity, antennas and a comparison against the Meck-P4 port. None of those were wrong. The loop simply never called sensors.loop(). Every S3 board calls it once per iteration (src/main.cpp: STALL_SCOPE("sensors", sensors.loop())). That file is excluded from this build and the P4 has its own main loop, which is where the call went missing during the port: sensors.begin() ran at boot and nothing ever pumped the manager again. sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser never receives a byte, so isValid() is false forever and the UI shows "searching" regardless of signal. It also starves the telemetry sensors and the GPS->RTC time sync that hang off the same manager. The evidence lines up exactly: - a T-Deck with the same L76K fixes almost instantly, the P4 never does; - 15 minutes under clear sky changed nothing, because no amount of signal helps a parser that is never run; - and a raw UART probe showed the MODULE reaching a valid 3D fix ($GNGGA,...,1,05,2.2 at HDOP 2.2, plus $GPTXT ANTENNA OK) that the firmware had no idea about. That reading came from the probe, not the provider, which is precisely why it looked like the GPS "worked" while the device disagreed. Builds clean and boots clean on the P4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- tdisplay_p4/main/main.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index 3156f48..8a7f393 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -440,6 +440,17 @@ extern "C" void app_main(void) { } serial_interface.tickWebSocketHandshake(); + // Service the sensor manager. This was MISSING on this board: sensors.begin() ran at boot and + // then nothing ever pumped it again, while every S3 board calls this each iteration (see + // src/main.cpp, which is excluded from this build — that is how the call got lost in the port). + // + // sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser never sees a + // byte, so isValid() stays false forever and the UI sits on "searching" no matter how good the + // sky is — while the module itself is perfectly happy and does acquire a fix (proven with a raw + // UART probe: a valid 3D fix at HDOP 2.2 that the firmware never knew about). It also starves + // everything else the manager provides: telemetry sensors and the GPS->RTC time sync. + sensors.loop(); + the_mesh.loop(); vTaskDelay(1); } From f5fee0c9866c4e54f49b878c8b4f1fc1ea78d89d Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 11:06:59 +0200 Subject: [PATCH 60/99] =?UTF-8?q?touch:=20P4=20whole-screen=20flicker=20(#?= =?UTF-8?q?167)=20=E2=80=94=20run=20code=20from=20PSRAM=20so=20flash=20wri?= =?UTF-8?q?tes=20stop=20starving=20the=20DSI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent 100%-reproducible triggers finally pinned this down, and they all turn out to be the same thing: a write to flash. - every received message -> chat-history write - opening a channel that has unread messages -> mark-read write ("if you open them back up and there aren't unread messages, there's no flash") - deleting a contact -> the chunked bulk delete writes repeatedly, and it flickers CONTINUOUSLY for the whole operation Mechanism. This panel is a MIPI-DSI DPI/video one: its single ~1.4 MB framebuffer can only live in PSRAM and the DSI DMA streams it to the glass continuously. PSRAM sits behind the same cache as flash, and a flash write has to DISABLE that cache while it runs. For the duration of the write the DMA cannot fetch the framebuffer, the panel underruns, and a frame comes out as a whole-screen colour flash. Nothing to do with what is being drawn, which is why it appeared on every screen, on every board revision, and from the very start of the port. Fix: CONFIG_SPIRAM_XIP_FROM_PSRAM=y. Code and constants are relocated into PSRAM at startup so the cache no longer has to be dropped for a flash write. The SoC supports it (CONFIG_SOC_SPIRAM_XIP_SUPPORTED=y) and IDF ships the same pairing as a P4 reference config (esp_mm/test_apps/mm/sdkconfig.ci.xip_psram_esp32p4). It pulls in SPIRAM_FETCH_INSTRUCTIONS / SPIRAM_RODATA / SPIRAM_FLASH_LOAD_TO_PSRAM. This also explains why 7aaff80 (moving the flush scratch out of PSRAM) did not help: that reduced PSRAM BANDWIDTH contention, but the cache is disabled outright during a flash write, so bandwidth was never the constraint. That commit is still worth keeping on its own merits, but it was not the cause. Also restores CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK and its SDIO twin to this fragment. They existed only in the GENERATED sdkconfig, so regenerating it for the XIP change silently dropped them and esp-hosted fell back to resetting the slave over GPIO 54 — a line this board does not have (C6_EN is on the XL9535, so it can only be driven by callback). Inert today because TDP4_C6_READY is 0, but the config is now reproducible from its fragments instead of depending on an untracked file nobody could regenerate. Boots clean with XIP enabled: display up, GPS probe reading, only rst:0x1 (POWERON). Needs field confirmation against the contact-delete trigger, which is the reliable one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- tdisplay_p4/sdkconfigs/tdisplay_p4 | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 index 70d14bc..60eb6f8 100644 --- a/tdisplay_p4/sdkconfigs/tdisplay_p4 +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -47,3 +47,36 @@ CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=64 + +# --- Whole-screen flicker (#167) ------------------------------------------------------------- +# Execute .text/.rodata from PSRAM instead of memory-mapped flash. +# +# This board's MIPI-DSI panel is a DPI/video one: its single ~1.4 MB framebuffer can only live in +# PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. PSRAM sits behind the same cache as +# flash, and any flash write has to DISABLE that cache while it runs -- so for the duration of the +# write the DMA cannot fetch the framebuffer, the panel underruns, and one frame comes out as a +# whole-screen colour flash. +# +# That is why the reported triggers are all storage writes and nothing to do with what is drawn: +# every received message (history write), opening a channel with unread messages (mark-read write), +# and deleting a contact -- which writes repeatedly and therefore flickers continuously until it +# finishes. It also explains why it appears on every screen and has been there since the port +# began, and why moving the flush scratch buffer out of PSRAM (7aaff80) did not help: that reduced +# bandwidth contention, but the cache is disabled outright here, so bandwidth was never the issue. +# +# With XIP enabled, code and constants are relocated into PSRAM at startup so the cache no longer +# has to be dropped for a flash write. Supported on this SoC (CONFIG_SOC_SPIRAM_XIP_SUPPORTED=y); +# IDF ships the same pairing as a P4 reference config +# (esp_mm/test_apps/mm/sdkconfig.ci.xip_psram_esp32p4). +CONFIG_SPIRAM_XIP_FROM_PSRAM=y + +# --- esp-hosted C6 reset: callback, NOT a GPIO ------------------------------------------------- +# These were only ever present in the GENERATED sdkconfig, never in this tracked fragment, so a +# regeneration silently dropped them and esp-hosted fell back to resetting the slave via GPIO 54. +# There is no such reset line on this board: the C6's enable/reset is C6_EN on the XL9535 expander +# (see Xl9535::powerOnSequence), which can only be driven through a callback. Currently inert +# because TDP4_C6_READY is 0 and the C6 runs factory ESP-AT via c6_at rather than esp-hosted, but +# pinned here so the config is reproducible from the fragments and a future C6 bring-up does not +# inherit a bogus GPIO reset. +CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y +CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y From 1bc0c7ab39e82fc0a20b1946530af394f3b689ce Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 11:28:57 +0200 Subject: [PATCH 61/99] p4: route prefs off internal-flash NVS onto the SD card (#167) The T-Display P4 never called SdNvsPrefs::useFile, so although contacts and chat history correctly moved to the SD card, every settings/thread-state write still landed in internal-flash NVS. On this board that is not just wear: the DSI panel streams its framebuffer continuously out of PSRAM, and an internal-flash write visibly disturbs it -- the whole-screen flash reported as #167. The trigger list matches exactly: a message arriving, opening a chat with unread messages, and deleting a contact (which writes repeatedly, hence flickering for the whole operation). Map panning writes tiles to SD continuously and never flickers, which is what pinned it down. Only when a card is mounted. With no card there is nowhere better to put prefs, and moving them from NVS to the internal FAT partition would trade one flash write for another while needlessly relocating existing users' settings. Old NVS values still load and migrate on their next save, so this is transparent. Fixing it also required an include-path fix. The vendored core component publishes core/src publicly and still carries OLD copies of the headers whose .cpp files it deliberately excludes because our src/ ships them. main.cpp's angle includes therefore resolved to the stale core headers while the linker bound the new objects, so anything added since the core tag looked like it did not exist (SdNvsPrefs::useFile, touchPrefsReload). Putting the repo's src/ ahead of the core dir in main's INCLUDE_DIRS makes main.cpp and the app layer agree. Verified on device: "[PREFS] backend = FILE /meshcomod". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- tdisplay_p4/main/CMakeLists.txt | 8 ++++++++ tdisplay_p4/main/main.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 2d4ccde..2b47f75 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -19,6 +19,14 @@ idf_component_register( c6_at # AT-over-SDIO Wi-Fi/BLE driver for the on-board ESP32-C6 (P4-only) INCLUDE_DIRS "." + # The repo's own src/ MUST come before the meshcore component's include dir. The vendored + # core publishes core/src publicly and still carries OLD copies of the headers whose .cpp + # files it deliberately excludes (SdNvsPrefs, TouchPrefsStore, ...) because our src/ ships + # them. Without this line an angle include here resolves to the stale core header while the + # linker binds the new object -- so anything added to those headers since the core tag looks + # like it does not exist (SdNvsPrefs::useFile, touchPrefsReload). Same headers the app + # component compiles against, so main.cpp and the app layer now agree. + "${CMAKE_CURRENT_LIST_DIR}/../../src" ) # The vendored MeshCore core + Arduino libs trip IDF's blanket -Werror=all. A bare diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index 8a7f393..4bb78d1 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -21,6 +21,7 @@ #include <helpers/esp32/MultiTransportCompanionInterface.h> #include <helpers/esp32/WifiRuntimeStore.h> // wifiConfig* (runtime Wi-Fi state the loop drives) #include <helpers/esp32/TouchPrefsStore.h> // touchPrefsBuildLocalTz + WIFI_CONFIG_* sizes +#include <helpers/esp32/SdNvsPrefs.h> // route prefs off internal-flash NVS onto the SD card #include "esp_hosted.h" #include "esp_netif.h" #include "esp_event.h" @@ -212,6 +213,30 @@ static void wadameshSetup() { } store.useSdMmcStorage(); } + + // Route the touch settings + Wi-Fi creds off internal-flash NVS onto the SD card, the same way + // every S3 board does (src/main.cpp). This board never did it, so although contacts and chat + // history correctly moved to SD above, EVERY pref write still hit NVS in internal flash. + // + // That matters here beyond wear: this panel is a DSI video one whose framebuffer is streamed + // continuously out of PSRAM, and an internal-flash write visibly disturbs it -- a whole-screen + // flash, reported as #167. The trigger list is exactly the pref/thread-state writes: a message + // arriving, opening a chat that has unread messages, and deleting a contact (which writes + // repeatedly and so flickers for the whole operation). Writes that go to the SD card instead + // never do it -- panning the map downloads and writes tiles continuously with no flicker at all, + // which is what pinned this down. + // + // Only when a card is actually mounted: with no card there is nowhere better to put prefs, and + // moving them from NVS to the internal FAT partition would trade one flash write for another + // while needlessly changing where existing users' settings live. Old NVS values still load and + // migrate on their next save, so this is transparent. touchPrefsReload() is required because + // boot already read prefs (UI rotation) through the old backend and cached the blob. + if (g_sd_ok) { + SdNvsPrefs::useFile((fs::FS*)&SD_MMC, "/meshcomod"); + touchPrefsReload(); + printf("[storage] prefs -> SD /meshcomod (off internal-flash NVS)\n"); + } + store.begin(); the_mesh.begin(disp != NULL); From 1e6e121c8555e68e892ad924652c8245b75ac381 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 11:40:40 +0200 Subject: [PATCH 62/99] p4: blank the framebuffer before display-on; widen LVGL bands (#167) Two changes against the whole-screen colour flash. 1. Real fix for the flash testers see BEFORE the boot splash. The panel was switched on and only then was the framebuffer cleared, so the DSI streamed a freshly-allocated (garbage) PSRAM buffer to the screen until the clear caught up. Blank it with a memset + cache flush while the panel is still dark. That also removes 39 back-to-back draw_bitmap transfers into a buffer the panel is scanning out. 2. Diagnostic, P4 only: LVGL bands 24 -> 64 lines. This panel is DSI video mode with a SINGLE framebuffer, so every band is copied into the buffer being actively scanned. At 24 lines a full-screen repaint is ~26 back-to-back transfers into that live buffer. The reported triggers (message arrives, opening a chat with unread, adding/deleting a contact) all rebuild the whole screen; panning the map repaints only its own region and never flashes, which is the shape this predicts. Wider bands cut the same repaint to ~10 transfers. Sized so the 2x upscale scratch still fits internal SRAM, so burst count is the only variable that moves. Supersedes the write-path theory: the flash still occurs with contacts, history and prefs all on SD, and the pre-splash case involves no write at all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 12 +++++++++++ variants/tdisplay_p4/RM69A10Display.cpp | 28 ++++++++++++------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 736df7e..f95e4b0 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1950,7 +1950,19 @@ static bool g_cap_touch_hw_started = false; // build). 24 × 240 × 2 B = 11.5 KB. Flush time is bound by SPI clock // (80 MHz), not memcpy speed, so the throughput delta is small — // LVGL just calls flush_cb more often. +#if defined(HAS_TDISPLAY_P4) +// DIAGNOSTIC (#167): the T-Display P4 is a DSI video-mode panel with a SINGLE framebuffer, so every +// LVGL band is copied into the buffer the panel is actively scanning out. At 24 lines a full-screen +// repaint is ~26 back-to-back DMA transfers into that live buffer -- and the events testers report +// (a message arriving, opening a chat with unread, adding/deleting a contact) are exactly the ones +// that rebuild the whole screen, while panning the map only repaints its own region and never +// flashes. Wider bands make the same repaint ~10 transfers instead of ~26. Kept just small enough +// that the 2x upscale scratch still fits internal SRAM (568*128*2 = ~145 KB), so this changes ONLY +// the burst count and not where the scratch lives. +static constexpr int LV_DRAW_BUF_LINES = 64; +#else static constexpr int LV_DRAW_BUF_LINES = 24; +#endif static lv_color_t* g_draw_buffer = nullptr; static uint32_t g_draw_buf_px = 240 * LV_DRAW_BUF_LINES; // actual buffer size in px; shrinks if the full alloc fails at boot #if CAP_LARGE_SCREEN diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp index cdcad22..ee5d922 100644 --- a/variants/tdisplay_p4/RM69A10Display.cpp +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -3,6 +3,7 @@ #include "RM69A10Display.h" #include <Arduino.h> #include "esp_heap_caps.h" +#include "esp_cache.h" // esp_cache_msync — flush the memset framebuffer out to the DSI DMA #include "esp_ldo_regulator.h" #include "Xl9535.h" // SCREEN_RST lives on the expander (reset_gpio_num = -1) #include "esp_lcd_mipi_dsi.h" @@ -109,6 +110,19 @@ bool RM69A10Display::begin() { esp_lcd_panel_reset(_panel); if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[RM69A10] panel_init fail"); return false; } + + // Blank the framebuffer BEFORE switching the panel on. It is freshly allocated PSRAM, so it holds + // garbage; turning the panel on first meant the DSI streamed that garbage to the screen until the + // clear below finished -- the colour flash testers see before the boot splash (#167). Write the + // buffer directly (memset + a cache flush to make it visible to the DSI's DMA) instead of pushing + // strips through draw_bitmap: it is one pass with the panel still dark, and it avoids a burst of + // 39 back-to-back DMA transfers into a buffer the panel is scanning out. + void* fb = nullptr; + if (esp_lcd_dpi_panel_get_frame_buffer(_panel, 1, &fb) == ESP_OK && fb) { + const size_t fb_bytes = (size_t)RM_W * RM_H * 2; + memset(fb, 0, fb_bytes); + esp_cache_msync(fb, fb_bytes, ESP_CACHE_MSYNC_FLAG_DIR_C2M); + } esp_lcd_panel_disp_on_off(_panel, true); // Brightness: the init array ships 0x51=0 (placeholder), and RM69A10 brightness is driven at @@ -125,20 +139,6 @@ bool RM69A10Display::begin() { cbs.on_color_trans_done = rm69a10TransDone; esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr); - // Clear the panel framebuffer to black once, so nothing stale shows before LVGL's first flush. - { - const int SH = 32; - uint16_t* strip = (uint16_t*)heap_caps_calloc((size_t)RM_W * SH, 2, MALLOC_CAP_SPIRAM); - if (strip) { - for (int y0 = 0; y0 < RM_H; y0 += SH) { - int hh = (y0 + SH <= RM_H) ? SH : (RM_H - y0); - esp_lcd_panel_draw_bitmap(_panel, 0, y0, RM_W, y0 + hh, strip); - if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // let the copy finish before reusing/freeing strip - } - free(strip); - } - } - Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H); return true; } From 0695ca6596bae0412ab60e6685c359907615d009 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 11:51:06 +0200 Subject: [PATCH 63/99] p4: flush-trace build gate + take code/rodata off the PSRAM bus (#167) Ran the flush tracer instead of theorising further. Result is decisive: every large uniform band LVGL pushes to the panel is rgb565=0x0000 (black), across the whole boot window where the flash occurs. LVGL never paints the flash colour, so the panel is displaying something that was never written to the framebuffer. That excludes the entire UI layer, and with it the redraw/repaint theories. Adds WADA_P4_FLUSH_TRACE=1 as a build gate so the tracer can be run on demand without shipping it (it slows the flush path, so never confirm a fix with it on). With the UI excluded the remaining candidate is the DSI starving. The panel streams ~84 MB/s out of PSRAM continuously, and XIP put every instruction fetch and rodata read on that same bus. The triggers are all bursts of code and data churn (rebuilding the contact/chat list); panning the map reuses a warm tile cache and never flashes. Turning XIP off leaves the PSRAM bus essentially to the display. Experiment, not a claimed fix. Also supersedes the burst-count theory from 1e6e121: widening LVGL bands from 24 to 64 lines (26 transfers -> 10 for a full repaint) changed nothing on device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- tdisplay_p4/main/CMakeLists.txt | 8 ++++++++ tdisplay_p4/sdkconfigs/tdisplay_p4 | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 2b47f75..5c5c6dd 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -111,6 +111,14 @@ endforeach() # One-off C6-reflash helper: build with `C6_FLASH_HELPER=1 ./build.sh build` to boot a P4 image that # never touches the C6 (no C6_EN reset pulse, no AT worker), so the C6 can be held in ROM download mode # and reflashed over its own USB. Harmless when the env var is unset (normal builds are unaffected). +# Diagnostic build for the whole-screen colour flash (#167): `WADA_P4_FLUSH_TRACE=1 ./build.sh build` +# logs every large, mostly-single-colour band pushed to the panel, with its RGB565 value. If the +# screen flashes and nothing logs, the cause is below LVGL and the UI is the wrong place to look. +if(DEFINED ENV{WADA_P4_FLUSH_TRACE}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_FLUSH_TRACE=1" APPEND) + message(STATUS "wadamesh: FLUSH TRACE enabled (diagnostic build, do not ship)") +endif() + if(DEFINED ENV{C6_FLASH_HELPER}) idf_build_set_property(COMPILE_OPTIONS "-DTDP4_C6_FLASH_HELPER=1" APPEND) message(STATUS "wadamesh: BUILDING C6-FLASH HELPER (P4 leaves the C6 free)") diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 index 60eb6f8..44becbc 100644 --- a/tdisplay_p4/sdkconfigs/tdisplay_p4 +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -80,3 +80,15 @@ CONFIG_SPIRAM_XIP_FROM_PSRAM=y # inherit a bogus GPIO reset. CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y + +# --- #167 whole-screen colour flash: keep code/rodata OFF the PSRAM bus. ------------------------ +# The flush tracer proved LVGL never paints the flash colour (every large band it pushes is 0x0000 +# black), so the panel is displaying something that was never in the framebuffer -- i.e. the DSI is +# starving, not the UI misdrawing. This panel streams 568*1232*2 bytes ~60x/s = ~84 MB/s out of +# PSRAM continuously, and with XIP every instruction fetch and rodata read competes for that same +# bus. The reported triggers are all bursts of code + data churn (rebuilding the contact/chat list), +# while panning the map reuses a warm tile cache and never flashes. Turning XIP off leaves the PSRAM +# bus essentially to the display alone; code runs from flash through the cache as usual. +CONFIG_SPIRAM_FETCH_INSTRUCTIONS=n +CONFIG_SPIRAM_RODATA=n +CONFIG_SPIRAM_XIP_FROM_PSRAM=n From c1d166e5e794067fe265f958ed0f7fbc3e6fbab2 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 12:02:57 +0200 Subject: [PATCH 64/99] Revert: p4 prefs-on-SD, wider LVGL bands, XIP-off (#167) None of the three fixed the flash, and each carried a cost: - prefs-on-SD (1bc0c7a) also moved main.cpp onto a different set of headers than it had been compiling against. SdNvsPrefs.h's own comment warns that exact mismatch previously caused a free()-of-garbage bootloop, and a map tile regression showed up on these builds. Not worth the risk for no benefit. - LVGL bands 24 -> 64 (1e6e121): a full-screen repaint went from ~26 transfers to ~10 with no change on device, so burst count is not the mechanism. - XIP-off (0695ca6): no change on device, so PSRAM bandwidth starving the DSI is not the mechanism either. Back to the configuration where map tiles were confirmed working. Kept the two changes that stand on their own: the framebuffer is blanked before the panel is switched on (it was streaming uninitialised PSRAM to the screen), and the WADA_P4_FLUSH_TRACE build gate. Standing evidence for whoever picks this up: the tracer shows every large band LVGL pushes is 0x0000 black, including through the boot window where the flash occurs, so the colour is never in the framebuffer and the UI layer is excluded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 12 ------------ tdisplay_p4/main/CMakeLists.txt | 8 -------- tdisplay_p4/main/main.cpp | 25 ------------------------- tdisplay_p4/sdkconfigs/tdisplay_p4 | 12 ------------ 4 files changed, 57 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index f95e4b0..736df7e 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1950,19 +1950,7 @@ static bool g_cap_touch_hw_started = false; // build). 24 × 240 × 2 B = 11.5 KB. Flush time is bound by SPI clock // (80 MHz), not memcpy speed, so the throughput delta is small — // LVGL just calls flush_cb more often. -#if defined(HAS_TDISPLAY_P4) -// DIAGNOSTIC (#167): the T-Display P4 is a DSI video-mode panel with a SINGLE framebuffer, so every -// LVGL band is copied into the buffer the panel is actively scanning out. At 24 lines a full-screen -// repaint is ~26 back-to-back DMA transfers into that live buffer -- and the events testers report -// (a message arriving, opening a chat with unread, adding/deleting a contact) are exactly the ones -// that rebuild the whole screen, while panning the map only repaints its own region and never -// flashes. Wider bands make the same repaint ~10 transfers instead of ~26. Kept just small enough -// that the 2x upscale scratch still fits internal SRAM (568*128*2 = ~145 KB), so this changes ONLY -// the burst count and not where the scratch lives. -static constexpr int LV_DRAW_BUF_LINES = 64; -#else static constexpr int LV_DRAW_BUF_LINES = 24; -#endif static lv_color_t* g_draw_buffer = nullptr; static uint32_t g_draw_buf_px = 240 * LV_DRAW_BUF_LINES; // actual buffer size in px; shrinks if the full alloc fails at boot #if CAP_LARGE_SCREEN diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 5c5c6dd..7af1bcf 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -19,14 +19,6 @@ idf_component_register( c6_at # AT-over-SDIO Wi-Fi/BLE driver for the on-board ESP32-C6 (P4-only) INCLUDE_DIRS "." - # The repo's own src/ MUST come before the meshcore component's include dir. The vendored - # core publishes core/src publicly and still carries OLD copies of the headers whose .cpp - # files it deliberately excludes (SdNvsPrefs, TouchPrefsStore, ...) because our src/ ships - # them. Without this line an angle include here resolves to the stale core header while the - # linker binds the new object -- so anything added to those headers since the core tag looks - # like it does not exist (SdNvsPrefs::useFile, touchPrefsReload). Same headers the app - # component compiles against, so main.cpp and the app layer now agree. - "${CMAKE_CURRENT_LIST_DIR}/../../src" ) # The vendored MeshCore core + Arduino libs trip IDF's blanket -Werror=all. A bare diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index 4bb78d1..8a7f393 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -21,7 +21,6 @@ #include <helpers/esp32/MultiTransportCompanionInterface.h> #include <helpers/esp32/WifiRuntimeStore.h> // wifiConfig* (runtime Wi-Fi state the loop drives) #include <helpers/esp32/TouchPrefsStore.h> // touchPrefsBuildLocalTz + WIFI_CONFIG_* sizes -#include <helpers/esp32/SdNvsPrefs.h> // route prefs off internal-flash NVS onto the SD card #include "esp_hosted.h" #include "esp_netif.h" #include "esp_event.h" @@ -213,30 +212,6 @@ static void wadameshSetup() { } store.useSdMmcStorage(); } - - // Route the touch settings + Wi-Fi creds off internal-flash NVS onto the SD card, the same way - // every S3 board does (src/main.cpp). This board never did it, so although contacts and chat - // history correctly moved to SD above, EVERY pref write still hit NVS in internal flash. - // - // That matters here beyond wear: this panel is a DSI video one whose framebuffer is streamed - // continuously out of PSRAM, and an internal-flash write visibly disturbs it -- a whole-screen - // flash, reported as #167. The trigger list is exactly the pref/thread-state writes: a message - // arriving, opening a chat that has unread messages, and deleting a contact (which writes - // repeatedly and so flickers for the whole operation). Writes that go to the SD card instead - // never do it -- panning the map downloads and writes tiles continuously with no flicker at all, - // which is what pinned this down. - // - // Only when a card is actually mounted: with no card there is nowhere better to put prefs, and - // moving them from NVS to the internal FAT partition would trade one flash write for another - // while needlessly changing where existing users' settings live. Old NVS values still load and - // migrate on their next save, so this is transparent. touchPrefsReload() is required because - // boot already read prefs (UI rotation) through the old backend and cached the blob. - if (g_sd_ok) { - SdNvsPrefs::useFile((fs::FS*)&SD_MMC, "/meshcomod"); - touchPrefsReload(); - printf("[storage] prefs -> SD /meshcomod (off internal-flash NVS)\n"); - } - store.begin(); the_mesh.begin(disp != NULL); diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 index 44becbc..60eb6f8 100644 --- a/tdisplay_p4/sdkconfigs/tdisplay_p4 +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -80,15 +80,3 @@ CONFIG_SPIRAM_XIP_FROM_PSRAM=y # inherit a bogus GPIO reset. CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y - -# --- #167 whole-screen colour flash: keep code/rodata OFF the PSRAM bus. ------------------------ -# The flush tracer proved LVGL never paints the flash colour (every large band it pushes is 0x0000 -# black), so the panel is displaying something that was never in the framebuffer -- i.e. the DSI is -# starving, not the UI misdrawing. This panel streams 568*1232*2 bytes ~60x/s = ~84 MB/s out of -# PSRAM continuously, and with XIP every instruction fetch and rodata read competes for that same -# bus. The reported triggers are all bursts of code + data churn (rebuilding the contact/chat list), -# while panning the map reuses a warm tile cache and never flashes. Turning XIP off leaves the PSRAM -# bus essentially to the display alone; code runs from flash through the cache as usual. -CONFIG_SPIRAM_FETCH_INSTRUCTIONS=n -CONFIG_SPIRAM_RODATA=n -CONFIG_SPIRAM_XIP_FROM_PSRAM=n From c99997ea9ace9c36eb950bceb5703c958f597e00 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 12:32:45 +0200 Subject: [PATCH 65/99] p4: Espressif's documented DPI-underrun fix stack (#167) Root-cause work, not another guess. The esp_lcd DSI driver's own ISR says it: // when an underrun happens, the LCD display may already becomes blue ESP_DRAM_LOGE(TAG, "can't fetch data from external memory fast enough, ...") A DSI bridge FIFO underrun displays as exactly one blue frame -- the reported whole-screen split-second blue flash -- and the driver logs an error line every time it happens. Every serial capture until now grepped for tile/storage/trace patterns that this line does not match, so it was never looked for. The esp-iot-solution MIPI-DSI guide prescribes, for this exact error: L2 cache 256 KB, 128 B cache lines, and perf optimization (plus SPIRAM 200M + XIP from PSRAM, already set). Applied all three for this board only: - build.sh: sdkconfig fragment order general;wadamesh;tdisplay_p4 so the BOARD fragment wins over the shared ones (it was last-loser before; the shared wadamesh fragment's -Os, needed for the Tanmatsu AppFS ceiling, was unoverridable). No net config change from the reorder itself: the board fragment previously duplicated no shared keys. - sdkconfigs/tdisplay_p4: COMPILER_OPTIMIZATION_PERF, CACHE_L2_CACHE_256KB, CACHE_L2_CACHE_LINE_128B. App 2.99 -> 3.35 MB of the 4 MB slot. - main/CMakeLists.txt: -O2's deeper analysis trips new warnings in vendored components (esp-modbus etc.); same keep-visible-never-fatal policy as the existing strips. Verification harness (scratchpad, not shipped): the companion protocol driven over the USB console (contact add/remove = the 100% reported trigger, pure local DB ops, no LoRa TX) while the same session captures every console line. Since the driver logs each underrun unconditionally, one watched run now gives a decisive verdict whichever way it goes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- tdisplay_p4/build.sh | 2 +- tdisplay_p4/main/CMakeLists.txt | 8 ++++++++ tdisplay_p4/sdkconfigs/tdisplay_p4 | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tdisplay_p4/build.sh b/tdisplay_p4/build.sh index 44d1d83..6011aee 100755 --- a/tdisplay_p4/build.sh +++ b/tdisplay_p4/build.sh @@ -71,6 +71,6 @@ WADA_FW_DATE="$(date '+%-d %b %Y')" exec idf.py -B build/tdisplay_p4 \ -DDEVICE=tdisplay_p4 \ - -DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/tdisplay_p4;sdkconfigs/wadamesh" \ + -DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/wadamesh;sdkconfigs/tdisplay_p4" \ -DWADA_FW_TAG="$WADA_FW_TAG" -DWADA_FW_DATE="$WADA_FW_DATE" \ -DIDF_TARGET=esp32p4 "$@" diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 7af1bcf..6bbb37f 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -25,6 +25,14 @@ idf_component_register( # -Wno-error does not cancel it, so strip it outright (warnings stay visible, non-fatal). idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-variable" APPEND) idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-const-variable" APPEND) +# -O2 (the #167 underrun fix stack) enables deeper flow analysis that trips new warnings in +# VENDORED code (esp-modbus and friends) which builds clean at -Os. Same policy as above: +# keep the warnings visible, never fatal, for code we consume as a library. +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-truncation" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-overflow" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=array-bounds" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=restrict" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=maybe-uninitialized" APPEND) idf_build_get_property(_co COMPILE_OPTIONS) list(REMOVE_ITEM _co "-Werror=all") idf_build_set_property(COMPILE_OPTIONS "${_co}") diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 index 60eb6f8..f8f3750 100644 --- a/tdisplay_p4/sdkconfigs/tdisplay_p4 +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -80,3 +80,20 @@ CONFIG_SPIRAM_XIP_FROM_PSRAM=y # inherit a bogus GPIO reset. CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y + +# --- #167 whole-screen blue flash: Espressif's documented DPI-underrun mitigations. ------------- +# The esp_lcd DSI driver's own ISR comment says an underrun turns the display blue, and the +# esp-iot-solution MIPI-DSI guide prescribes exactly these three for "can't fetch data from +# external memory fast enough": a 256 KB L2 cache with 128 B lines (fewer, larger PSRAM +# transactions -> more headroom for the continuous ~120 MB/s DSI fetch), and perf optimization. +# The shared 'wadamesh' fragment sets -Os for the Tanmatsu's AppFS size ceiling; this board is a +# plain 4 MB OTA slot with ~1.1 MB free, so it can afford -O2 (build.sh orders this fragment last +# so board overrides win). SPIRAM_SPEED_200M + XIP_FROM_PSRAM, the other two recommendations, +# were already set. +CONFIG_COMPILER_OPTIMIZATION_SIZE=n +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_CACHE_L2_CACHE_128KB=n +CONFIG_CACHE_L2_CACHE_256KB=y +CONFIG_CACHE_L2_CACHE_LINE_64B=n +CONFIG_CACHE_L2_CACHE_LINE_128B=y + From 3bf56cdd37cafac978db642fd40a01a9a39172ee Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 14:50:37 +0200 Subject: [PATCH 66/99] p4: #167 resolved -- SD writes flash the AMOLED; hot store moved to internal LittleFS Root cause, established by on-device elimination over one long session: SD-card WRITE bursts electrically disturb the T-Display P4's AMOLED -- the whole-screen blue flash of #167. The display stack itself measured clean at every layer while flashes were visible: framebuffer content (flush tracer: every band black), DSI bridge underrun interrupt (never fired), DSI host protocol errors incl. the host payload-buffer underflow (never latched), DCS/brightness traffic and XL9535 expander writes (silent), disp on/off (never). The flash survived every digital derate -- 4->1-bit SD bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane rate -- on every card tried, at any brightness (including near-zero, i.e. the panel is being upset at the hardware level, not showing content), yet vanished the moment the card was removed or left unwritten. The card's rail is switched off the same 3V3 domain as the panel (XL9535 SD_EN), so its program-current spikes reach the panel and no firmware setting can filter them. Streaming tile writes never flashed; rapid open-write-close store bursts always did. Meck-P4 not flashing proves nothing about the board: it never writes the SD. Fix: the hot store (contacts/channels/identity/blobs/prefs-file/chat history) lives on the INTERNAL 'storage' partition; the SD keeps bulk, gentle writers (map tiles, backups). The internal partition is mounted as LittleFS, not FAT: the P4's FAT layer has documented broken metadata and the v3 migration proved its writes are no better (a 102 KB contacts3 "copied" onto it read back as 0 B; on LittleFS the same copy reads back byte-exact and loads all 689 contacts). esp_littlefs looks partitions up by label with SUBTYPE_ANY, so the ex-FAT partition converts in place on first boot (formatOnFail) -- no partition-table change, OTA-safe. One-time v4 migration (lessons v1-v3 encoded): substance-gated (only from a card whose contacts3 beats the internal one -- a fresh test card once donated a blank store), whitelist-only copy (v2 filled the partition with junk before contacts3 landed), both historical card layouts probed (root vs /meshcomod), marker written only after the copy, card never written (it remains the rollback). Also in this change, kept as opt-in diagnostics (dormant without their env gates): WADA_P4_POKE_TRACE (DCS/expander/disp-on-off tracing + a DSI host error-register poller), WADA_SD_KHZ / WADA_SD_1BIT (SD primary-rung derates), WADA_DSI_MBPS (lane-rate override; default back at the vendor 1000 -- the 750 experiment changed nothing and is documented in the driver). Verified on device: no flash on the contact-churn trigger with the store internal (the previously 100%-reproducing trigger), 689/689 contacts after migration, tiles still on SD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 8 +- tdisplay_p4/main/CMakeLists.txt | 26 +++++ tdisplay_p4/main/main.cpp | 128 +++++++++++++++++++----- variants/tdisplay_p4/RM69A10Display.cpp | 47 ++++++++- variants/tdisplay_p4/Xl9535.cpp | 5 + 5 files changed, 185 insertions(+), 29 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 736df7e..b9f0a45 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41853,6 +41853,12 @@ static bool uiDataFsReady() { // 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.) + // #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; } extern bool g_sd_ok; if (g_sd_ok) { SD_MMC.mkdir("/meshcomod"); @@ -41860,8 +41866,6 @@ static bool uiDataFsReady() { strncpy(s_ui_data_root, "/meshcomod", sizeof s_ui_data_root - 1); return true; } - extern bool g_fs_ok; - if (g_fs_ok) { s_ui_data_fs = &FFat; s_ui_data_root[0] = '\0'; return true; } #elif defined(HAS_TDECK_GT911) // T-Deck: the SD card is the persistent user-data store (large, removable, // survives a reflash) and is where chat history already lives. Prefer it; only diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 6bbb37f..73a9bf4 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -111,6 +111,32 @@ endforeach() # One-off C6-reflash helper: build with `C6_FLASH_HELPER=1 ./build.sh build` to boot a P4 image that # never touches the C6 (no C6_EN reset pulse, no AT worker), so the C6 can be held in ROM download mode # and reflashed over its own USB. Harmless when the env var is unset (normal builds are unaffected). +# #167 SD-coupling experiments: WADA_SD_KHZ=<khz> / WADA_SD_1BIT=1 derate the primary SD mount +# (clock and/or bus width) to isolate + mitigate the SD-activity screen flash. +if(DEFINED ENV{WADA_SD_KHZ}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_KHZ=$ENV{WADA_SD_KHZ}" APPEND) + message(STATUS "wadamesh: SD primary rung clock = $ENV{WADA_SD_KHZ} kHz") +endif() +if(DEFINED ENV{WADA_SD_1BIT}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_1BIT=true" APPEND) + message(STATUS "wadamesh: SD primary rung = 1-bit bus") +endif() + +# #167: WADA_DSI_MBPS=<n> overrides the RM69A10 DSI lane bit rate (default 750 -- see the +# RM_BITRATE note in RM69A10Display.cpp; 1000 = the LilyGo/Meck value with the thin eye). +if(DEFINED ENV{WADA_DSI_MBPS}) + idf_build_set_property(COMPILE_OPTIONS "-DRM_BITRATE_MBPS=$ENV{WADA_DSI_MBPS}" APPEND) + message(STATUS "wadamesh: DSI lane rate = $ENV{WADA_DSI_MBPS} Mbps") +endif() + +# Companion diagnostic (#167): `WADA_P4_POKE_TRACE=1 ./build.sh build` logs every runtime poke at +# the panel or the XL9535 expander (DCS brightness writes, disp on/off, expander bit writes) so +# visible flashes can be correlated against them on the console. +if(DEFINED ENV{WADA_P4_POKE_TRACE}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_POKE_TRACE=1" APPEND) + message(STATUS "wadamesh: POKE TRACE enabled (diagnostic build, do not ship)") +endif() + # Diagnostic build for the whole-screen colour flash (#167): `WADA_P4_FLUSH_TRACE=1 ./build.sh build` # logs every large, mostly-single-colour band pushed to the panel, with its RGB565 value. If the # screen flashes and nothing logs, the cause is below LVGL and the UI is the wrong place to look. diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index 8a7f393..24d4732 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -14,7 +14,12 @@ #include "esp_heap_caps.h" #include "UITask.h" #include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init() -#include <FFat.h> // internal 'storage' FAT partition (no-card fallback) +#include <LittleFS.h> // internal 'storage' partition, mounted as LittleFS (label lookup is + // subtype-agnostic, so the ex-FAT partition converts in place -- no + // partition-table change, OTA-safe). The P4's FAT-on-flash layer has + // documented broken metadata (exists/size/f_getfree lie) and v3 of the + // #167 migration proved even its WRITES cannot be trusted; LittleFS is + // the same battle-proven backend every S3 board uses internally. #include <SD_MMC.h> // microSD on SDMMC slot 0 (primary store) #include "esp_partition.h" #include <WiFi.h> @@ -72,8 +77,8 @@ volatile int g_boot_phase = 0; extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; } // App globals — src/main.cpp declares these; we own them here (that file is excluded from the build). -DataStore store(FFat, rtc_clock); -bool g_fs_ok = false; // FFat('storage') mounted (extern — UITask file browser) +DataStore store(LittleFS, rtc_clock); +bool g_fs_ok = false; // internal 'storage' (LittleFS) mounted (extern — UITask file browser) bool g_sd_ok = false; // microSD mounted (extern) MultiTransportCompanionInterface serial_interface; StdRNG fast_rng; @@ -99,9 +104,11 @@ static void hostedConnectC6() { // Recursive FS→FS copy for the one-time SD adoption migration (FFat store → SD /meshcomod). // Skips files that already exist at the destination, so a partial earlier run just completes. -static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp) { - File probe = dst.open(dp, FILE_READ); - if (probe) { probe.close(); return; } +static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp, bool overwrite = false) { + if (!overwrite) { + File probe = dst.open(dp, FILE_READ); + if (probe) { probe.close(); return; } + } File in = src.open(sp, FILE_READ); if (!in) return; File out = dst.open(dp, FILE_WRITE); @@ -112,7 +119,7 @@ static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const ch out.close(); in.close(); printf("[storage] migrated %s\n", sp); } -static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir) { +static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir, bool overwrite = false) { File d = src.open(sdir); if (!d || !d.isDirectory()) { if (d) d.close(); return; } dst.mkdir(ddir); @@ -126,8 +133,8 @@ static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char snprintf(dp, sizeof dp, "%s/%s", ddir, leaf); bool is_dir = f.isDirectory(); f.close(); - if (is_dir) migrateFsDir(src, sp, dst, dp); - else migrateFsCopyFile(src, sp, dst, dp); + if (is_dir) migrateFsDir(src, sp, dst, dp, overwrite); + else migrateFsCopyFile(src, sp, dst, dp, overwrite); } d.close(); } @@ -164,9 +171,12 @@ static void wadameshSetup() { DisplayDriver* disp = &display; // 6. Storage — microSD (SDMMC slot 0) primary, internal FFat 'storage' as the no-card fallback. - g_fs_ok = FFat.begin(true, "/ffat", 10, "storage"); - printf("[storage] FFat(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); - if (g_fs_ok) { FFat.mkdir("/identity"); FFat.mkdir("/bl"); } + // formatOnFail=true: the first boot of this build finds FAT data in the partition, fails the + // littlefs mount, and formats it — the deliberate one-way conversion (store contents come back + // from the SD card via the v4 migration below). + g_fs_ok = LittleFS.begin(true, "/lfs", 10, "storage"); + printf("[storage] LittleFS(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); + if (g_fs_ok) { LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); } // The slot's VDD is gated by the XL9535's SD_EN (IO15), ACTIVE-LOW — powerOnSequence() drives it // low. (Root-caused 2026-07-15 with a GPIO pad sweep: SD_EN high = all six SD pads clamped LOW // through the unpowered card's ESD diodes -> 0x107/0x109 on every mount; SD_EN low = pads high, @@ -180,9 +190,24 @@ static void wadameshSetup() { for (int p : {44, 39, 40, 41, 42}) gpio_pullup_en((gpio_num_t)p); // Mount ladder: 4-bit@20 MHz normally succeeds first try now that the slot is powered; the // fallbacks stay for marginal cards. (1-bit skips D1..D3; 5 MHz derates signal margin.) + // + // #167: SD activity IS the whole-screen flash. Proven by elimination on-device: identical + // firmware with the card removed never flashes, while every reported trigger (message arrives, + // opening an unread chat, contact add/delete, boot pre-splash) ends in an SD write burst or the + // mount probe. The display stack itself measured clean throughout (framebuffer content, bridge + // underrun, host protocol errors, DCS traffic, expander writes -- all silent during visible + // flashes; DSI config + panel init identical to the flash-free Meck-P4). Remaining coupling is + // electrical: the 4-bit 20 MHz SDMMC bus and/or the card's write-current spikes on the shared + // rail. TDP4_SD_KHZ / TDP4_SD_1BIT (build-time) tune the first mount rung to test/derate. { +#ifndef TDP4_SD_KHZ +#define TDP4_SD_KHZ SDMMC_FREQ_DEFAULT // 20 MHz +#endif +#ifndef TDP4_SD_1BIT +#define TDP4_SD_1BIT false +#endif struct { bool onebit; int khz; const char *tag; } tries[] = { - { false, SDMMC_FREQ_DEFAULT, "4-bit 20MHz" }, + { TDP4_SD_1BIT, TDP4_SD_KHZ, "primary (TDP4_SD_KHZ)" }, { false, 5000, "4-bit 5MHz" }, { true, SDMMC_FREQ_DEFAULT, "1-bit 20MHz" }, { true, 5000, "1-bit 5MHz" }, @@ -197,20 +222,71 @@ static void wadameshSetup() { } printf("[storage] SD_MMC = %s\n", g_sd_ok ? "OK" : "no card"); if (g_sd_ok) { - SD_MMC.mkdir("/meshcomod"); SD_MMC.mkdir("/meshcomod/identity"); SD_MMC.mkdir("/meshcomod/bl"); - // One-time ADOPTION MIGRATION (the beta_36 lesson: never flip a storage root without migrating). - // This device has been running with the store on FFat while the SD init was broken — if the card - // has no identity yet but FFat does, copy the whole FFat store across BEFORE switching the root, - // or the node would boot with a fresh identity and "lose" contacts/prefs/history. - File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); - bool sd_has_id = (bool)sp; if (sp) sp.close(); - File fp = FFat.open("/identity/_main.id", FILE_READ); - bool ff_has_id = (bool)fp; if (fp) fp.close(); - if (!sd_has_id && ff_has_id) { - printf("[storage] first SD adoption -> migrating FFat store to SD /meshcomod\n"); - migrateFsDir(FFat, "/", SD_MMC, "/meshcomod"); + // #167 RESOLUTION -- the hot store lives on INTERNAL FFat; the SD carries only bulk, gentle + // writers (map tiles, backups). Root cause, established by on-device elimination: SD-card + // WRITE bursts electrically disturb the AMOLED (whole-screen blue flash). The display stack + // measured clean throughout -- framebuffer content, DSI bridge underrun, DSI host protocol + // errors, DCS traffic, expander writes: all silent during visible flashes -- and the flash + // survived every digital derate (4->1-bit bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane + // rate) on EVERY card tried, at any brightness, yet vanished the moment the card was removed + // or left unwritten. The card's supply rail is shared with the panel (XL9535 SD_EN switches + // it off the same 3V3 domain), so its program-current spikes reach the panel and firmware + // cannot filter them -- it can only stop hammering the card. Streaming tile writes never + // flashed, so the SD keeps those. This is also why the flash-free Meck build proves nothing + // about the board: it keeps its store internal and never writes the SD at all. + // + // One-time REVERSE migration: earlier P4 builds kept the live store on SD /meshcomod, so if + // the card carries an identity and this build hasn't migrated yet, copy the store back to + // FFat, OVERWRITING FFat's stale pre-adoption copies (the SD is the current truth -- booting + // cardless on the old FFat set showed long-deleted contacts). SD data is left in place, + // untouched, as a rollback. + // v4 migration -- one lesson per predecessor: v1 trusted whichever card was in and copied a + // blank store; v2 copied the whole SD tree and filled the partition before contacts3 landed; + // v3 did format+whitelist correctly and STILL read back empty -- because the P4's FAT-on-flash + // driver itself is broken (its stat layer lying was already documented; v3 proved writes are + // no better). v4 = the same clean-slate whitelist copy, onto LittleFS. Card is never written. + File mk = LittleFS.open("/.hot_store_on_lfs_v4", FILE_READ); + bool migrated = (bool)mk; if (mk) mk.close(); + if (!migrated) { + File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); + bool sd_has_id = (bool)sp; if (sp) sp.close(); + auto fsize = [](fs::FS &f, const char *path) -> size_t { + File h = f.open(path, FILE_READ); size_t n = h ? h.size() : 0; if (h) h.close(); return n; + }; + size_t sd_meshcomod = fsize(SD_MMC, "/meshcomod/contacts3"); + size_t sd_root = fsize(SD_MMC, "/contacts3"); + size_t lf_contacts = fsize(LittleFS, "/contacts3"); + size_t sd_contacts = sd_meshcomod > sd_root ? sd_meshcomod : sd_root; + if (sd_has_id && sd_contacts > lf_contacts) { + printf("[storage] #167 v4: store SD -> LittleFS (contacts3 /meshcomod=%uB root=%uB, lfs had %uB)\n", + (unsigned)sd_meshcomod, (unsigned)sd_root, (unsigned)lf_contacts); + LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); + const char *sub = sd_meshcomod >= sd_root ? "/meshcomod" : ""; // live layout root on the card + char sp2[64]; + migrateFsDir(SD_MMC, "/meshcomod/identity", LittleFS, "/identity", true); + migrateFsDir(SD_MMC, "/meshcomod/bl", LittleFS, "/bl", true); + static const char *k_files[] = { "/contacts3", "/channels2", "/adv_blobs", "/new_prefs", + "/new_prefs.tmp", "/regions2", "/ui_chat_history_v1.bin" }; + for (const char *nm : k_files) { + snprintf(sp2, sizeof sp2, "%s%s", sub, nm); + File probe = SD_MMC.open(sp2, FILE_READ); bool have = (bool)probe; if (probe) probe.close(); + if (!have && sub[0]) { snprintf(sp2, sizeof sp2, "%s", nm); } + else if (!have) { snprintf(sp2, sizeof sp2, "/meshcomod%s", nm); } + migrateFsCopyFile(SD_MMC, sp2, LittleFS, nm, true); + } + printf("[storage] #167 v4: done -- lfs used %u / %u KB, contacts3 readback %uB\n", + (unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024), + (unsigned)fsize(LittleFS, "/contacts3")); + File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); + if (w) { w.write((const uint8_t*)"1", 1); w.close(); } + } else { + printf("[storage] #167 v4: no migration (sd_id=%d sd=%uB lfs=%uB) -- LittleFS is the store\n", + (int)sd_has_id, (unsigned)sd_contacts, (unsigned)lf_contacts); + File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE); + if (w) { w.write((const uint8_t*)"1", 1); w.close(); } + } } - store.useSdMmcStorage(); + // Deliberately NOT calling store.useSdMmcStorage(): FFat stays the DataStore root. } store.begin(); diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp index ee5d922..4cc8b35 100644 --- a/variants/tdisplay_p4/RM69A10Display.cpp +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -10,6 +10,10 @@ #include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51) #include "esp_lcd_panel_ops.h" #include "esp_lcd_rm69a10.h" +#if defined(TDP4_POKE_TRACE) +#include "soc/mipi_dsi_host_struct.h" // MIPI_DSI_HOST — raw host error status (poke-trace poller) +#include "freertos/task.h" +#endif #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -20,6 +24,22 @@ // the frame buffer as horizontal black/garbage bands, worst during transitions (many rapid // flushes). This binary semaphore makes each flush synchronous: draw_bitmap → wait for done. static SemaphoreHandle_t s_flush_sem = nullptr; +#if defined(TDP4_POKE_TRACE) +// #167 hunt, round 3: the esp_lcd driver watches ONLY the bridge-FIFO underrun (proven silent +// during visible flashes). The DSI HOST has its own never-read error latches: int_st0 = the +// panel acking packets WITH ERRORS, int_st1 = ECC/CRC/timeout/payload-write errors INCLUDING +// dpi_buff_pld_under (the host payload buffer underflowing -- a different FIFO than the bridge). +// Both are read-clear, so a 25 ms poll gives per-event capture. If these latch when the screen +// flashes, we have a hardware flash-detector and can bisect subsystems without eyes on the panel. +static void dsiErrPollTask(void*) { + for (;;) { + const uint32_t st0 = MIPI_DSI_HOST.int_st0.val; // RC + const uint32_t st1 = MIPI_DSI_HOST.int_st1.val; // RC + if (st0 || st1) printf("[DSIERR] %lu st0=%08X st1=%08X\n", (unsigned long)millis(), (unsigned)st0, (unsigned)st1); + vTaskDelay(pdMS_TO_TICKS(25)); + } +} +#endif static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel_event_data_t*, void*) { BaseType_t hpw = pdFALSE; if (s_flush_sem) xSemaphoreGiveFromISR(s_flush_sem, &hpw); @@ -38,7 +58,19 @@ static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel #endif #define RM_DPI_MHZ 60 #define RM_LANES 2 -#define RM_BITRATE 1000 // Mbps per lane — TODO(device): confirm vs LilyGo RM69A10 define +// Lane bit rate. LilyGo/Meck ship 1000 Mbps (confirmed against both sources 2026-07-31), and the +// panel needs only ~550 Mbps of it at DPI 60 MHz / RGB565 / 2 lanes -- the rest is eye margin. +// #167 made that margin matter: SD-card write bursts load the P4's on-chip LDO4 and disturb the +// sibling LDO3 that powers the DSI PHY, and at a 1 ns unit interval that wobble was enough to +// glitch the link -- the panel loses a frame to a whole-screen blue flash (proven by elimination: +// no flash with the card out or unwritten; framebuffer/underrun/host-error/DCS all measured clean; +// identical timings+init to the flash-free Meck build). A 750 Mbps derate was tried against #167 and changed nothing +// (the disturbance reaches the panel around the link, not through it), so this stays at the +// vendor value. Overridable per build: -DRM_BITRATE_MBPS=<n>. +#ifndef RM_BITRATE_MBPS +#define RM_BITRATE_MBPS 1000 +#endif +#define RM_BITRATE RM_BITRATE_MBPS #define RM_HSYNC 50 #define RM_HBP 150 #define RM_HFP 50 @@ -139,6 +171,9 @@ bool RM69A10Display::begin() { cbs.on_color_trans_done = rm69a10TransDone; esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr); + #if defined(TDP4_POKE_TRACE) + xTaskCreatePinnedToCore(dsiErrPollTask, "dsierr", 3072, nullptr, 3, nullptr, 1); +#endif Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H); return true; } @@ -221,10 +256,20 @@ void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_ void RM69A10Display::setBrightness(uint8_t b) { if (!_dbi_io) return; +#if defined(TDP4_POKE_TRACE) + // #167 hunt: DCS 0x51 is a low-power command injected into the LIVE video stream -- if the + // panel glitches a frame per command, these lines will pace the visible flashes exactly. + printf("[BRI] %lu val=%u\n", (unsigned long)millis(), b); +#endif esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS } +#if defined(TDP4_POKE_TRACE) +void RM69A10Display::turnOn() { printf("[DSP] %lu on\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; } +void RM69A10Display::turnOff() { printf("[DSP] %lu off\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; } +#else void RM69A10Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; } void RM69A10Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; } +#endif #endif // HAS_TDISPLAY_P4 diff --git a/variants/tdisplay_p4/Xl9535.cpp b/variants/tdisplay_p4/Xl9535.cpp index 6b4fa67..1c5a0cd 100644 --- a/variants/tdisplay_p4/Xl9535.cpp +++ b/variants/tdisplay_p4/Xl9535.cpp @@ -51,6 +51,11 @@ void Xl9535::write(uint8_t io, bool high) { uint8_t port, bit; split(io, port, bit); if (high) _out[port] |= (1 << bit); else _out[port] &= ~(1 << bit); +#if defined(TDP4_POKE_TRACE) + // #167 hunt: any expander write while the panel streams is a glitch suspect (this is an + // UNLOCKED shadow RMW -- a cross-task race can transiently drop SCREEN_RST or a rail bit). + printf("[XLW] %lu io=%u v=%d out=%02X%02X\n", (unsigned long)millis(), io, (int)high, _out[1], _out[0]); +#endif writeReg(REG_OUT0 + port, _out[port]); } From 099ccd8e3cc5c4f7c9c6710204ef68cb6f123599 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 15:37:05 +0200 Subject: [PATCH 67/99] p4: core-0 storage dispatcher + the completed #167 experiment matrix Follow-up to 3bf56cd, driven by the (correct) challenge that "SD writes upset the panel electrically" could not be the whole story when tile writes never flash and other P4 firmwares use their cards freely. The completed on-device matrix now separates two distinct mechanisms: M1 An SD write EXECUTING on core 1 (where display.begin() allocated the DSI frame-restart ISR, and where the Arduino loop task runs) masks that core's interrupts inside the SDMMC/FATFS critical sections long enough to drop a display frame. Proven: 80 contact saves to SD from core 1 flash every time; the identical 80 saves hopped to core 0 never flash. NOT electrical -- the challenge was right. M2 An SD write CONCURRENT with live UI rendering still flashes even from core 0 (manual add/delete flows pump LVGL while the hopped write runs; the write-open trace shows those runs performed only correct core-0 writes and flashed anyway). The same manual flow with the card mounted but ZERO SD writes (internal LittleFS store) is clean. Shipped design therefore stays: hot store on internal LittleFS (immune to both mechanisms, verified across every flow), SD for tiles/bulk. Tiles' long-standing immunity is consistent: brief core-0 writes vs the store's long ones. New in this commit, kept for good: - p4StorageCall (DataStore.cpp): a core-0 storage task; every hot write entry (saveContacts/saveChannels/savePrefs/blobs, history segs, threads file) hops onto it, blocking the caller -- semantics unchanged, execution core moved. With the internal store this is still wanted hygiene: no storage write ever runs on the render core again. - WADA_SD_STORE_TEST build gate: puts the store back on the SD card for future M2 investigation (known to still flash on manual flows; do not ship). - TDP4_POKE_TRACE now also logs every write-open with its issuing core ([SDW]), which is what pinned M2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/DataStore.cpp | 95 ++++++++++++++++++++++++++++++++ src/DataStore.h | 5 ++ src/helpers/esp32/SdNvsPrefs.cpp | 3 + src/ui-touch/UITask.cpp | 36 ++++++++++++ tdisplay_p4/main/CMakeLists.txt | 6 ++ tdisplay_p4/main/main.cpp | 10 +++- 6 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/DataStore.cpp b/src/DataStore.cpp index 89d302e..cc8dee9 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -46,6 +46,9 @@ const char* DataStore::_rp(const char* name) { } File DataStore::openWrite(FILESYSTEM* fs, const char* filename) { +#if defined(TDP4_POKE_TRACE) + printf("[SDW] %lu core%d store w %s\n", (unsigned long)millis(), xPortGetCoreID(), filename); +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove(_rp(filename)); return fs->open(_rp(filename), FILE_O_WRITE); @@ -365,6 +368,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no } bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; const NodePrefs* p; double la, lo; bool r; } a{ this, &_prefs, node_lat, node_lon, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->savePrefs(*a->p, a->la, a->lo); }, &a); + return a.r; + } +#endif // Write to a temp file first and swap it in afterwards: a reboot / power cut // mid-write can then never destroy the only copy (the loader recovers from // whichever file survived). SPIFFS has no atomic rename-over, so the swap is @@ -468,6 +478,13 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3"); } void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; DataStoreHost* h; bool (*f)(const ContactInfo&); } a{ this, host, filter }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveContacts(a->h, a->f); }, &a); + return; + } +#endif #if defined(ESP32) // A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC // pass that disables the flash cache and starves core 0's idle task, tripping @@ -608,6 +625,13 @@ void DataStore::loadChannels(DataStoreHost* host) { } void DataStore::saveChannels(DataStoreHost* host) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; DataStoreHost* h; } a{ this, host }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveChannels(a->h); }, &a); + return; + } +#endif File file = openWrite(_getContactsChannelsFS(), "/channels2"); if (file) { uint8_t channel_idx = 0; @@ -778,6 +802,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b } bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); + return a.r; + } +#endif if (len < PUB_KEY_SIZE+4+SIGNATURE_SIZE || len > MAX_ADVERT_PKT_LEN) return false; checkAdvBlobFile(); File file = _getContactsChannelsFS()->open("/adv_blobs", FILE_O_WRITE); @@ -815,6 +846,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src return false; // error } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); + return a.r; + } +#endif return true; // this is just a stub on NRF52/STM32 platforms } #else @@ -841,6 +879,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b } bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); + return a.r; + } +#endif char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); @@ -856,6 +901,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { + struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); + return a.r; + } +#endif char path[64]; makeBlobPath(key, key_len, path, sizeof(path)); @@ -912,3 +964,46 @@ bool DataStore::useSdMmcStorage() { return true; } #endif + +#if defined(HAS_TDISPLAY_P4) +// #167: core-0 storage dispatcher. The DSI panel's frame-restart ISR lives on core 1 (where +// Arduino's loop task also runs); SDMMC/FATFS write paths entered FROM core 1 mask that core's +// interrupts inside their critical sections long enough to drop a display frame -- the +// whole-screen flash. Tiles write the same card from the core-0 tile task without ever +// flashing; this moves every hot store write onto the same quiet core. The caller BLOCKS until +// the write completes, so call-site semantics are exactly as before. +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" + +struct P4StoreJob { void (*fn)(void*); void* arg; SemaphoreHandle_t done; }; +static QueueHandle_t s_p4store_q = nullptr; + +static void p4StoreTaskFn(void*) { + P4StoreJob j; + for (;;) { + if (xQueueReceive(s_p4store_q, &j, portMAX_DELAY) == pdTRUE) { + j.fn(j.arg); + xSemaphoreGive(j.done); + } + } +} + +void p4StorageCall(void (*fn)(void*), void* arg) { + if (xPortGetCoreID() == 0) { fn(arg); return; } // already on the quiet core + static SemaphoreHandle_t s_mtx = nullptr; + if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); // races only at first-ever call during boot (single-threaded) + xSemaphoreTake(s_mtx, portMAX_DELAY); + if (!s_p4store_q) { + s_p4store_q = xQueueCreate(1, sizeof(P4StoreJob)); + xTaskCreatePinnedToCore(p4StoreTaskFn, "store0", 8192, nullptr, 2, nullptr, 0); + } + static SemaphoreHandle_t s_done = nullptr; // one in flight (serialized by s_mtx) + if (!s_done) s_done = xSemaphoreCreateBinary(); + P4StoreJob j{ fn, arg, s_done }; + xQueueSend(s_p4store_q, &j, portMAX_DELAY); + xSemaphoreTake(s_done, portMAX_DELAY); + xSemaphoreGive(s_mtx); +} +#endif diff --git a/src/DataStore.h b/src/DataStore.h index 478c096..6f15c6b 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -88,3 +88,8 @@ public: private: FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;}; }; + +#if defined(HAS_TDISPLAY_P4) +// #167: run fn(arg) on a dedicated core-0 storage task, blocking the caller. See DataStore.cpp. +void p4StorageCall(void (*fn)(void*), void* arg); +#endif diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 9d69a59..42f6a4b 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -136,6 +136,9 @@ void SdNvsPrefs::sdSave() { strncpy(dir, _path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0'; char* slash = strrchr(dir, '/'); if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); } +#if defined(TDP4_POKE_TRACE) + printf("[SDW] %lu core%d kv w %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), _path); +#endif File f = fs->open(_path, FILE_WRITE); // truncate + rewrite the whole file if (!f) return; for (auto& e : _sd) { diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b9f0a45..5193a3f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41906,6 +41906,11 @@ static bool uiDataFsIsSdCard() { #endif } static File uiDataOpen(const char* name, const char* mode) { +#if defined(TDP4_POKE_TRACE) + // #167 hunt: every ui-data open, with the issuing core -- a 'w'/'a' open from core 1 during a + // manual add/delete IS the un-hopped writer we're looking for. + if (mode && mode[0] != 'r') printf("[SDW] %lu core%d uiData %s %s\n", (unsigned long)millis(), xPortGetCoreID(), mode, name); +#endif if (!uiDataFsReady()) return File(); char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); File f = s_ui_data_fs->open(p, mode); @@ -42461,6 +42466,15 @@ bool UITask::loadHistoryFromStorage() { bool UITask::saveThreadsToStorage() { #if defined(ESP32) +#if defined(HAS_TDISPLAY_P4) + // #167: the threads file is rewritten on every contact add/delete and chat-state change -- + // hop the write to the core-0 storage task like every other hot writer (see p4StorageCall). + if (xPortGetCoreID() != 0) { + struct A { UITask* t; bool ok; } a{ this, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = a->t->saveThreadsToStorage(); }, &a); + return a.ok; + } +#endif WdtHeavyGuard _wg; File f = uiDataOpen(k_ui_threads_path, "w"); if (!f) return false; @@ -42630,6 +42644,15 @@ static bool uiSegWriteRecords(File& f, const UITask::UIMessage* recs, int n) { // the whole point of the segmented layout). static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n) { +#if defined(HAS_TDISPLAY_P4) + // #167: history writes hop to the core-0 storage task (see p4StorageCall in DataStore.cpp) -- + // a core-1 SD write can mask the DSI frame-restart ISR long enough to drop a display frame. + if (xPortGetCoreID() != 0) { + struct A { uint32_t fs; bool c; const UITask::UIMessage* r; int n; bool ok; } a{ first_seq, create, recs, n, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegAppendRecords(a->fs, a->c, a->r, a->n); }, &a); + return a.ok; + } +#endif WdtHeavyGuard _wg; errno = 0; char name[48]; @@ -42682,6 +42705,13 @@ static bool uiSegAppendRecords(uint32_t first_seq, bool create, // lesson from the single-file store). static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { // #167: hop to core 0 (see uiSegAppendRecords) + struct A { uint32_t fs; const UITask::UIMessage* r; int n; bool sw; bool ok; } a{ first_seq, recs, n, sync_writer, false }; + p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegCompactWrite(a->fs, a->r, a->n, a->sw); }, &a); + return a.ok; + } +#endif WdtHeavyGuard _wg; errno = 0; char fin[48], tmp[48]; @@ -42715,6 +42745,12 @@ static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, // Remove a retired segment (ring grew past retention; its records are gone // from RAM too). static void uiSegRemoveFile(uint32_t first_seq) { +#if defined(HAS_TDISPLAY_P4) + if (xPortGetCoreID() != 0) { // #167: hop to core 0 (see uiSegAppendRecords) + p4StorageCall([](void* p){ uiSegRemoveFile((uint32_t)(uintptr_t)p); }, (void*)(uintptr_t)first_seq); + return; + } +#endif char name[48]; uiSegName(first_seq, "", name, sizeof name); uiDataRemove(name); diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt index 73a9bf4..b15483f 100644 --- a/tdisplay_p4/main/CMakeLists.txt +++ b/tdisplay_p4/main/CMakeLists.txt @@ -129,6 +129,12 @@ if(DEFINED ENV{WADA_DSI_MBPS}) message(STATUS "wadamesh: DSI lane rate = $ENV{WADA_DSI_MBPS} Mbps") endif() +# #167: WADA_SD_STORE_TEST=1 puts the store back on the SD card (writes hop to core 0). +if(DEFINED ENV{WADA_SD_STORE_TEST}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_STORE_TEST=1" APPEND) + message(STATUS "wadamesh: SD-STORE-ON-CORE-0 experiment build") +endif() + # Companion diagnostic (#167): `WADA_P4_POKE_TRACE=1 ./build.sh build` logs every runtime poke at # the panel or the XL9535 expander (DCS brightness writes, disp on/off, expander bit writes) so # visible flashes can be correlated against them on the console. diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp index 24d4732..3160a3f 100644 --- a/tdisplay_p4/main/main.cpp +++ b/tdisplay_p4/main/main.cpp @@ -286,7 +286,15 @@ static void wadameshSetup() { if (w) { w.write((const uint8_t*)"1", 1); w.close(); } } } - // Deliberately NOT calling store.useSdMmcStorage(): FFat stays the DataStore root. +#if defined(TDP4_SD_STORE_TEST) + // #167 EXPERIMENT: store back on the SD card with all hot writes hopped to core 0 (the tile + // task's core, which has never flashed). One variable vs the known-flashing config: the + // executing core. Uses the card's existing (slightly stale) store; migration skipped. + printf("[storage] TDP4_SD_STORE_TEST: store on SD, writes on core 0\n"); + store.useSdMmcStorage(); +#else + // Deliberately NOT calling store.useSdMmcStorage(): the internal store stays the DataStore root. +#endif } store.begin(); From 83887976f048ad9bff68114155055e0eeb79b144 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 17:00:27 +0200 Subject: [PATCH 68/99] p4: fix the storage dispatcher (it never engaged) + the measured #167 mechanism The p4StorageCall dispatcher committed in 099ccd8 had an inverted guard: it skipped marshalling when already on core 0 -- written believing the main task ran on core 1, but ESP_MAIN_TASK_AFFINITY_CPU0 puts the UI+mesh main task on core 0, so every "hopped" write actually ran inline on the main task (proven by a sector-level SDMMC trace: all card I/O on task 'main'). Fixed: the guard is task-identity (p4OnStorageTask), all 11 write wrappers use it, and the storage task runs at priority 3 on core 0 -- core 1 was tried and is pathological for SD I/O (the SDMMC completion ISR lives on core 0; cross-core wakeups made a contact save take ~20 s). With the dispatcher REALLY engaging, the mechanism of the SD-store flash was finally measured instead of theorized: a gap tracker in the DSI frame-restart ISR recorded 35-39 ms inter-frame gaps (vs the 20 ms cadence) exactly interleaved with SD sector transactions. The panel's frame DMA stops after every frame and waits for an ISR to re-arm it; anything that masks interrupts long enough (SD-transaction critical sections -- and on RISC-V a critical section masks ALL levels, which is why an ESP_INTR_FLAG_LEVEL3 experiment changed nothing) makes the restart late, and the panel displays the extended blanking as the whole-screen flash. No error register ever latches because nothing fails; frames are merely late. This also finally explains the tile asymmetry: a tile is one large sequential transfer (one interrupt burst per file), a FATFS store save is dozens of small transactions and metadata round-trips -- dozens of chances per save to push a frame restart late. Ship state stays: hot store on internal LittleFS (no SD transactions on the hot path -- no late frames), SD for tiles/bulk. The promising future fix for restoring the T-Deck-style SD store is making the panel autonomous: a CIRCULAR single-FB DMA descriptor (dw_gdma_lli_set_next(item, item), is_last=false) so the hardware loops frames with no ISR in the path at all -- prototyped against the local IDF's esp_lcd_panel_dpi.c and reverted with the other diagnostics (sdmmc sector trace, DSILATE gap tracker, LEVEL3); it needs its own verified session and a durable patch mechanism before it can ship. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/DataStore.cpp | 32 +++++++++++++++++++++++--------- src/DataStore.h | 3 ++- src/ui-touch/UITask.cpp | 14 ++++++++------ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/DataStore.cpp b/src/DataStore.cpp index cc8dee9..c645cf2 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -195,6 +195,9 @@ File DataStore::openRead(const char* filename) { } File DataStore::openRead(FILESYSTEM* fs, const char* filename) { +#if defined(TDP4_POKE_TRACE) + printf("[SDR] %lu core%d store r %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), filename); +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return fs->open(_rp(filename), FILE_O_READ); #elif defined(RP2040_PLATFORM) @@ -369,7 +372,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; const NodePrefs* p; double la, lo; bool r; } a{ this, &_prefs, node_lat, node_lon, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->savePrefs(*a->p, a->la, a->lo); }, &a); return a.r; @@ -479,7 +482,7 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3"); void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; DataStoreHost* h; bool (*f)(const ContactInfo&); } a{ this, host, filter }; p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveContacts(a->h, a->f); }, &a); return; @@ -626,7 +629,7 @@ void DataStore::loadChannels(DataStoreHost* host) { void DataStore::saveChannels(DataStoreHost* host) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; DataStoreHost* h; } a{ this, host }; p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveChannels(a->h); }, &a); return; @@ -803,7 +806,7 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); return a.r; @@ -847,7 +850,7 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); return a.r; @@ -880,7 +883,7 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a); return a.r; @@ -902,7 +905,7 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a); return a.r; @@ -990,14 +993,25 @@ static void p4StoreTaskFn(void*) { } } +static TaskHandle_t s_p4store_task = nullptr; + +bool p4OnStorageTask() { return xTaskGetCurrentTaskHandle() == s_p4store_task && s_p4store_task != nullptr; } + void p4StorageCall(void (*fn)(void*), void* arg) { - if (xPortGetCoreID() == 0) { fn(arg); return; } // already on the quiet core + // Guard on TASK IDENTITY, not core. The original core guard was written believing the main + // task ran on core 1; it runs on core 0 (ESP_MAIN_TASK_AFFINITY_CPU0), so the guard + // short-circuited and every "hopped" write actually ran inline on the main task -- proven by + // the sector-level trace (all SDIO on task 'main'). The storage task is pinned to CORE 1, + // which is essentially empty on this board: the DSI frame-restart ISR, the main (UI+mesh) + // task, the SDMMC ISR and the tile task all live on core 0, so SD critical sections executed + // on core 1 can never mask the display's interrupt regardless of what the UI is rendering. + if (p4OnStorageTask()) { fn(arg); return; } // re-entry on the storage task static SemaphoreHandle_t s_mtx = nullptr; if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); // races only at first-ever call during boot (single-threaded) xSemaphoreTake(s_mtx, portMAX_DELAY); if (!s_p4store_q) { s_p4store_q = xQueueCreate(1, sizeof(P4StoreJob)); - xTaskCreatePinnedToCore(p4StoreTaskFn, "store0", 8192, nullptr, 2, nullptr, 0); + xTaskCreatePinnedToCore(p4StoreTaskFn, "store1", 8192, nullptr, 3, &s_p4store_task, 0); // core 0: the SDMMC completion ISR lives there; core-1 I/O crawls on cross-core wakeups } static SemaphoreHandle_t s_done = nullptr; // one in flight (serialized by s_mtx) if (!s_done) s_done = xSemaphoreCreateBinary(); diff --git a/src/DataStore.h b/src/DataStore.h index 6f15c6b..884d12f 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -90,6 +90,7 @@ private: }; #if defined(HAS_TDISPLAY_P4) -// #167: run fn(arg) on a dedicated core-0 storage task, blocking the caller. See DataStore.cpp. +// #167: run fn(arg) on the dedicated CORE-1 storage task, blocking the caller. See DataStore.cpp. void p4StorageCall(void (*fn)(void*), void* arg); +bool p4OnStorageTask(); // true when the current task IS the storage task (wrapper re-entry guard) #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5193a3f..79e7b5e 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41907,9 +41907,11 @@ static bool uiDataFsIsSdCard() { } static File uiDataOpen(const char* name, const char* mode) { #if defined(TDP4_POKE_TRACE) - // #167 hunt: every ui-data open, with the issuing core -- a 'w'/'a' open from core 1 during a - // manual add/delete IS the un-hopped writer we're looking for. + // #167 hunt: every ui-data open with its issuing core. Writes ([SDW]) were all correctly on + // core 0 during a flashing manual flow -- so now READS are traced too ([SDR]): a read is the + // same FATFS/SDMMC critical-section machinery, and UI flows read constantly from core 1. if (mode && mode[0] != 'r') printf("[SDW] %lu core%d uiData %s %s\n", (unsigned long)millis(), xPortGetCoreID(), mode, name); + else printf("[SDR] %lu core%d uiData r %s\n", (unsigned long)millis(), xPortGetCoreID(), name); #endif if (!uiDataFsReady()) return File(); char p[80]; snprintf(p, sizeof p, "%s%s", s_ui_data_root, name); @@ -42469,7 +42471,7 @@ bool UITask::saveThreadsToStorage() { #if defined(HAS_TDISPLAY_P4) // #167: the threads file is rewritten on every contact add/delete and chat-state change -- // hop the write to the core-0 storage task like every other hot writer (see p4StorageCall). - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { UITask* t; bool ok; } a{ this, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = a->t->saveThreadsToStorage(); }, &a); return a.ok; @@ -42647,7 +42649,7 @@ static bool uiSegAppendRecords(uint32_t first_seq, bool create, #if defined(HAS_TDISPLAY_P4) // #167: history writes hop to the core-0 storage task (see p4StorageCall in DataStore.cpp) -- // a core-1 SD write can mask the DSI frame-restart ISR long enough to drop a display frame. - if (xPortGetCoreID() != 0) { + if (!p4OnStorageTask()) { struct A { uint32_t fs; bool c; const UITask::UIMessage* r; int n; bool ok; } a{ first_seq, create, recs, n, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegAppendRecords(a->fs, a->c, a->r, a->n); }, &a); return a.ok; @@ -42706,7 +42708,7 @@ static bool uiSegAppendRecords(uint32_t first_seq, bool create, static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { // #167: hop to core 0 (see uiSegAppendRecords) + if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) struct A { uint32_t fs; const UITask::UIMessage* r; int n; bool sw; bool ok; } a{ first_seq, recs, n, sync_writer, false }; p4StorageCall([](void* p){ auto* a = (A*)p; a->ok = uiSegCompactWrite(a->fs, a->r, a->n, a->sw); }, &a); return a.ok; @@ -42746,7 +42748,7 @@ static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, // from RAM too). static void uiSegRemoveFile(uint32_t first_seq) { #if defined(HAS_TDISPLAY_P4) - if (xPortGetCoreID() != 0) { // #167: hop to core 0 (see uiSegAppendRecords) + if (!p4OnStorageTask()) { // #167: hop to core 0 (see uiSegAppendRecords) p4StorageCall([](void* p){ uiSegRemoveFile((uint32_t)(uintptr_t)p); }, (void*)(uintptr_t)first_seq); return; } From 40c4f205d19efce56d89afb611f3fcd789842623 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 17:44:11 +0200 Subject: [PATCH 69/99] touch: V4-R8 landscape touch was point-mirrored; boot forces portrait as rescue A tester who switched the V4-R8 to landscape got stuck: every touch registered in the wrong place, so the setting could not be reached to switch back. Cause: the shared cap-touch landscape transform (applyPointRotation + rotateSwipeDelta in HeltecV4CapTouch.cpp) was tuned on the V4's ST7789 driver, whose PORTRAIT baseline is panel rotation 2 -- a +180 offset baked into the landscape maps ("+180 baseline" in the comments). The R8's LovyanGFX panel runs baseline 0 (offset_rotation = 0, portrait = setRotation(0)), so applyHardwarePanelRotation lands the R8 panel 180 degrees from where the V4 lands for the same requested rotation, and the shared maps point-mirror every landscape touch. Fixes: - HeltecV4CapTouch.cpp: R8-gated landscape maps without the +180 baseline (the V4's two landscape cases swapped) for both the touch-point transform and the swipe-delta transform. Marked TESTER-VERIFY: geometry is confirmed, sign is not -- if landscape is still mirrored on-device, cases 1 and 3 swap back. - UITask.cpp: the V4-R8 forces PORTRAIT at every boot (and persists it), so the stuck reporter recovers on their next reboot and a wrong sign guess in the new maps costs a reboot instead of a stuck device. V4 and T-Deck behavior unchanged (verified: both build; the new maps are HELTEC_LORA_V4_R8-gated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/helpers/input/HeltecV4CapTouch.cpp | 25 +++++++++++++++++++++++++ src/ui-touch/UITask.cpp | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/helpers/input/HeltecV4CapTouch.cpp b/src/helpers/input/HeltecV4CapTouch.cpp index 053b233..2e1bf94 100644 --- a/src/helpers/input/HeltecV4CapTouch.cpp +++ b/src/helpers/input/HeltecV4CapTouch.cpp @@ -84,12 +84,22 @@ void heltecV4CapTouchSetRotation(uint8_t lvgl_rot) { s_ui_rotation = lvgl_rot & // identity. (Verified on hardware — without the flip, taps landed at the // diagonally-opposite point.) static void rotateSwipeDelta(int dx, int dy, int* odx, int* ody) { +#if defined(HELTEC_LORA_V4_R8) + // R8: no +180 panel baseline (see applyPointRotation) -- plain rotations, V4 cases swapped. + switch (s_ui_rotation) { + case 1: *odx = dy; *ody = -dx; break; // ROT_90 (baseline-0 panel) + case 2: *odx = -dx; *ody = -dy; break; // 180 + case 3: *odx = -dy; *ody = dx; break; // ROT_270 + default: *odx = dx; *ody = dy; break; // portrait + } +#else switch (s_ui_rotation) { case 1: *odx = -dy; *ody = dx; break; // ROT_90 (panel-rot-2 baseline) case 2: *odx = -dx; *ody = -dy; break; // 180 case 3: *odx = dy; *ody = -dx; break; // ROT_270 default: *odx = dx; *ody = dy; break; // portrait } +#endif } // Hardware-rotation touch-point transform. When the panel is rotated in @@ -107,6 +117,20 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) { const int H = 320; // V4 panel portrait height const int px = *x, py = *y; int lx, ly; +#if defined(HELTEC_LORA_V4_R8) + // R8 (LovyanGFX panel, offset_rotation 0, portrait = setRotation(0)): the panel has NO +180 + // baseline, so the plain 90/270 maps apply -- exactly the V4's two landscape cases swapped. + // The V4-tuned maps below landed every landscape touch point-mirrored on this board (the + // stuck-in-landscape report). TESTER-VERIFY: confirmed geometry, unconfirmed sign -- if + // landscape touch is still mirrored, swap case 1 and case 3 back. Boot forces portrait on + // this board (UITask), so a wrong guess costs a reboot, not a stuck device. + switch (s_point_rotation) { + case 1: lx = py; ly = (W - 1) - px; break; // ROT_90 -> 320x240 (plain 90) + case 2: lx = (W - 1) - px; ly = (H - 1) - py; break; // 180 + case 3: lx = (H - 1) - py; ly = px; break; // ROT_270 -> 320x240 (plain 270) + default: lx = px; ly = py; break; + } +#else // Same +180 baseline as rotateSwipeDelta: ROT_90 uses the 270-degree map. switch (s_point_rotation) { case 1: lx = (H - 1) - py; ly = px; break; // ROT_90 -> 320x240 @@ -114,6 +138,7 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) { case 3: lx = py; ly = (W - 1) - px; break; // ROT_270 -> 320x240 default: lx = px; ly = py; break; } +#endif if (lx < 0) lx = 0; if (ly < 0) ly = 0; *x = (uint16_t)lx; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 9230e88..6fda6d7 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -44372,6 +44372,19 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // is the single point where orientation is established for the session. #if defined(ESP32) s_ui_rotation = touchPrefsGetUiRotation(); +#if defined(HELTEC_LORA_V4_R8) + // V4-R8: force PORTRAIT at every boot. A tester who switched to landscape got stuck: the + // shared cap-touch landscape transform was tuned on the V4's ST7789 driver, whose portrait + // baseline is panel rotation 2 (+180) -- the R8's LovyanGFX panel runs baseline 0, so the + // same map landed every touch point-mirrored and the setting could not be reached to undo + // it. The R8-specific map in HeltecV4CapTouch.cpp is the candidate fix (tester-verify); + // until a tester confirms it, booting always returns to the known-good portrait, so the + // worst case of trying landscape is a reboot. + if (s_ui_rotation != LV_DISP_ROT_NONE) { + s_ui_rotation = LV_DISP_ROT_NONE; + touchPrefsSetUiRotation(LV_DISP_ROT_NONE); + } +#endif #if defined(HAS_TDECK_GT911) // The T-Deck panel is landscape-native (320x240) — the early boot wordmark // already renders upright at panel rotation 3. Always run the UI in From d52fcc47a5f16ce1460adf511750c8eab222d6cf Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Fri, 31 Jul 2026 17:45:18 +0200 Subject: [PATCH 70/99] =?UTF-8?q?touch:=20beta=5F54=20=E2=80=94=20P4=20scr?= =?UTF-8?q?een-flash=20+=20GPS=20fixes,=20Romanian=20(jcyrio),=20V4-R8=20l?= =?UTF-8?q?andscape=20rescue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- release-notes/beta_54.txt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 release-notes/beta_54.txt diff --git a/release-notes/beta_54.txt b/release-notes/beta_54.txt new file mode 100644 index 0000000..4613e48 --- /dev/null +++ b/release-notes/beta_54.txt @@ -0,0 +1,19 @@ +# beta_54 - T-Display P4 screen flash fixed, P4 GPS fixed, Romanian, V4-R8 landscape rescue +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- T-Display P4 ---------------------------------------------------------------------- +The whole-screen blue flash (#167) is fixed. It was never the display: writing to the SD card delays the interrupt that restarts the screen's frame timer, and the panel shows the late frame as a flash. The fix moves your contacts, channels and chat history to fast internal storage - the SD card now carries only map tiles, backups and exports. Your data migrates across automatically on first boot; the card is not modified and everything on it stays as a backup. +Because the card no longer holds your identity, swapping or removing the SD card can never again change who your node is or lose your contacts. +GPS is really fixed this time: the firmware never actually read what the GPS module was saying (the sensor loop was missing on this board). It reads it now - fixes confirmed outdoors, and the T-Deck-style quick fix behaviour returns after the module has had sky time. +The GPS settings page now shows live satellite info (in view, in use, signal strength) and updates while open, so "Searching" is no longer a black box. +Storage writes now run on a dedicated background worker, so a big contact save cannot make the interface stutter. + +# --- Romanian (community) --------------------------------------------------------------- +Romanian is the 13th interface language, with its own keyboard layout: ă on the home row, ş/ţ on the deck, â/î via the accent popups. Contributed by @jcyrio (#188, #189) - mulţumim! + +# --- Heltec V4-R8 ----------------------------------------------------------------------- +Fixed landscape mode registering every touch in the wrong place, which could leave the device stuck in landscape. The R8's screen sits rotated differently from the V4's, and the touch mapping assumed the V4. If you were stuck: this build boots back to portrait automatically. +For safety the R8 returns to portrait on every boot for now; landscape can be re-tested from Settings and we will make it stick once a tester confirms the fix. + +# --- General ---------------------------------------------------------------------------- +The Airtime and Snake apps got a crash fix when closing. From 1c761e78464d7e59ea8c0c9a205fe4cd549de2bc Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Fri, 31 Jul 2026 09:56:12 -0700 Subject: [PATCH 71/99] fix(esp32): make file-backed preferences crash-safe Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 258 ++++++++++++++++++++++++------- src/helpers/esp32/SdNvsPrefs.h | 6 +- 2 files changed, 204 insertions(+), 60 deletions(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 42f6a4b..2c196e1 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -4,6 +4,7 @@ #include <FS.h> #include <SD.h> #include <SPIFFS.h> +#include <errno.h> #include <string.h> // ----------------------------- backend selection ----------------------------- @@ -59,23 +60,45 @@ bool SdNvsPrefs::useNvs() { // ----------------------------- begin / end ----------------------------- bool SdNvsPrefs::begin(const char* ns, bool readOnly) { _read_only = readOnly; - _sd.clear(); - _sd_loaded = false; if (_nvs_open) { _nvs.end(); _nvs_open = false; } if (fileMode()) { - snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns); - sdLoad(); - _sd_loaded = true; + char path[sizeof(_path)]; + snprintf(path, sizeof(path), "%s/%s.kv", s_file_dir, ns); + if (strncmp(_path, path, sizeof(_path)) != 0) { + strncpy(_path, path, sizeof(_path) - 1); + _path[sizeof(_path) - 1] = '\0'; + _sd.clear(); + _sd_loaded = false; + } + const bool had_cache = _sd_loaded; + const bool loaded = sdLoad(); + if (loaded) _sd_loaded = true; if (nvsHasLegacyData()) _nvs_open = _nvs.begin(ns, true); // RO migration source - return true; + // A transient storage outage must not turn a previously-loaded namespace + // into an empty one. Readers can keep using the last-known-good mirror; + // writers fail so callers do not report a save that never reached disk. + return loaded || (readOnly && had_cache); } - if (useNvs()) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; } - snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns); - sdLoad(); - _sd_loaded = true; - return true; + if (useNvs()) { + _sd.clear(); + _sd_loaded = false; + _nvs_open = _nvs.begin(ns, readOnly); + return _nvs_open; + } + char path[sizeof(_path)]; + snprintf(path, sizeof(path), "/meshcomod/%s.kv", ns); + if (strncmp(_path, path, sizeof(_path)) != 0) { + strncpy(_path, path, sizeof(_path) - 1); + _path[sizeof(_path) - 1] = '\0'; + _sd.clear(); + _sd_loaded = false; + } + const bool had_cache = _sd_loaded; + const bool loaded = sdLoad(); + if (loaded) _sd_loaded = true; + return loaded || (readOnly && had_cache); } void SdNvsPrefs::end() { @@ -88,48 +111,114 @@ SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) { return nullptr; } -void SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { +bool SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { + if (!key || (len > 0 && !data)) return false; Kv* e = sdFind(key); + bool added = false; + std::vector<uint8_t> old; if (!e) { _sd.push_back(Kv{}); e = &_sd.back(); + added = true; strncpy(e->key, key, sizeof(e->key) - 1); e->key[sizeof(e->key) - 1] = '\0'; + } else { + old = e->val; } - e->val.assign(data, data + len); - sdSave(); + if (len == 0) e->val.clear(); + else e->val.assign(data, data + len); + if (sdSave()) return true; + if (added) _sd.pop_back(); + else e->val = std::move(old); + return false; } -void SdNvsPrefs::sdLoad() { +bool SdNvsPrefs::sdLoad() { + fs::FS* fs = activeFs(); + if (!fs) return false; + + char tmp[sizeof(_path) + 5], bak[sizeof(_path) + 5]; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", _path) >= (int)sizeof(tmp) || + snprintf(bak, sizeof(bak), "%s.bak", _path) >= (int)sizeof(bak)) return false; + + // No file on the first load is a valid, empty namespace. Once a namespace + // has been loaded, however, a missing file means the backing FS disappeared; + // retain the RAM mirror instead of silently replacing every key with defaults. + const bool have_live = fs->exists(_path); + const bool have_tmp = fs->exists(tmp); + const bool have_bak = fs->exists(bak); + if (!have_live && !have_tmp && !have_bak) { + if (_sd_loaded) return false; + _sd.clear(); + return true; + } + + auto parse = [fs](const char* path, std::vector<Kv>& out) { + File f = fs->open(path, FILE_READ); + if (!f) return false; + bool ok = true; + // record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Parse into a + // temporary vector so a short/corrupt file cannot destroy the RAM mirror. + while (f.available() > 0) { + if (out.size() >= 256) { ok = false; break; } + int kl = f.read(); + if (kl <= 0 || kl > 15) { ok = false; break; } + char k[16] = {0}; + if (f.read((uint8_t*)k, kl) != kl) { ok = false; break; } + int lo = f.read(), hi = f.read(); + if (lo < 0 || hi < 0) { ok = false; break; } + size_t vl = (size_t)lo | ((size_t)hi << 8); + if (vl > 2048) { ok = false; break; } + std::vector<uint8_t> v(vl); + if (vl && f.read(v.data(), vl) != (int)vl) { ok = false; break; } + Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0'; + e.val = std::move(v); + out.push_back(std::move(e)); + } + f.close(); + return ok; + }; + + // Prefer the live file. If a reset landed between the two renames, validate + // the verified temp next, then fall back to the previous complete backup. + std::vector<Kv> loaded; + if (have_live && parse(_path, loaded)) { + if (have_tmp) fs->remove(tmp); + if (have_bak) fs->remove(bak); + _sd = std::move(loaded); + return true; + } + loaded.clear(); + if (have_tmp && parse(tmp, loaded)) { + if (have_live && !fs->remove(_path)) return false; + if (!fs->rename(tmp, _path)) return false; + if (have_bak) fs->remove(bak); + _sd = std::move(loaded); + return true; + } + loaded.clear(); + if (have_bak && parse(bak, loaded)) { + if (have_live && !fs->remove(_path)) return false; + if (have_tmp) fs->remove(tmp); + if (!fs->rename(bak, _path)) return false; + _sd = std::move(loaded); + return true; + } + + Serial.printf("[PREFS] refusing partial/corrupt file %s\n", _path); + if (_sd_loaded) { + // Keep the last-known-good mirror when a mounted filesystem becomes + // temporarily unreadable or exposes an incomplete file. + return false; + } _sd.clear(); - fs::FS* fs = activeFs(); - if (!fs) return; - File f = fs->open(_path, FILE_READ); - if (!f) return; - // record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Sanity caps stop a - // corrupt file from a huge alloc; on any bad record we stop and keep what loaded. - while (f.available() > 0 && _sd.size() < 256) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - char k[16] = {0}; - if (f.read((uint8_t*)k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - std::vector<uint8_t> v(vl); - if (vl && f.read(v.data(), vl) != (int)vl) break; - Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0'; - e.val = std::move(v); - _sd.push_back(std::move(e)); - } - f.close(); + return false; } -void SdNvsPrefs::sdSave() { - if (_read_only) return; +bool SdNvsPrefs::sdSave() { + if (_read_only) return false; fs::FS* fs = activeFs(); - if (!fs) return; + if (!fs) return false; // Create the parent dir (SD needs it; SPIFFS is flat and treats the whole path // as a filename, so mkdir is a harmless no-op there). char dir[40]; @@ -139,18 +228,63 @@ void SdNvsPrefs::sdSave() { #if defined(TDP4_POKE_TRACE) printf("[SDW] %lu core%d kv w %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), _path); #endif - File f = fs->open(_path, FILE_WRITE); // truncate + rewrite the whole file - if (!f) return; + + char tmp[sizeof(_path) + 5], bak[sizeof(_path) + 5]; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", _path) >= (int)sizeof(tmp) || + snprintf(bak, sizeof(bak), "%s.bak", _path) >= (int)sizeof(bak)) return false; + fs->remove(tmp); // stale pre-swap temp; the intact live file remains authoritative + + errno = 0; + File f = fs->open(tmp, FILE_WRITE); + if (!f) { + Serial.printf("[PREFS] save open failed %s (e%d)\n", tmp, errno); + return false; + } + bool ok = true; + size_t expected = 0; for (auto& e : _sd) { size_t kl = strnlen(e.key, sizeof(e.key)); size_t vl = e.val.size(); - f.write((uint8_t)kl); - f.write((const uint8_t*)e.key, kl); - f.write((uint8_t)(vl & 0xFF)); - f.write((uint8_t)((vl >> 8) & 0xFF)); - if (vl) f.write(e.val.data(), vl); + expected += 1 + kl + 2 + vl; + ok = ok && (f.write((uint8_t)kl) == 1); + ok = ok && (f.write((const uint8_t*)e.key, kl) == kl); + ok = ok && (f.write((uint8_t)(vl & 0xFF)) == 1); + ok = ok && (f.write((uint8_t)((vl >> 8) & 0xFF)) == 1); + if (vl) ok = ok && (f.write(e.val.data(), vl) == vl); + if (!ok) break; } f.close(); + + // A short write leaves only the temp damaged. Verify the closed file size too + // before touching the live copy (catches buffered close/flush failures). + if (ok) { + File verify = fs->open(tmp, FILE_READ); + ok = verify && verify.size() == expected; + if (verify) verify.close(); + } + if (!ok) { + Serial.printf("[PREFS] save write failed %s (e%d)\n", tmp, errno); + fs->remove(tmp); + return false; + } + + // Two-step swap with recovery breadcrumbs. At every point either the old + // live file, the verified temp, or the backup is complete; sdLoad() resolves + // leftovers after a reset between renames. + const bool had_live = fs->exists(_path); + if (fs->exists(bak)) fs->remove(bak); + if (had_live && !fs->rename(_path, bak)) { + Serial.printf("[PREFS] save backup rename failed %s (e%d)\n", _path, errno); + fs->remove(tmp); + return false; + } + if (!fs->rename(tmp, _path)) { + Serial.printf("[PREFS] save commit rename failed %s (e%d)\n", _path, errno); + if (had_live) fs->rename(bak, _path); // best-effort live rollback + return false; + } + if (had_live) fs->remove(bak); + return true; } uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) { @@ -165,8 +299,7 @@ uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) { size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) { uint8_t b[8]; for (int i = 0; i < width; i++) b[i] = (uint8_t)(v >> (8 * i)); - sdSet(key, b, width); - return width; + return sdSet(key, b, width) ? width : 0; } // ----------------------------- API ----------------------------- @@ -181,15 +314,22 @@ bool SdNvsPrefs::isKey(const char* key) { bool SdNvsPrefs::remove(const char* key) { if (!fileMode() && useNvs()) return _nvs_open && _nvs.remove(key); - for (size_t i = 0; i < _sd.size(); i++) - if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) == 0) { _sd.erase(_sd.begin() + i); sdSave(); return true; } + for (size_t i = 0; i < _sd.size(); i++) { + if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) != 0) continue; + Kv old = _sd[i]; + _sd.erase(_sd.begin() + i); + if (sdSave()) return true; + _sd.insert(_sd.begin() + i, std::move(old)); + return false; + } return false; } bool SdNvsPrefs::clear() { if (fileMode()) { + std::vector<Kv> old = _sd; _sd.clear(); - sdSave(); + if (!sdSave()) { _sd = std::move(old); return false; } // Wipe any legacy NVS copy too, so a factory reset is permanent. Derive the // namespace from "<dir>/<ns>.kv". if (nvsHasLegacyData()) { @@ -204,7 +344,11 @@ bool SdNvsPrefs::clear() { return true; } if (useNvs()) return _nvs_open && _nvs.clear(); - _sd.clear(); sdSave(); return true; + std::vector<Kv> old = _sd; + _sd.clear(); + if (sdSave()) return true; + _sd = std::move(old); + return false; } uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) { @@ -299,9 +443,9 @@ size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { return n; } size_t SdNvsPrefs::putString(const char* k, const char* v) { - if (fileMode()) { size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; } + if (fileMode()) { size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0; } if (useNvs()) return _nvs.putString(k, v); - size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; + size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0; } size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { @@ -319,9 +463,9 @@ size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { return n; } size_t SdNvsPrefs::putBytes(const char* k, const void* buf, size_t len) { - if (fileMode()) { sdSet(k, (const uint8_t*)buf, len); return len; } + if (fileMode()) return sdSet(k, (const uint8_t*)buf, len) ? len : 0; if (useNvs()) return _nvs.putBytes(k, buf, len); - sdSet(k, (const uint8_t*)buf, len); return len; + return sdSet(k, (const uint8_t*)buf, len) ? len : 0; } #endif // ESP32 diff --git a/src/helpers/esp32/SdNvsPrefs.h b/src/helpers/esp32/SdNvsPrefs.h index f2d914d..fbf611c 100644 --- a/src/helpers/esp32/SdNvsPrefs.h +++ b/src/helpers/esp32/SdNvsPrefs.h @@ -71,9 +71,9 @@ private: bool useNvs(); // legacy probe (NVS vs SD), cached globally Kv* sdFind(const char* key); - void sdSet(const char* key, const uint8_t* data, size_t len); - void sdLoad(); - void sdSave(); + bool sdSet(const char* key, const uint8_t* data, size_t len); + bool sdLoad(); + bool sdSave(); uint64_t sdGetInt(const char* key, uint64_t def, int width); size_t sdPutInt(const char* key, uint64_t v, int width); }; From 540084a71821a43c80b3aa6aec34e1d9d9fa057a Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Fri, 31 Jul 2026 17:10:55 -0700 Subject: [PATCH 72/99] fix(touch): stop persisting unused tab state --- src/ui-touch/UITask.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6fda6d7..1deae3d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -45093,13 +45093,6 @@ void UITask::onLvTabChanged(int tab_index) { if (tab_index > k_last) tab_index = k_last; } _touch_screen = static_cast<TouchUiScreen>(static_cast<uint8_t>(tab_index)); -#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI) - SdNvsPrefs prefs; - if (prefs.begin("meshTouch", false)) { - prefs.putUChar("tab", static_cast<uint8_t>(tab_index)); - prefs.end(); - } -#endif } void UITask::appendDiag(const char* message) { From 2001ef72ddb94b224f02fa9d3c34b3aeaadd74cb Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Fri, 31 Jul 2026 18:08:27 -0700 Subject: [PATCH 73/99] refactor(esp32): persist preferences off the UI task Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 657 ++++++++++++++++++++------ src/helpers/esp32/SdNvsPrefs.h | 30 +- src/helpers/esp32/TouchPrefsStore.cpp | 136 ++---- src/helpers/esp32/TouchPrefsStore.h | 5 + src/ui-touch/UITask.cpp | 57 ++- 5 files changed, 614 insertions(+), 271 deletions(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 2c196e1..ffee892 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -4,7 +4,12 @@ #include <FS.h> #include <SD.h> #include <SPIFFS.h> -#include <errno.h> +#include <freertos/FreeRTOS.h> +#include <freertos/queue.h> +#include <freertos/semphr.h> +#include <freertos/task.h> +#include <new> +#include <stddef.h> #include <string.h> // ----------------------------- backend selection ----------------------------- @@ -19,12 +24,248 @@ static bool s_file_mode = false; static int s_legacy = -1; // legacy probe cache: -1 undecided, 0 SD, 1 NVS static int s_migrate = -1; // -1 undecided, 0 = no NVS legacy data, 1 = yes +static constexpr uint32_t PREFS_MAGIC = 0x32504657u; // "WFP2" +static constexpr uint16_t PREFS_FORMAT = 1; +static constexpr uint32_t PREFS_QUIET_MS = 750; +static constexpr uint32_t PREFS_MAX_WAIT_MS = 5000; +// These are settings namespaces, not a general object store. A hard ceiling +// bounds the RAM snapshot + worker copy on boards without PSRAM; current touch +// and discovered payloads are both well below 16 KB. +static constexpr size_t PREFS_MAX_PAYLOAD = 64u * 1024u; + +struct __attribute__((packed)) SnapshotFooter { + uint32_t magic; + uint16_t format; + uint16_t footer_size; + uint32_t generation; + uint32_t payload_size; + uint32_t payload_crc; + uint32_t footer_crc; +}; + +struct FileCache { + fs::FS* fs = nullptr; + char legacy_path[40] = {0}; + std::vector<SdNvsPrefs::Kv> kv; + uint32_t generation = 0; + uint32_t revision = 0; + uint32_t dirty_first_ms = 0; + uint32_t dirty_last_ms = 0; + uint32_t retry_after_ms = 0; + char active_slot = 0; + bool loaded = false; + bool dirty = false; + bool writing = false; + bool legacy_present = false; +}; + +struct WriteJob { + fs::FS* fs = nullptr; + char legacy_path[40] = {0}; + std::vector<SdNvsPrefs::Kv> kv; + uint32_t generation = 0; + uint32_t revision = 0; + char slot = 'a'; +}; + +static std::vector<FileCache> s_caches; +static SemaphoreHandle_t s_cache_mutex = nullptr; +static QueueHandle_t s_write_queue = nullptr; +static TaskHandle_t s_write_task = nullptr; + +static uint32_t crc32Update(uint32_t crc, const uint8_t* data, size_t len) { + crc = ~crc; + while (len--) { + crc ^= *data++; + for (int bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + return ~crc; +} + +static uint32_t crc32(const void* data, size_t len) { + return crc32Update(0, static_cast<const uint8_t*>(data), len); +} + +static bool slotPath(const char* legacy_path, char slot, char* out, size_t cap) { + if (!legacy_path || !out || cap == 0) return false; + if (slot == 'a') return snprintf(out, cap, "%s", legacy_path) < (int)cap; + const size_t n = strlen(legacy_path); + if (n >= 3 && strcmp(legacy_path + n - 3, ".kv") == 0) + return snprintf(out, cap, "%.*s.alt", (int)(n - 3), legacy_path) < (int)cap; + return snprintf(out, cap, "%s.alt", legacy_path) < (int)cap; +} + +static bool serializeKv(const std::vector<SdNvsPrefs::Kv>& kv, std::vector<uint8_t>& out) { + size_t total = 0; + for (const auto& e : kv) { + const size_t kl = strnlen(e.key, sizeof(e.key)); + if (kl == 0 || kl > 15 || e.val.size() > 2048) return false; + total += 1 + kl + 2 + e.val.size(); + if (total > PREFS_MAX_PAYLOAD) return false; + } + out.clear(); + out.reserve(total); + for (const auto& e : kv) { + const size_t kl = strnlen(e.key, sizeof(e.key)); + const size_t vl = e.val.size(); + out.push_back((uint8_t)kl); + out.insert(out.end(), e.key, e.key + kl); + out.push_back((uint8_t)(vl & 0xFF)); + out.push_back((uint8_t)((vl >> 8) & 0xFF)); + out.insert(out.end(), e.val.begin(), e.val.end()); + } + return true; +} + +static bool parsePayload(const uint8_t* data, size_t len, std::vector<SdNvsPrefs::Kv>& out) { + out.clear(); + size_t pos = 0; + while (pos < len) { + if (out.size() >= 256 || pos + 1 > len) return false; + const size_t kl = data[pos++]; + if (kl == 0 || kl > 15 || pos + kl + 2 > len) return false; + SdNvsPrefs::Kv e{}; + memcpy(e.key, data + pos, kl); + e.key[kl] = '\0'; + pos += kl; + const size_t vl = (size_t)data[pos] | ((size_t)data[pos + 1] << 8); + pos += 2; + if (vl > 2048 || pos + vl > len) return false; + e.val.assign(data + pos, data + pos + vl); + pos += vl; + out.push_back(std::move(e)); + } + return true; +} + +static bool readLegacyFile(fs::FS* fs, const char* path, std::vector<SdNvsPrefs::Kv>& out) { + if (!fs || !fs->exists(path)) return false; + File f = fs->open(path, FILE_READ); + if (!f) return false; + const size_t size = f.size(); + if (size > PREFS_MAX_PAYLOAD) { f.close(); return false; } + std::vector<uint8_t> payload(size); + const bool ok = (size == 0 || f.read(payload.data(), size) == (int)size); + f.close(); + return ok && parsePayload(payload.data(), payload.size(), out); +} + +static bool readSnapshot(fs::FS* fs, const char* path, std::vector<SdNvsPrefs::Kv>& out, + uint32_t& generation) { + if (!fs || !fs->exists(path)) return false; + File f = fs->open(path, FILE_READ); + if (!f) return false; + const size_t size = f.size(); + if (size < sizeof(SnapshotFooter) || size - sizeof(SnapshotFooter) > PREFS_MAX_PAYLOAD) { + f.close(); + return false; + } + const size_t payload_size = size - sizeof(SnapshotFooter); + std::vector<uint8_t> payload(payload_size); + SnapshotFooter footer{}; + bool ok = (payload_size == 0 || f.read(payload.data(), payload_size) == (int)payload_size) && + f.read((uint8_t*)&footer, sizeof(footer)) == (int)sizeof(footer); + f.close(); + if (!ok || footer.magic != PREFS_MAGIC || footer.format != PREFS_FORMAT || + footer.footer_size != sizeof(footer) || footer.payload_size != payload_size || + footer.payload_crc != crc32(payload.data(), payload.size()) || + footer.footer_crc != crc32(&footer, offsetof(SnapshotFooter, footer_crc))) return false; + if (!parsePayload(payload.data(), payload.size(), out)) return false; + generation = footer.generation; + return true; +} + +static bool writeSnapshot(fs::FS* fs, const char* legacy_path, char slot, uint32_t generation, + const std::vector<SdNvsPrefs::Kv>& kv) { + if (!fs) return false; + char path[44]; + if (!slotPath(legacy_path, slot, path, sizeof(path))) return false; + char dir[40]; + strncpy(dir, legacy_path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0'; + char* slash = strrchr(dir, '/'); + if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); } + + std::vector<uint8_t> payload; + if (!serializeKv(kv, payload)) return false; + SnapshotFooter footer{PREFS_MAGIC, PREFS_FORMAT, (uint16_t)sizeof(SnapshotFooter), + generation, (uint32_t)payload.size(), crc32(payload.data(), payload.size()), 0}; + footer.footer_crc = crc32(&footer, offsetof(SnapshotFooter, footer_crc)); + +#if defined(TDP4_POKE_TRACE) + printf("[SDW] %lu core%d prefs %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), path); +#endif + File f = fs->open(path, FILE_WRITE); + if (!f) return false; + bool ok = (payload.empty() || f.write(payload.data(), payload.size()) == payload.size()) && + f.write((const uint8_t*)&footer, sizeof(footer)) == sizeof(footer); + f.flush(); + f.close(); + if (!ok) return false; + + std::vector<SdNvsPrefs::Kv> verify; + uint32_t verify_generation = 0; + return readSnapshot(fs, path, verify, verify_generation) && verify_generation == generation; +} + +static bool newerGeneration(uint32_t lhs, uint32_t rhs) { + return (int32_t)(lhs - rhs) > 0; +} + +static FileCache* findCacheLocked(const char* path) { + for (auto& cache : s_caches) + if (strncmp(cache.legacy_path, path, sizeof(cache.legacy_path)) == 0) return &cache; + return nullptr; +} + +static void prefsWriterTask(void*) { + for (;;) { + WriteJob* job = nullptr; + if (xQueueReceive(s_write_queue, &job, portMAX_DELAY) != pdTRUE || !job) continue; + const uint32_t started = millis(); + const bool ok = writeSnapshot(job->fs, job->legacy_path, job->slot, job->generation, job->kv); + const uint32_t elapsed = millis() - started; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(job->legacy_path)) { + cache->writing = false; + if (ok) { + cache->generation = job->generation; + cache->active_slot = job->slot; + if (cache->revision == job->revision) cache->dirty = false; + cache->retry_after_ms = 0; + } else { + cache->dirty = true; + cache->dirty_first_ms = cache->dirty_last_ms = millis(); + cache->retry_after_ms = millis() + 30000; + Serial.printf("[PREFS] snapshot write failed %s.%c\n", job->legacy_path, job->slot); + } + } + xSemaphoreGive(s_cache_mutex); + if (ok && elapsed >= 200) + Serial.printf("[PREFS] slow snapshot %s slot %c: %lums\n", job->legacy_path, + job->slot, (unsigned long)elapsed); + delete job; + } +} + +static bool ensureWorker() { + if (!s_cache_mutex) s_cache_mutex = xSemaphoreCreateMutex(); + if (!s_write_queue) s_write_queue = xQueueCreate(2, sizeof(WriteJob*)); + if (!s_cache_mutex || !s_write_queue) return false; + if (!s_write_task) { + if (xTaskCreatePinnedToCore(prefsWriterTask, "prefs_writer", 6144, nullptr, 2, + &s_write_task, 0) != pdPASS) s_write_task = nullptr; + } + return s_write_task != nullptr; +} + void SdNvsPrefs::useFile(fs::FS* fs, const char* dir) { if (!fs) return; s_file_fs = fs; strncpy(s_file_dir, (dir && dir[0]) ? dir : "/prefs", sizeof(s_file_dir) - 1); s_file_dir[sizeof(s_file_dir) - 1] = '\0'; s_file_mode = true; + ensureWorker(); Serial.printf("[PREFS] backend = FILE %s\n", s_file_dir); } @@ -63,22 +304,11 @@ bool SdNvsPrefs::begin(const char* ns, bool readOnly) { if (_nvs_open) { _nvs.end(); _nvs_open = false; } if (fileMode()) { - char path[sizeof(_path)]; - snprintf(path, sizeof(path), "%s/%s.kv", s_file_dir, ns); - if (strncmp(_path, path, sizeof(_path)) != 0) { - strncpy(_path, path, sizeof(_path) - 1); - _path[sizeof(_path) - 1] = '\0'; - _sd.clear(); - _sd_loaded = false; - } - const bool had_cache = _sd_loaded; + if (!ns || snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns) >= (int)sizeof(_path)) + return false; const bool loaded = sdLoad(); - if (loaded) _sd_loaded = true; if (nvsHasLegacyData()) _nvs_open = _nvs.begin(ns, true); // RO migration source - // A transient storage outage must not turn a previously-loaded namespace - // into an empty one. Readers can keep using the last-known-good mirror; - // writers fail so callers do not report a save that never reached disk. - return loaded || (readOnly && had_cache); + return loaded; } if (useNvs()) { @@ -87,14 +317,8 @@ bool SdNvsPrefs::begin(const char* ns, bool readOnly) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; } - char path[sizeof(_path)]; - snprintf(path, sizeof(path), "/meshcomod/%s.kv", ns); - if (strncmp(_path, path, sizeof(_path)) != 0) { - strncpy(_path, path, sizeof(_path) - 1); - _path[sizeof(_path) - 1] = '\0'; - _sd.clear(); - _sd_loaded = false; - } + if (!ns || snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns) >= (int)sizeof(_path)) + return false; const bool had_cache = _sd_loaded; const bool loaded = sdLoad(); if (loaded) _sd_loaded = true; @@ -107,12 +331,49 @@ void SdNvsPrefs::end() { // ----------------------------- file helpers ----------------------------- SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) { + if (fileMode()) { + if (!s_cache_mutex) return nullptr; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + Kv* found = nullptr; + if (cache) { + for (auto& e : cache->kv) + if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } + } + xSemaphoreGive(s_cache_mutex); + return found; + } for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e; return nullptr; } bool SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { - if (!key || (len > 0 && !data)) return false; + if (_read_only || !key || key[0] == '\0' || strlen(key) > 15 || len > 2048 || + (len > 0 && !data)) return false; + if (fileMode()) { + if (!s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache || !cache->loaded) { xSemaphoreGive(s_cache_mutex); return false; } + Kv* e = nullptr; + for (auto& item : cache->kv) + if (strncmp(item.key, key, sizeof(item.key)) == 0) { e = &item; break; } + if (!e) { + if (cache->kv.size() >= 256) { xSemaphoreGive(s_cache_mutex); return false; } + cache->kv.push_back(Kv{}); + e = &cache->kv.back(); + strncpy(e->key, key, sizeof(e->key) - 1); + } + if (len == 0) e->val.clear(); else e->val.assign(data, data + len); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); + return true; + } Kv* e = sdFind(key); bool added = false; std::vector<uint8_t> old; @@ -136,154 +397,215 @@ bool SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) { bool SdNvsPrefs::sdLoad() { fs::FS* fs = activeFs(); if (!fs) return false; - - char tmp[sizeof(_path) + 5], bak[sizeof(_path) + 5]; - if (snprintf(tmp, sizeof(tmp), "%s.tmp", _path) >= (int)sizeof(tmp) || - snprintf(bak, sizeof(bak), "%s.bak", _path) >= (int)sizeof(bak)) return false; - - // No file on the first load is a valid, empty namespace. Once a namespace - // has been loaded, however, a missing file means the backing FS disappeared; - // retain the RAM mirror instead of silently replacing every key with defaults. - const bool have_live = fs->exists(_path); - const bool have_tmp = fs->exists(tmp); - const bool have_bak = fs->exists(bak); - if (!have_live && !have_tmp && !have_bak) { - if (_sd_loaded) return false; - _sd.clear(); + if (!fileMode()) { + // Legacy fallback before useFile(): retain the old single-file behaviour. + std::vector<Kv> loaded; + if (!fs->exists(_path)) { _sd.clear(); return true; } + if (!readLegacyFile(fs, _path, loaded)) return false; + _sd = std::move(loaded); return true; } - auto parse = [fs](const char* path, std::vector<Kv>& out) { - File f = fs->open(path, FILE_READ); - if (!f) return false; - bool ok = true; - // record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Parse into a - // temporary vector so a short/corrupt file cannot destroy the RAM mirror. - while (f.available() > 0) { - if (out.size() >= 256) { ok = false; break; } - int kl = f.read(); - if (kl <= 0 || kl > 15) { ok = false; break; } - char k[16] = {0}; - if (f.read((uint8_t*)k, kl) != kl) { ok = false; break; } - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) { ok = false; break; } - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) { ok = false; break; } - std::vector<uint8_t> v(vl); - if (vl && f.read(v.data(), vl) != (int)vl) { ok = false; break; } - Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0'; - e.val = std::move(v); - out.push_back(std::move(e)); - } - f.close(); + if (!ensureWorker()) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* existing = findCacheLocked(_path)) { + const bool ok = existing->loaded; + xSemaphoreGive(s_cache_mutex); return ok; - }; + } + xSemaphoreGive(s_cache_mutex); - // Prefer the live file. If a reset landed between the two renames, validate - // the verified temp next, then fall back to the previous complete backup. - std::vector<Kv> loaded; - if (have_live && parse(_path, loaded)) { - if (have_tmp) fs->remove(tmp); - if (have_bak) fs->remove(bak); - _sd = std::move(loaded); - return true; - } - loaded.clear(); - if (have_tmp && parse(tmp, loaded)) { - if (have_live && !fs->remove(_path)) return false; - if (!fs->rename(tmp, _path)) return false; - if (have_bak) fs->remove(bak); - _sd = std::move(loaded); - return true; - } - loaded.clear(); - if (have_bak && parse(bak, loaded)) { - if (have_live && !fs->remove(_path)) return false; - if (have_tmp) fs->remove(tmp); - if (!fs->rename(bak, _path)) return false; - _sd = std::move(loaded); - return true; + char path_a[44], path_b[44]; + if (!slotPath(_path, 'a', path_a, sizeof(path_a)) || + !slotPath(_path, 'b', path_b, sizeof(path_b))) return false; + std::vector<Kv> a, b, loaded; + uint32_t gen_a = 0, gen_b = 0; + const bool ok_a = readSnapshot(fs, path_a, a, gen_a); + const bool ok_b = readSnapshot(fs, path_b, b, gen_b); + uint32_t generation = 0; + char active = 0; + bool legacy_present = false; + bool migrate = false; + if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) { + loaded = std::move(a); generation = gen_a; active = 'a'; + } else if (ok_b) { + loaded = std::move(b); generation = gen_b; active = 'b'; + } else { + // Migrate the released single-file format. Parsing is all-or-nothing; + // defaults never replace a corrupt namespace. + if (readLegacyFile(fs, _path, loaded)) { + legacy_present = true; + migrate = true; + } else if (fs->exists(_path) || fs->exists(path_a) || fs->exists(path_b)) { + Serial.printf("[PREFS] no valid snapshot for %s\n", _path); + return false; + } + // No file at all is a valid fresh namespace. } - Serial.printf("[PREFS] refusing partial/corrupt file %s\n", _path); - if (_sd_loaded) { - // Keep the last-known-good mirror when a mounted filesystem becomes - // temporarily unreadable or exposes an incomplete file. - return false; + FileCache cache; + cache.fs = fs; + strncpy(cache.legacy_path, _path, sizeof(cache.legacy_path) - 1); + cache.kv = std::move(loaded); + cache.generation = generation; + cache.active_slot = active; + cache.loaded = true; + cache.legacy_present = legacy_present; + if (migrate) { + cache.dirty = true; + cache.dirty_first_ms = cache.dirty_last_ms = millis(); + ++cache.revision; } - _sd.clear(); - return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + s_caches.push_back(std::move(cache)); + xSemaphoreGive(s_cache_mutex); + return true; } bool SdNvsPrefs::sdSave() { if (_read_only) return false; + if (fileMode()) return true; // sdSet/remove/clear already dirtied the shared cache fs::FS* fs = activeFs(); if (!fs) return false; - // Create the parent dir (SD needs it; SPIFFS is flat and treats the whole path - // as a filename, so mkdir is a harmless no-op there). - char dir[40]; - strncpy(dir, _path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0'; - char* slash = strrchr(dir, '/'); - if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); } -#if defined(TDP4_POKE_TRACE) - printf("[SDW] %lu core%d kv w %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), _path); -#endif - - char tmp[sizeof(_path) + 5], bak[sizeof(_path) + 5]; - if (snprintf(tmp, sizeof(tmp), "%s.tmp", _path) >= (int)sizeof(tmp) || - snprintf(bak, sizeof(bak), "%s.bak", _path) >= (int)sizeof(bak)) return false; - fs->remove(tmp); // stale pre-swap temp; the intact live file remains authoritative - - errno = 0; - File f = fs->open(tmp, FILE_WRITE); - if (!f) { - Serial.printf("[PREFS] save open failed %s (e%d)\n", tmp, errno); - return false; - } - bool ok = true; - size_t expected = 0; - for (auto& e : _sd) { - size_t kl = strnlen(e.key, sizeof(e.key)); - size_t vl = e.val.size(); - expected += 1 + kl + 2 + vl; - ok = ok && (f.write((uint8_t)kl) == 1); - ok = ok && (f.write((const uint8_t*)e.key, kl) == kl); - ok = ok && (f.write((uint8_t)(vl & 0xFF)) == 1); - ok = ok && (f.write((uint8_t)((vl >> 8) & 0xFF)) == 1); - if (vl) ok = ok && (f.write(e.val.data(), vl) == vl); - if (!ok) break; - } + std::vector<uint8_t> payload; + if (!serializeKv(_sd, payload)) return false; + File f = fs->open(_path, FILE_WRITE); + if (!f) return false; + const bool ok = payload.empty() || f.write(payload.data(), payload.size()) == payload.size(); f.close(); + return ok; +} - // A short write leaves only the temp damaged. Verify the closed file size too - // before touching the live copy (catches buffered close/flush failures). - if (ok) { - File verify = fs->open(tmp, FILE_READ); - ok = verify && verify.size() == expected; - if (verify) verify.close(); +static bool scheduleOne(uint32_t now, bool force) { + if (!fileMode() || !ensureWorker()) return false; + WriteJob* job = nullptr; + char armed_path[40] = {0}; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (auto& cache : s_caches) { + if (!cache.loaded || !cache.dirty || cache.writing) continue; + if (!force && cache.retry_after_ms && (int32_t)(now - cache.retry_after_ms) < 0) continue; + const bool due = force || (uint32_t)(now - cache.dirty_last_ms) >= PREFS_QUIET_MS || + (uint32_t)(now - cache.dirty_first_ms) >= PREFS_MAX_WAIT_MS; + if (!due) continue; + job = new (std::nothrow) WriteJob(); + if (!job) break; + job->fs = cache.fs; + strncpy(job->legacy_path, cache.legacy_path, sizeof(job->legacy_path) - 1); + job->kv = cache.kv; + job->generation = cache.generation + 1; + job->revision = cache.revision; + job->slot = cache.active_slot == 'a' ? 'b' + : cache.active_slot == 'b' ? 'a' + : cache.legacy_present ? 'b' : 'a'; + cache.writing = true; + strncpy(armed_path, cache.legacy_path, sizeof(armed_path) - 1); + break; } - if (!ok) { - Serial.printf("[PREFS] save write failed %s (e%d)\n", tmp, errno); - fs->remove(tmp); + xSemaphoreGive(s_cache_mutex); + if (!job) return false; + if (xQueueSend(s_write_queue, &job, 0) != pdTRUE) { + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(armed_path)) cache->writing = false; + xSemaphoreGive(s_cache_mutex); + delete job; return false; } + return true; +} - // Two-step swap with recovery breadcrumbs. At every point either the old - // live file, the verified temp, or the backup is complete; sdLoad() resolves - // leftovers after a reset between renames. - const bool had_live = fs->exists(_path); - if (fs->exists(bak)) fs->remove(bak); - if (had_live && !fs->rename(_path, bak)) { - Serial.printf("[PREFS] save backup rename failed %s (e%d)\n", _path, errno); - fs->remove(tmp); - return false; +void SdNvsPrefs::tick(uint32_t now_ms) { + if (!fileMode()) return; + if (now_ms == 0) now_ms = millis(); + scheduleOne(now_ms, false); +} + +bool SdNvsPrefs::flush(uint32_t timeout_ms) { + if (!fileMode()) return true; + const uint32_t start = millis(); + for (;;) { + scheduleOne(millis(), true); + bool pending = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) + if (cache.dirty || cache.writing) { pending = true; break; } + xSemaphoreGive(s_cache_mutex); + if (!pending) return true; + if ((uint32_t)(millis() - start) >= timeout_ms) return false; + delay(10); } - if (!fs->rename(tmp, _path)) { - Serial.printf("[PREFS] save commit rename failed %s (e%d)\n", _path, errno); - if (had_live) fs->rename(bak, _path); // best-effort live rollback - return false; +} + +bool SdNvsPrefs::busy() { + if (!fileMode() || !s_cache_mutex) return false; + bool result = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) + if (cache.writing) { result = true; break; } + xSemaphoreGive(s_cache_mutex); + return result; +} + +static bool explicitNamespacePath(const char* dir, const char* ns, char* out, size_t cap) { + if (!dir || !ns || !out) return false; + return snprintf(out, cap, "%s/%s.kv", dir, ns) < (int)cap; +} + +static bool loadExplicitNamespace(fs::FS* fs, const char* legacy_path, + std::vector<SdNvsPrefs::Kv>& kv, + uint32_t& generation, char& active) { + char path_a[44], path_b[44]; + if (!slotPath(legacy_path, 'a', path_a, sizeof(path_a)) || + !slotPath(legacy_path, 'b', path_b, sizeof(path_b))) return false; + std::vector<SdNvsPrefs::Kv> a, b; + uint32_t gen_a = 0, gen_b = 0; + const bool ok_a = readSnapshot(fs, path_a, a, gen_a); + const bool ok_b = readSnapshot(fs, path_b, b, gen_b); + if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) { + kv = std::move(a); generation = gen_a; active = 'a'; return true; } - if (had_live) fs->remove(bak); + if (ok_b) { kv = std::move(b); generation = gen_b; active = 'b'; return true; } + generation = 0; + active = 0; + return readLegacyFile(fs, legacy_path, kv); +} + +bool SdNvsPrefs::readFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool& value) { + char legacy_path[40]; + if (!fs || !key || !explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false; + std::vector<Kv> kv; + uint32_t generation = 0; + char active = 0; + if (!loadExplicitNamespace(fs, legacy_path, kv, generation, active)) return false; + for (const auto& e : kv) { + if (strncmp(e.key, key, sizeof(e.key)) != 0) continue; + if (e.val.empty()) return false; + value = e.val[0] != 0; + return true; + } + return false; +} + +bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool value) { + char legacy_path[40]; + if (!fs || !key || key[0] == '\0' || strlen(key) > 15 || + !explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false; + std::vector<Kv> kv; + uint32_t generation = 0; + char active = 0; + const bool existed = loadExplicitNamespace(fs, legacy_path, kv, generation, active); + Kv* found = nullptr; + for (auto& e : kv) + if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } + if (!found) { + kv.push_back(Kv{}); + found = &kv.back(); + strncpy(found->key, key, sizeof(found->key) - 1); + } + found->val.assign(1, value ? 1 : 0); + const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : existed ? 'b' : 'a'; + if (!writeSnapshot(fs, legacy_path, slot, generation + 1, kv)) return false; return true; } @@ -314,6 +636,26 @@ bool SdNvsPrefs::isKey(const char* key) { bool SdNvsPrefs::remove(const char* key) { if (!fileMode() && useNvs()) return _nvs_open && _nvs.remove(key); + if (fileMode()) { + if (_read_only || !s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache) { xSemaphoreGive(s_cache_mutex); return false; } + for (size_t i = 0; i < cache->kv.size(); ++i) { + if (strncmp(cache->kv[i].key, key, sizeof(cache->kv[i].key)) != 0) continue; + cache->kv.erase(cache->kv.begin() + i); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); + return true; + } + xSemaphoreGive(s_cache_mutex); + return false; + } for (size_t i = 0; i < _sd.size(); i++) { if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) != 0) continue; Kv old = _sd[i]; @@ -327,9 +669,18 @@ bool SdNvsPrefs::remove(const char* key) { bool SdNvsPrefs::clear() { if (fileMode()) { - std::vector<Kv> old = _sd; - _sd.clear(); - if (!sdSave()) { _sd = std::move(old); return false; } + if (_read_only || !s_cache_mutex) return false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + FileCache* cache = findCacheLocked(_path); + if (!cache) { xSemaphoreGive(s_cache_mutex); return false; } + cache->kv.clear(); + const uint32_t now = millis(); + if (!cache->dirty) cache->dirty_first_ms = now; + cache->dirty = true; + cache->dirty_last_ms = now; + cache->retry_after_ms = 0; + ++cache->revision; + xSemaphoreGive(s_cache_mutex); // Wipe any legacy NVS copy too, so a factory reset is permanent. Derive the // namespace from "<dir>/<ns>.kv". if (nvsHasLegacyData()) { diff --git a/src/helpers/esp32/SdNvsPrefs.h b/src/helpers/esp32/SdNvsPrefs.h index fbf611c..0284078 100644 --- a/src/helpers/esp32/SdNvsPrefs.h +++ b/src/helpers/esp32/SdNvsPrefs.h @@ -11,12 +11,12 @@ namespace fs { class FS; } // forward decl only — keep this header's layout // keep the touch app's settings OFF NVS. // // File mode (set by SdNvsPrefs::useFile() once at boot, after the SD/SPIFFS -// storage decision): every namespace lives in a flat <dir>/<ns>.kv file on the -// chosen filesystem — SD (/meshcomod) when a card is the active data store, else -// SPIFFS (/prefs). Nothing new is written to NVS; NVS is read-only, used only to -// migrate settings written by an older NVS build (they move to the file on their -// next save). Legacy mode (before useFile, e.g. the early boot-rotation read): -// NVS if it works, else /meshcomod/<ns>.kv on SD — the previous behaviour. +// storage decision): every namespace is held in RAM and committed to alternating +// <dir>/<ns>.kv / .alt snapshots. Setters only dirty the RAM copy; a worker writes +// the inactive slot after a short quiet period, keeping filesystem latency off +// the UI task. Each snapshot has a generation and CRC, so a reset can only lose +// the newest pending update — it cannot turn an interrupted write into defaults. +// Legacy <dir>/<ns>.kv and NVS data are read for migration. // // IMPORTANT: the monorepo lib ships a STALE copy of this header that other // translation units may pick up via an angle include. To avoid an ODR/layout @@ -25,11 +25,20 @@ namespace fs { class FS; } // forward decl only — keep this header's layout // The file backend therefore uses file-static state in the .cpp, not a member. // Only the layout-neutral static useFile() is new. // -// On-disk file format is unchanged: [keylen u8][key][vallen u16 LE][val]. class SdNvsPrefs { public: static void useFile(fs::FS* fs, const char* dir); // route prefs to <dir>/<ns>.kv static fs::FS* fileFs(); // the active file-mode fs, or nullptr when NVS-backed + static void tick(uint32_t now_ms = 0); // schedule due snapshots; never blocks on I/O + static bool flush(uint32_t timeout_ms = 12000); // force all queued snapshots before reset/sleep + static bool busy(); // a worker currently owns a filesystem handle + + // Early boot needs `use_sd` before the regular backend is selected. These + // helpers use the same A/B format on an explicitly supplied filesystem. + static bool readFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool& value); + static bool writeFileBool(fs::FS* fs, const char* dir, const char* ns, + const char* key, bool value); bool begin(const char* ns, bool readOnly = false); void end(); @@ -57,6 +66,10 @@ public: size_t getBytes(const char* key, void* buf, size_t maxLen); size_t putBytes(const char* key, const void* buf, size_t len); + // Public only so the file-static snapshot worker can preserve this class's + // ABI while sharing namespace caches across short-lived instances. + struct Kv { char key[16]; std::vector<uint8_t> val; }; + private: // --- NVS-backed path --- (LAYOUT MUST MATCH the lib's stale header — see above) Preferences _nvs; @@ -64,7 +77,6 @@ private: bool _read_only = false; // --- file-backed path --- - struct Kv { char key[16]; std::vector<uint8_t> val; }; std::vector<Kv> _sd; // in-RAM mirror of <dir>/<ns>.kv char _path[40] = {0}; bool _sd_loaded = false; @@ -73,7 +85,7 @@ private: Kv* sdFind(const char* key); bool sdSet(const char* key, const uint8_t* data, size_t len); bool sdLoad(); - bool sdSave(); + bool sdSave(); // queue current RAM mirror (no filesystem I/O) uint64_t sdGetInt(const char* key, uint64_t def, int width); size_t sdPutInt(const char* key, uint64_t v, int width); }; diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 5dd014f..6a8083e 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -10,7 +10,6 @@ #include <SPIFFS.h> #include <stddef.h> // offsetof #include <string.h> // memcpy -#include <vector> // writeUseSdToSpiffsKv record list static const char* TOUCH_NS = "touch"; @@ -30,11 +29,10 @@ static bool s_begun = false; // keys too. "use_sd" is mirrored to NVS on every UI toggle (main.cpp reads it at // boot via touchPrefsReadUseSdAtBoot); "setup_ok" is still NVS-only. // -// On first run with the blob absent we read every legacy per-key into s_cfg, write -// "cfg" ONCE, and only after that durable write do we remove() the legacy keys to -// reclaim their entries. The write-before-remove ordering makes the migration -// crash-safe and idempotent (a power-cut before the write just re-migrates next -// boot; one after it finds "cfg" present and skips). `magic` rejects a garbage / +// On first run with the blob absent we read every legacy per-key into s_cfg, add +// "cfg", and remove the superseded file keys in the same queued RAM snapshot. +// The released .kv remains the fallback until that complete A/B snapshot verifies, +// making the migration crash-safe and idempotent. `magic` rejects a garbage / // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) @@ -215,8 +213,8 @@ static void cfgSetDefaults(TouchCfg& c) { c.hist_sync_after = 2; // chat flush: 2 failed background writes -> synchronous loop-task fallback } -// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) -// discipline every setter in this file uses. Returns true on a durable write. +// Update the whole blob using the same end()/begin(RW)/put/end()/begin(RO) +// discipline every setter in this file uses. File mode queues a coalesced write. static bool cfgFlush() { s_prefs.end(); if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = false; return false; } @@ -327,9 +325,8 @@ static void cfgLoadOrMigrate() { } } - // Write "cfg" ONCE. Only after a durable write do we reclaim the legacy keys. - // If the write fails (e.g. NVS full / SD missing) we keep the legacy keys - // intact and retry the whole migration on the next boot. + // Add "cfg" and retire the old file keys in one RAM transaction. The A/B + // worker commits the complete result; until then the released .kv is intact. if (cfgFlush()) { s_prefs.end(); if (s_prefs.begin(TOUCH_NS, false)) { @@ -377,6 +374,10 @@ void touchPrefsReload() { touchPrefsBegin(); } +void touchPrefsTick(uint32_t now_ms) { SdNvsPrefs::tick(now_ms); } +bool touchPrefsFlush(uint32_t timeout_ms) { return SdNvsPrefs::flush(timeout_ms); } +bool touchPrefsIoBusy() { return SdNvsPrefs::busy(); } + // Arduino's Preferences::getString()/getBytes() emit an [E] nvs_get_* "NOT_FOUND" // log every time a key is absent — which floods the (USB-CDC) console on a fresh // device and on every empty Wi-Fi-slot read. isKey() (getType → raw nvs probes) @@ -1234,36 +1235,23 @@ bool touchPrefsSetHomeIsDrawer(bool on) { // the data loads, so changing it needs a reboot. Key "use_sd" in the "touch" // namespace — main.cpp must see the same value the UI toggle writes. static const char* KEY_USE_SD_STORAGE = "use_sd"; -static const char* TOUCH_KV_BOOT_PATH = "/prefs/touch.kv"; - -// SdNvsPrefs file mode writes touch.kv only — parse a bool for boot migration. -static bool readBoolFromTouchKvFile(const char* want_key) { - if (!SPIFFS.exists(TOUCH_KV_BOOT_PATH)) return false; - File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ); - if (!f) return false; - while (f.available() > 0) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - char k[16] = {0}; - if (f.read((uint8_t*)k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - if (strncmp(k, want_key, sizeof k) == 0) { - const bool on = (vl >= 1) && (f.read() != 0); - f.close(); - return on; - } - for (size_t i = 0; i < vl; ++i) { - if (f.read() < 0) { f.close(); return false; } - } - } - f.close(); - return false; -} +static const char* BOOT_PREFS_NS = "bootprefs"; bool touchPrefsReadUseSdAtBoot() { + bool file_val = false; + bool found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS, + KEY_USE_SD_STORAGE, file_val); + // Once present, the explicit boot namespace is authoritative. This lets a + // Launcher device recover even if its best-effort NVS mirror is stale. + if (found) { + Preferences mirror; + if (mirror.begin(TOUCH_NS, false)) { + mirror.putBool(KEY_USE_SD_STORAGE, file_val); + mirror.end(); + } + return file_val; + } + bool nvs_val = false; Preferences p; if (p.begin(TOUCH_NS, true)) { @@ -1271,15 +1259,17 @@ bool touchPrefsReadUseSdAtBoot() { p.end(); } if (nvs_val) return true; - const bool file_val = readBoolFromTouchKvFile(KEY_USE_SD_STORAGE); - if (file_val) { - Serial.println("[BOOT] use_sd read from /prefs/touch.kv (syncing to NVS)"); + // One-time migration for builds that stored the boot flag inside touch.kv. + found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", TOUCH_NS, + KEY_USE_SD_STORAGE, file_val); + if (found && file_val) { + Serial.println("[BOOT] use_sd read from SPIFFS boot prefs (syncing to NVS)"); if (p.begin(TOUCH_NS, false)) { p.putBool(KEY_USE_SD_STORAGE, true); p.end(); } } - return file_val; + return found && file_val; } bool touchPrefsGetUseSdStorage() { @@ -1287,55 +1277,6 @@ bool touchPrefsGetUseSdStorage() { return s_prefs.getBool(KEY_USE_SD_STORAGE, false); // default = SPIFFS } -// Rewrite (or create) the SPIFFS copy of touch.kv with use_sd set, keeping every -// other record. Needed because the boot storage decision can only read SPIFFS -// (SD isn't mounted yet, see touchPrefsReadUseSdAtBoot): when prefs actively -// live on the SD card, the toggle must land in BOTH file copies — otherwise -// installs with unusable NVS (Launcher) could turn SD storage on but never OFF -// again (the stale SPIFFS true would win every boot). PR #123 follow-up. -static void writeUseSdToSpiffsKv(bool on) { - struct Rec { char k[16]; std::vector<uint8_t> v; }; - std::vector<Rec> recs; - File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ); - if (f) { - while (f.available() > 0 && recs.size() < 256) { - int kl = f.read(); - if (kl <= 0 || kl > 15) break; - Rec r{}; - if (f.read((uint8_t*)r.k, kl) != kl) break; - int lo = f.read(), hi = f.read(); - if (lo < 0 || hi < 0) break; - size_t vl = (size_t)lo | ((size_t)hi << 8); - if (vl > 2048) break; - r.v.resize(vl); - if (vl && f.read(r.v.data(), vl) != (int)vl) break; - recs.push_back(std::move(r)); - } - f.close(); - } - bool found = false; - for (auto& r : recs) { - if (strncmp(r.k, KEY_USE_SD_STORAGE, sizeof r.k) == 0) { r.v.assign(1, on ? 1 : 0); found = true; } - } - if (!found) { - Rec r{}; - strncpy(r.k, KEY_USE_SD_STORAGE, sizeof r.k - 1); - r.v.assign(1, on ? 1 : 0); - recs.push_back(std::move(r)); - } - File w = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_WRITE); // truncate + rewrite - if (!w) return; - for (auto& r : recs) { - size_t kl = strnlen(r.k, sizeof r.k), vl = r.v.size(); - w.write((uint8_t)kl); - w.write((const uint8_t*)r.k, kl); - w.write((uint8_t)(vl & 0xFF)); - w.write((uint8_t)((vl >> 8) & 0xFF)); - if (vl) w.write(r.v.data(), vl); - } - w.close(); -} - bool touchPrefsSetUseSdStorage(bool use_sd) { if (!s_begun) touchPrefsBegin(); s_prefs.end(); @@ -1351,11 +1292,12 @@ bool touchPrefsSetUseSdStorage(bool use_sd) { nvs.putBool(KEY_USE_SD_STORAGE, use_sd); nvs.end(); } - // When prefs live on the SD card, boot's fallback still reads the SPIFFS - // copy — keep it in sync so the toggle works in BOTH directions there. - fs::FS* ffs = SdNvsPrefs::fileFs(); - if (ffs && ffs != (fs::FS*)&SPIFFS) writeUseSdToSpiffsKv(use_sd); - return ok; + // The boot storage decision happens before SD is mounted. Keep a tiny, + // crash-safe SPIFFS A/B namespace as the Launcher-safe fallback regardless + // of where the rest of the preferences currently live. + const bool boot_ok = SdNvsPrefs::writeFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS, + KEY_USE_SD_STORAGE, use_sd); + return ok && boot_ok; } // UI language index (UiLang enum in i18n.h; 0 = English). Read at boot to pick diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index aac04cb..14fc008 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -13,6 +13,11 @@ void touchPrefsBegin(); // Force a fresh load of the settings blob. Call after SdNvsPrefs::useFile() at // boot: an earlier pref read may have cached the blob from the legacy backend. void touchPrefsReload(); +// Drive/force the deferred file-backed snapshot writer without exposing the +// vendored core's stale SdNvsPrefs header to UI translation units. +void touchPrefsTick(uint32_t now_ms); +bool touchPrefsFlush(uint32_t timeout_ms = 12000); +bool touchPrefsIoBusy(); /** Screen timeout in seconds; 0 = never sleep. Default 20. */ uint16_t touchPrefsGetScreenTimeoutSecs(); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 1deae3d..7d20ad3 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1807,13 +1807,16 @@ static void purgeDiscovered() { for (int i = 0; i < DISCOVERED_MAX; ++i) s_discovered[i].used = false; } -// ---- Persist the discovered ring across reboots (NVS blob "disc") ---- +// ---- Persist the discovered ring across reboots --------------------------- +// High-churn discovery data has its own namespace so an advert burst never +// rewrites the critical touch settings snapshot. static const char* KDISC = "disc"; // chunk 0 (older firmware wrote ≤24 records here) +static const char* DISC_NS = "discovered"; +static const char* DISC_MARKER = "ver"; static bool s_disc_dirty = false; static unsigned long s_disc_save_at = 0; -// Compact on-disk record (84 B/entry). SdNvsPrefs's SD .kv backend (used under -// Launcher) silently DROPS any single value > 2048 B on load (sdLoad bails on -// the oversize record), so we cap each stored blob at DISC_PER_CHUNK records +// Compact on-disk record (84 B/entry). SdNvsPrefs caps one value at 2048 B, so +// we cap each stored blob at DISC_PER_CHUNK records // (2 + 24*84 = 2018 B) and split the ring across as many chunk keys ("disc", // "disc1", …) as DISCOVERED_MAX needs. We persist only what the Discovered list // redraws — the bulky parts of ContactInfo (out_path[64], shared_secret, …) are @@ -1837,6 +1840,9 @@ static void discChunkKey(int chunk, char* out, size_t cap) { } static void saveDiscovered() { if (!s_discovered || !discBlob()) return; + SdNvsPrefs prefs; + if (!prefs.begin(DISC_NS, false)) return; + prefs.putUChar(DISC_MARKER, DISC_BLOB_VER); int src = 0; // walk the ring once, emitting used entries into successive chunk blobs for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { uint8_t* p = s_disc_blob; @@ -1860,18 +1866,23 @@ static void saveDiscovered() { } *pcount = count; char key[8]; discChunkKey(chunk, key, sizeof key); - if (count > 0) touchPrefsSetBlob(key, s_disc_blob, (size_t)(p - s_disc_blob)); - else touchPrefsSetBlob(key, nullptr, 0); // clear an empty trailing chunk + if (count > 0) prefs.putBytes(key, s_disc_blob, (size_t)(p - s_disc_blob)); + else prefs.remove(key); // clear an empty trailing chunk } + prefs.end(); s_disc_dirty = false; } static void loadDiscovered() { if (!s_discovered || !discBlob()) return; memset(s_discovered, 0, sizeof(LvDiscoveredEntry) * DISCOVERED_MAX); + SdNvsPrefs prefs; + const bool prefs_open = prefs.begin(DISC_NS, true); + const bool have_new = prefs_open && prefs.isKey(DISC_MARKER); int dst = 0; for (int chunk = 0; chunk < DISC_CHUNKS && dst < DISCOVERED_MAX; ++chunk) { char key[8]; discChunkKey(chunk, key, sizeof key); - size_t got = touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ); + size_t got = have_new ? prefs.getBytes(key, s_disc_blob, DISC_BLOB_SZ) + : touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ); if (got < 2 || s_disc_blob[0] != DISC_BLOB_VER) continue; // missing / garbage chunk -> skip uint8_t count = s_disc_blob[1]; const uint8_t* p = s_disc_blob + 2; @@ -1894,6 +1905,20 @@ static void loadDiscovered() { e.ci.shared_secret_valid = false; // force secret recompute on first use } } + prefs.end(); + if (!have_new) { + // First boot after the split: queue the new namespace but leave legacy + // chunks intact until a later boot proves the new snapshot is durable. + saveDiscovered(); + } else { + // The separate snapshot survived a reboot, so the old copies are now safe + // to retire from the critical touch namespace. + for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) { + char key[8]; discChunkKey(chunk, key, sizeof key); + if (touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ) > 0) + touchPrefsSetBlob(key, nullptr, 0); + } + } } // Coalesce a burst of adverts into one write ~20 s later (flash-churn guard). static void markDiscoveredDirty() { s_disc_dirty = true; s_disc_save_at = millis() + 20000; } @@ -34550,6 +34575,7 @@ static void doFactoryReset() { lv_refr_now(nullptr); delay(150); // let the overlay actually hit the panel wdtHeavyBegin(); // SPIFFS format is a long flash burst + touchPrefsFlush(); // do not format under an open prefs worker handle #if CAP_SD factoryWipeSdData(); // SD /meshcomod data (tiles at /tiles root are kept) #endif @@ -35779,6 +35805,7 @@ static void powerOffCb(lv_event_t* e) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh + touchPrefsFlush(); // and all queued A/B preference snapshots g_lv.task->showAlert(TR("Powering off\xE2\x80\xA6 click trackball to wake"), 1500); } // Let the toast paint, then enter deep sleep. @@ -35827,6 +35854,7 @@ static void powerDownloadCb(lv_event_t* e) { if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring + touchPrefsFlush(); // and all queued A/B preference snapshots g_lv.task->showAlert(TR("Download mode\xE2\x80\xA6 reflash over USB"), 1500); } lv_refr_now(NULL); @@ -45765,6 +45793,7 @@ void UITask::rebootDevice() { } discoveredFlushNow(); // persist the Discovered ring before we go down the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices) + touchPrefsFlush(); // finish queued A/B snapshots before reset if (_board) _board->reboot(); } @@ -46128,7 +46157,7 @@ static void sdHealthTick() { // Hold off while the core-0 worker is (or may be about to be) on the card — // SD.end() under an open worker file handle is the crash we must not add. // On a truly wedged card the worker's own ops fail fast, so this clears. - if (s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request) return; + if (s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request || touchPrefsIoBusy()) return; #if defined(HAS_TDECK_GT911) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA if (s_sdfw_request) return; // worker streaming a firmware download to /BINS (T-Deck only) #endif @@ -46174,6 +46203,10 @@ static void sdHealthTick() { void UITask::loop() { unsigned long now = millis(); +#if defined(ESP32) + // Snapshot copying is quick; all filesystem I/O runs on the core-0 worker. + touchPrefsTick(now); +#endif #if !defined(HAS_TANMATSU) // REMOTE mode: draw/refresh the physical-panel placeholder (first pass via the IP // sentinel, then whenever the IP changes), and clear the bootloop guard once this @@ -46219,9 +46252,8 @@ void UITask::loop() { #endif g_ui_stall_max = 0; g_ui_stall_tag = ""; s_ui_cp_tag = "ui:head"; s_ui_cp_t0 = now; #if defined(ESP32) - // Persist the clock floor every 15 min (small prefs-blob write; SdNvsPrefs - // rewrites a file on the Launcher-SD backend, so keep the cadence low). Power - // loss costs at most this window of floor progress — a soft reset keeps the + // Persist the clock floor every 15 min (coalesced into the next prefs snapshot). + // Power loss costs at most this window of floor progress — a soft reset keeps the // ESP32 RTC domain ticking, so only true power-off needs the persisted copy. { static unsigned long s_floor_due = 0; if (now >= s_floor_due) { @@ -47173,7 +47205,8 @@ void UITask::shutdown(bool restart) { if (_threads_dirty) saveThreadsToStorage(); if (_msgs_dirty) saveMsgsToStorage(); #if defined(ESP32) - touchPrefsSetClockFloor(rtc_clock.getFloor()); // clock floor: final synchronous save (#89) + touchPrefsSetClockFloor(rtc_clock.getFloor()); // queue final clock-floor update (#89) + touchPrefsFlush(); // finish queued A/B snapshots #endif if (_display) { _display->startFrame(); From cdd2f21cbb0b9d6583b7abf74615256aba3eabf0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 09:59:03 +0200 Subject: [PATCH 74/99] =?UTF-8?q?touch:=20V4-R8=20buzzer=20=E2=80=94=20wir?= =?UTF-8?q?e=20the=20Expansion=20Kit=20V2's=20PAM8904=20DIN=20(GPIO4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The R8 env shipped HELTEC_V4_BUZZER_PIN=-1 because the Kit V2's PAM8904 wiring was unconfirmed, so R8 users have never heard a notification. Heltec's Expansion_board_V2.03.pdf schematic settles it: DIN = GPIO4, and EN1/EN2 are hardware-strapped high (10K pull-ups, 3x boost) with DIP switch SW1 as a hardware mute -- no enable code needed, the stock V4 tone() chime drives DIN directly. GPIO4 is unclaimed on the R8 (board I2C moved to 17/18). Tester-verify: chime on message. If still silent, SW1 on the expansion board is the hardware mute -- check it is not switched to ground. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- platformio.ini | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index dc10360..d2d757a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -272,7 +272,12 @@ build_flags = -D PIN_BOARD_SDA=17 -D PIN_BOARD_SCL=18 -D PIN_SD_CS=3 - -D HELTEC_V4_BUZZER_PIN=-1 + ; Buzzer: the Expansion Kit V2 integrates a piezo behind a PAM8904 driver (U9). Per Heltec's + ; Expansion_board_V2.03.pdf schematic: DIN = net "4" = GPIO4 (P2 header pin 15); EN1/EN2 are + ; NOT GPIOs -- strapped high via 10K pull-ups (3x boost, always enabled) with DIP switch SW1 + ; on the expansion board as a HARDWARE MUTE (closed = EN grounded = silent). So the stock V4 + ; tone() chime works as-is on DIN. If a tester still hears nothing: check SW1 on their kit. + -D HELTEC_V4_BUZZER_PIN=4 -D FIRMWARE_OTA_ENV='"heltec_v4_r8_tft_companion_radio_usb_tcp_touch"' lib_deps = ${env:heltec_v4_tft_companion_radio_usb_tcp_touch.lib_deps} From c0d702a8a73dca6c9a8db0bb2a6912e831c07543 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 10:15:26 +0200 Subject: [PATCH 75/99] touch: control-centre GPS tile long-press opens the GPS page, not Radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale CAT_RADIO from before the settings reorg gave GPS its own category — every other tile already pointed at its own page. All boards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6fda6d7..2a51c89 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -36520,7 +36520,9 @@ static void openControlCenter() { #if !CAP_GPS ccToggle(row, LV_SYMBOL_GPS, "GPS", false, ccGpsNoneCb, tw, th, -1); // no onboard GPS — info-only, untoggable #else - ccToggle(row, LV_SYMBOL_GPS, "GPS", gps_on, ccGpsCb, tw, th, CAT_RADIO); + // Long-press opens the GPS settings PAGE. CAT_RADIO here was a leftover from before the + // settings reorg gave GPS its own category (the GPS block used to live under radio/device). + ccToggle(row, LV_SYMBOL_GPS, "GPS", gps_on, ccGpsCb, tw, th, CAT_GPS); #endif ccToggle(row, LV_SYMBOL_TINT, "Theme", false, ccThemeCb, tw, th, CAT_DISPLAY); #if CAP_KEYBOARD From 05f4ba65422b891fdec63c7381f1daf26124f1f9 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 02:14:24 -0700 Subject: [PATCH 76/99] feat(touch): add T-Pager UI size presets Signed-off-by: Pixel Perfect <me@pixp.cc> --- platformio.ini | 6 + src/helpers/esp32/TouchPrefsStore.cpp | 18 +- src/helpers/esp32/TouchPrefsStore.h | 4 +- src/ui-touch/UITask.cpp | 304 ++++++++++++++++++++++---- src/ui-touch/device_caps.h | 9 + 5 files changed, 294 insertions(+), 47 deletions(-) diff --git a/platformio.ini b/platformio.ini index dc10360..d0e59be 100644 --- a/platformio.ini +++ b/platformio.ini @@ -665,6 +665,9 @@ build_flags = ; --- T-LoRa Pager board --- -I variants/lilygo_tlora_pager -D TLORA_PAGER=1 + -D LV_FONT_MONTSERRAT_18=1 + -D LV_FONT_MONTSERRAT_20=1 + -D LV_FONT_MONTSERRAT_24=1 -D BOARD_HAS_PSRAM=1 ; 0 = silence ARDUHAL [E] spam, same rationale as the other two envs. -D CORE_DEBUG_LEVEL=0 @@ -887,6 +890,9 @@ build_flags = ; --- T-LoRa Pager board (SX1262 variant) --- -I variants/lilygo_tlora_pager -D TLORA_PAGER=1 + -D LV_FONT_MONTSERRAT_18=1 + -D LV_FONT_MONTSERRAT_20=1 + -D LV_FONT_MONTSERRAT_24=1 -D BOARD_HAS_PSRAM=1 ; 0 = silence ARDUHAL [E] spam, same rationale as the other envs. -D CORE_DEBUG_LEVEL=0 diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 5dd014f..82ec3c5 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -38,7 +38,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 42; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down) +static const uint8_t TOUCH_CFG_VER = 43; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -83,7 +83,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t map_show_tilexyz; // show the zoom + tile z/x/y line on the map (bool) — v6 uint8_t map_show_contacts;// show contact markers on the map (bool) — v6 uint8_t app_grid_large; // app drawer: large grid (one fewer column, bigger icons) — v7 - uint8_t ui_scale; // UI resolution scale: 0=100% 1=150% 2=200% (Tanmatsu, applied at boot) — v8 + uint8_t ui_scale; // UI-size preset 0..2 (board-specific font/geometry mapping, applied at boot) — v8 uint8_t kbd_nav; // T-Deck keyboard ESDFX nav: 0=off (default), 1=on (E/X/S/F move focus, D select, Q back) — v9 (was tb_keypad) uint8_t sleep_idle; // idle light-sleep feature on/off (bool) — v10 (trailing so existing blobs default it OFF) uint8_t nav_keys[5]; // keyboard-nav tab hotkeys (ASCII), one per main tab [chat,contacts,home,map,settings] — v11 (trailing) @@ -172,7 +172,11 @@ static void cfgSetDefaults(TouchCfg& c) { c.map_show_tilexyz = 1; c.map_show_contacts = 1; c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols) - c.ui_scale = 1; // default: 150% "Large" UI scale (Tanmatsu; S3 boards ignore this) +#if defined(TLORA_PAGER) + c.ui_scale = 0; // Pager: Small/current typography by default +#else + c.ui_scale = 1; // large-screen boards keep their existing 150% default +#endif #if defined(HAS_TANMATSU) c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on #else @@ -284,6 +288,12 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle) if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in) if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default +#if defined(TLORA_PAGER) + // ui_scale existed before the Pager exposed the selector, so every old + // Pager inherited the unrelated large-screen default (1) while ignoring + // it. Reset it once so upgrading cannot enlarge the UI without consent. + if (s_cfg.ver < 43) s_cfg.ui_scale = 0; +#endif s_cfg.ver = TOUCH_CFG_VER; s_cfg.magic = TOUCH_CFG_MAGIC; cfgFlush(); // rewrite with new fields defaulted-in @@ -1052,7 +1062,7 @@ bool touchPrefsSetAppGridLarge(bool on) { uint8_t touchPrefsGetUiScale() { if (!s_begun) touchPrefsBegin(); - return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; // 0=100% 1=150% 2=200% + return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; } bool touchPrefsSetUiScale(uint8_t scale) { if (!s_begun) touchPrefsBegin(); diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index aac04cb..885a028 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -146,8 +146,8 @@ bool touchPrefsSetAppGridLarge(bool on); bool touchPrefsGetSleepIdle(); bool touchPrefsSetSleepIdle(bool on); -/* UI resolution scale (Tanmatsu): 0=100% (native 800x480), 1=150%, 2=200%. Applied at boot — - * LVGL renders at a lower resolution and the flush upscales to the panel. Reboot to apply. */ +/* UI-size preset, 0..2. Large-screen boards retain their percentage mapping; + * the T-Pager exposes Small/Medium/Large font presets. Reboot to apply. */ uint8_t touchPrefsGetUiScale(); bool touchPrefsSetUiScale(uint8_t scale); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6fda6d7..4e7b8aa 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -552,8 +552,8 @@ static bool emojiImgfontPathCb(const lv_font_t* /*font*/, void* img_src, uint16_ static lv_font_t* s_emoji_font[3] = { nullptr, nullptr, nullptr }; // one per text size #endif -// UI scale (font-based, at NATIVE resolution so it stays crisp). 100 / 130 / 160 percent — set in -// initTouchFontFallbacks() from the saved pref. SC() scales a layout dimension to match. The bottom +// UI size (font-based, at native resolution so it stays crisp) is set in +// initTouchFontFallbacks() from the saved preset. SC() scales geometry where a board supports it. The bottom // tab bar is deliberately NOT scaled (g_font_tab stays 16 px + TABBAR_H is fixed) — by request it // stays the Normal size at every level. static int s_ui_fscale = 100; @@ -578,12 +578,35 @@ static inline lv_coord_t PCW(int px) { lv_coord_t cap = lv_disp_get_hor_res(nullptr) - SC(12); return (cap > 0 && w > cap) ? cap : w; } -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE static lv_font_t g_font_tab; // fixed 16 px tab-bar icon font (montserrat_16 + person glyph) #endif static void initTouchFontFallbacks() { -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // The Pager is wide but only 222 px tall. Grow the semantic text roles while + // leaving SC() at 100%; globally scaling every row/card made content + // unreachable. Layouts that need more room are bounded individually below. + s_ui_fscale = 100; + switch (touchPrefsGetUiScale()) { + case 1: + g_font_12 = lv_font_montserrat_16; + g_font_14 = lv_font_montserrat_18; + g_font_16 = lv_font_montserrat_20; + break; + case 2: + g_font_12 = lv_font_montserrat_18; + g_font_14 = lv_font_montserrat_20; + g_font_16 = lv_font_montserrat_24; + break; + default: + g_font_12 = lv_font_montserrat_12; + g_font_14 = lv_font_montserrat_14; + g_font_16 = lv_font_montserrat_16; + break; + } + g_font_tab = lv_font_montserrat_16; +#elif CAP_LARGE_SCREEN // Crisp "UI size": render bigger by swapping in larger built-in Montserrat fonts (NOT by // upscaling a low-res frame). g_font_12/14/16 are what the whole UI draws with, so this scales // every screen at once. The colour-emoji + non-Latin fallbacks stay their baked sizes (they don't @@ -653,7 +676,7 @@ static void initTouchFontFallbacks() { s_cc_icons_font = cc_icons_16; s_cc_icons_font.fallback = g_font_16.fallback; g_font_16.fallback = &s_cc_icons_font; -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE // The fixed-size tab bar font needs the person glyph too (Contacts tab icon, U+F007) at 16 px. static lv_font_t s_tab_person; s_tab_person = person_font; s_tab_person.fallback = nullptr; g_font_tab.fallback = &s_tab_person; @@ -10814,9 +10837,9 @@ static void clock12hToggleCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(on ? TR("Clock: 12-hour") : TR("Clock: 24-hour"), 900); } -#if CAP_LARGE_SCREEN -// UI size (resolution scale) selector — saves the pref; applied at the next boot (the LVGL -// resolution + flush upscaler are set up in begin()), so prompt for a restart. +#if CAP_UI_SIZE +// UI-size selector — saves the board-specific preset. Fonts and any matching +// geometry are selected while the UI tree is built, so prompt for a restart. static void uiScaleSelectCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; touchPrefsSetUiScale((uint8_t)lv_dropdown_get_selected(lv_event_get_target(e))); @@ -11843,13 +11866,16 @@ static void buildDeviceSettings(int sec) { y += SC(38); } -#if CAP_LARGE_SCREEN - /* UI size (resolution scale): Normal 100% / Large 150% / Huge 200%. Renders the whole UI at a - lower resolution and upscales to the panel, so EVERYTHING grows. Applied at boot → restart. */ +#if CAP_UI_SIZE + /* Board-specific UI-size presets. Applied while the UI tree is built, so a restart is required. */ { y += settingsRowLabel(body, y, 0, TR("UI size (restart to apply)"), COLOR_SUB, &g_font_12, 0) + 2; lv_obj_t* dd = lv_dropdown_create(body); +#if defined(TLORA_PAGER) + lv_dropdown_set_options(dd, "Small\nMedium\nLarge"); +#else lv_dropdown_set_options(dd, TR("Normal (100%)\nLarge (150%)\nHuge (200%)")); +#endif lv_dropdown_set_selected(dd, touchPrefsGetUiScale()); lv_obj_set_width(dd, lv_pct(100)); lv_obj_set_pos(dd, 2, y); @@ -27652,6 +27678,15 @@ static void applyMapChrome(bool on) { } static void makeMapTab(lv_obj_t* tab) { +#if defined(TLORA_PAGER) + // Diagnostic/map chrome stays compact at every Pager UI-size preset so the + // overlays do not hide the map itself. + const lv_font_t* map_info_font = &lv_font_montserrat_14; + const lv_font_t* map_control_font = &lv_font_montserrat_20; +#else + const lv_font_t* map_info_font = &g_font_12; + const lv_font_t* map_control_font = &g_font_16; +#endif lv_obj_set_scroll_dir(tab, LV_DIR_NONE); lv_obj_set_scrollbar_mode(tab, LV_SCROLLBAR_MODE_OFF); lv_obj_clear_flag(tab, LV_OBJ_FLAG_SCROLLABLE); @@ -27699,7 +27734,7 @@ static void makeMapTab(lv_obj_t* tab) { lv_label_set_long_mode(s_map_status_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(s_map_status_lbl, k_map_canvas_w - 20); lv_obj_set_style_text_color(s_map_status_lbl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); - lv_obj_set_style_text_font(s_map_status_lbl, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_font(s_map_status_lbl, map_info_font, LV_PART_MAIN); lv_obj_set_style_text_align(s_map_status_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_label_set_text(s_map_status_lbl, TR("Map — no tile pack on SPIFFS yet.\n\n" @@ -27713,7 +27748,7 @@ static void makeMapTab(lv_obj_t* tab) { // dark map patches. auto style_corner = [&](lv_obj_t* l) { lv_obj_set_style_text_color(l, lv_color_hex(0x000000), LV_PART_MAIN); - lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_font(l, map_info_font, LV_PART_MAIN); lv_obj_set_style_bg_color(l, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_set_style_bg_opa(l, LV_OPA_40, LV_PART_MAIN); lv_obj_set_style_pad_hor(l, 4, LV_PART_MAIN); @@ -27771,7 +27806,7 @@ static void makeMapTab(lv_obj_t* tab) { lv_obj_t* l = lv_label_create(b); lv_label_set_text(l, sym); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); - lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); + lv_obj_set_style_text_font(l, map_control_font, LV_PART_MAIN); lv_obj_center(l); return b; }; @@ -29423,7 +29458,14 @@ static void openMessageActionMenu(int msg_idx) { // smaller boards keep plain integers (PSC is a no-op there). Buttons sit TWO // per row — with Delete the menu carries up to 7 actions, and a single column // no longer fit the 240-px screens without scrolling. -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // Spend the Pager's horizontal room instead of its scarce vertical room. + const int card_w = sw - 80; + const int btn_h = 30; + const int gap = 6; + const int pad = 10; + const int hdr_h = 24; +#elif CAP_LARGE_SCREEN const int card_w = PCW(220); const int btn_h = PSC(30); const int gap = PSC(4); @@ -29486,7 +29528,11 @@ static void openMessageActionMenu(int msg_idx) { if (can_ack) mk_btn(LV_SYMBOL_OK " Ack", msgMenuAckCb); if (can_mention) { char ml[UITask::MAX_SENDER_NAME + 16]; +#if defined(TLORA_PAGER) + snprintf(ml, sizeof ml, "@%.16s", m.sender); // wide Pager cells can keep more of the name +#else snprintf(ml, sizeof ml, "@%.10s", m.sender); // "Mention" is implied by the @; half-width cell +#endif mk_btn(ml, msgMenuMentionCb); } mk_btn(LV_SYMBOL_COPY " Copy", msgMenuCopyCb); @@ -29563,7 +29609,12 @@ static void openMessageInfoPopup(int msg_idx) { const bool show_trace = !m.channel; const int route_pts = buildRouteFromMessage(m); const bool show_route = (route_pts >= 2); -#if CAP_LARGE_SCREEN +#if defined(TLORA_PAGER) + // Reduce wrapping across the wide, short Pager. The independently scrollable + // body handles routes and ACK details that exceed the available height. + const int card_w = sw - 28; + int card_h = sh - STATUSBAR_H - 8; +#elif CAP_LARGE_SCREEN const int card_w = PCW(220); int card_h = (show_trace || show_route) ? PSC(290) : PSC(250); #else @@ -29768,7 +29819,14 @@ static void openMessageInfoPopup(int msg_idx) { lv_obj_set_pos(bodywrap, 0, body_top); lv_obj_set_style_bg_opa(bodywrap, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_scroll_dir(bodywrap, LV_DIR_VER); - lv_obj_set_scrollbar_mode(bodywrap, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_scrollbar_mode(bodywrap, +#if defined(TLORA_PAGER) + LV_SCROLLBAR_MODE_ON +#else + LV_SCROLLBAR_MODE_AUTO +#endif + ); + lv_obj_add_event_cb(bodywrap, scrollClampOnEndCb, LV_EVENT_SCROLL_END, nullptr); // Thin visible scrollbar (remove_style_all wiped the theme's). lv_obj_set_style_width(bodywrap, 4, LV_PART_SCROLLBAR); lv_obj_set_style_bg_color(bodywrap, lv_color_hex(0x6FA8DA), LV_PART_SCROLLBAR); @@ -31974,8 +32032,15 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN); lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); - lv_obj_set_style_min_height(btn, 56, LV_PART_MAIN); - lv_obj_set_height(btn, 56); +#if defined(TLORA_PAGER) + static constexpr lv_coord_t kThreadRowH = 48; + static constexpr lv_coord_t kThreadAvatar = 34; +#else + static constexpr lv_coord_t kThreadRowH = 56; + static constexpr lv_coord_t kThreadAvatar = 40; +#endif + lv_obj_set_style_min_height(btn, kThreadRowH, LV_PART_MAIN); + lv_obj_set_height(btn, kThreadRowH); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); // Avatar: same FNV-1a hue family as the chat-bubble colours (see @@ -31986,7 +32051,7 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_remove_style_all(av); lv_obj_add_flag(av, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_clear_flag(av, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); // taps fall through to the row - lv_obj_set_size(av, 40, 40); + lv_obj_set_size(av, kThreadAvatar, kThreadAvatar); lv_obj_set_style_radius(av, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_color(av, lv_color_hsv_to_rgb((uint16_t)(hh % 360u), 55, 42), LV_PART_MAIN); lv_obj_set_style_bg_opa(av, LV_OPA_COVER, LV_PART_MAIN); @@ -32035,7 +32100,7 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_remove_style_all(gear); lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_add_flag(gear, NAV_HMOVE_FLAG); - lv_obj_set_size(gear, gear_w, 40); + lv_obj_set_size(gear, gear_w, kThreadRowH - 8); lv_obj_align(gear, LV_ALIGN_RIGHT_MID, -2, 0); lv_obj_add_event_cb(gear, threadGearCb, LV_EVENT_CLICKED, &p.ctx_store[i]); lv_obj_t* gl = lv_label_create(gear); @@ -32063,7 +32128,7 @@ static void refreshChatList(LvChatPanel& p) { } // Name (top line) + last-message preview (bottom line), right of the avatar. - const lv_coord_t text_x = 8 + 40 + 8; + const lv_coord_t text_x = 8 + kThreadAvatar + 8; const lv_coord_t name_w = (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - time_w - 24); lv_obj_t* nm2 = lv_label_create(btn); lv_obj_add_flag(nm2, LV_OBJ_FLAG_IGNORE_LAYOUT); @@ -32073,8 +32138,20 @@ static void refreshChatList(LvChatPanel& p) { lv_label_set_long_mode(nm2, LV_LABEL_LONG_DOT); // Fixed ONE-LINE height: with only a width, LONG_DOT lets a long name wrap to // a second line (never truncating) and it overlapped the preview underneath. - lv_obj_set_size(nm2, name_w, 18); - lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, 9); + lv_obj_set_size(nm2, name_w, +#if defined(TLORA_PAGER) + lv_font_get_line_height(&g_font_14) +#else + 18 +#endif + ); + lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, +#if defined(TLORA_PAGER) + 3 +#else + 9 +#endif + ); // Preview: "sender: text" for channels, "You: text" for own DMs, plain text otherwise. char psender[UITask::MAX_SENDER_NAME + 1] = ""; @@ -32097,8 +32174,20 @@ static void refreshChatList(LvChatPanel& p) { lv_obj_set_style_text_font(pv, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_label_set_long_mode(pv, LV_LABEL_LONG_DOT); - lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), 16); // one line, ellipsized - lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, -8); + lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), +#if defined(TLORA_PAGER) + lv_font_get_line_height(&g_font_12) +#else + 16 +#endif + ); // one line, ellipsized + lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, +#if defined(TLORA_PAGER) + -3 +#else + -8 +#endif + ); // Unread pill bottom-right (under the time), @ to its left on a mention. if (unread > 0) { @@ -32495,8 +32584,12 @@ static void refreshContactsList() { // scrolling the name (rejected) or gluing the value columns. TWO-LINE rows instead: // line 1: the full name (one line, dot-ellipsized — never scrolls) // line 2: heard-age and distance, small + dim under the name - const int ROW_H = mid_cols ? 46 : 34; const int name_line_h_row = lv_font_get_line_height(&g_font_14); +#if defined(TLORA_PAGER) + const int ROW_H = 5 + name_line_h_row + 2 + lv_font_get_line_height(&g_font_12) + 5; +#else + const int ROW_H = mid_cols ? 46 : 34; +#endif const int row2_y = 5 + name_line_h_row + 2; // line 2 top (below the name line) int name_w = heard_x - name_x - 6; if (name_w < 50) name_w = 50; @@ -33215,6 +33308,42 @@ static void updatePagerKbBacklight(unsigned long now) { #endif #if defined(HAS_PAGER_ENCODER) +// A focus step normally asks LVGL to reveal the next object. On the Pager that +// makes a message or popup body taller than the viewport impossible to read: +// the next detent abandons it and reveals the following object. Consume detents +// as page-scrolls until the focused oversized object reaches its edge. +static bool pagerEncoderScrollOversizedFocused(bool up) { + lv_obj_t* focused = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; + if (!focused || !lv_obj_is_valid(focused)) return false; + + for (lv_obj_t* scroller = lv_obj_get_parent(focused); scroller; + scroller = lv_obj_get_parent(scroller)) { + if (!lv_obj_has_flag(scroller, LV_OBJ_FLAG_SCROLLABLE)) continue; + + lv_area_t viewport; + lv_area_t item; + lv_obj_get_coords(scroller, &viewport); + lv_obj_get_coords(focused, &item); + viewport.y1 += lv_obj_get_style_pad_top(scroller, LV_PART_MAIN); + viewport.y2 -= lv_obj_get_style_pad_bottom(scroller, LV_PART_MAIN); + const lv_coord_t view_h = viewport.y2 - viewport.y1 + 1; + const lv_coord_t item_h = item.y2 - item.y1 + 1; + if (view_h <= 0 || item_h <= view_h) return false; + + const bool unread_edge = up ? (item.y1 < viewport.y1) : (item.y2 > viewport.y2); + const lv_coord_t room = up ? lv_obj_get_scroll_top(scroller) + : lv_obj_get_scroll_bottom(scroller); + if (!unread_edge || room <= 0) return false; + + lv_coord_t step = view_h * 2 / 3; + if (step < 24) step = 24; + if (step > room) step = room; + lv_obj_scroll_by(scroller, 0, up ? step : -step, LV_ANIM_ON); + return true; + } + return false; +} + // T-LoRa Pager rotary encoder: a single linear nav axis (not 2D like the // trackball, so none of updateTrackball()'s game/emoji-grid/cursor special // cases apply) — each detent moves focus one step via the same navFifo the @@ -33468,8 +33597,16 @@ static void updatePagerEncoder(unsigned long now) { // top-bar items / back into the bubbles (pagerChatComposerNav), and the // edge bubble of a still-loading history scrolls the list instead of // stepping focus out (pagerEncoderChatEdgeScroll). - for (; delta > 0; delta--) { if (!pagerChatComposerNav(false) && !pagerEncoderChatEdgeScroll(false)) navPushTap(LV_KEY_NEXT); } - for (; delta < 0; delta++) { if (!pagerChatComposerNav(true) && !pagerEncoderChatEdgeScroll(true)) navPushTap(LV_KEY_PREV); } + for (; delta > 0; delta--) { + if (!pagerEncoderScrollOversizedFocused(false) && + !pagerChatComposerNav(false) && !pagerEncoderChatEdgeScroll(false)) + navPushTap(LV_KEY_NEXT); + } + for (; delta < 0; delta++) { + if (!pagerEncoderScrollOversizedFocused(true) && + !pagerChatComposerNav(true) && !pagerEncoderChatEdgeScroll(true)) + navPushTap(LV_KEY_PREV); + } } static constexpr uint32_t kLongPressMs = 1000; @@ -36854,7 +36991,7 @@ static void addAppTile(lv_obj_t* parent, int x, int y, int w, int h, // Rounded-square chip (iOS-style squircle), tinted with the app's accent // colour, centred up top. Bigger + a small proportional corner radius so it // reads as a SQUARE app icon, not a circle. -#if CAP_LARGE_SCREEN +#if CAP_UI_SIZE // Reserve the ACTUAL label line height (it grows with the UI-scale font) so the // name never overlaps the icon chip on the big panel. int chip = h - (lv_font_get_line_height(big ? &g_font_14 : &g_font_12) + 10); @@ -37666,6 +37803,12 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_color(g_statusbar.left_label, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.left_label, &g_font_14, LV_PART_MAIN); lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 6, 0); +#if defined(TLORA_PAGER) + // Keep the large-text title out of the clock/status cluster. Individual home + // names may opt into a marquee; other titles truncate inside this window. + lv_obj_set_width(g_statusbar.left_label, 200); + lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); +#endif // Tapping the unread badge (✉ N) jumps to the Chats inbox. The CLICKABLE flag is // toggled per-tick in updateGlobalStatusBar so only the badge state intercepts the // tap; every other left-zone state falls through to the bar's control-center tap. @@ -37715,7 +37858,13 @@ static void buildGlobalStatusBar() { lv_label_set_text(g_statusbar.batt_pct, "?"); lv_obj_set_style_text_color(g_statusbar.batt_pct, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.batt_pct, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_RIGHT_MID, -SC(22), 0); + lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -32, +#else + -SC(22), +#endif + 0); // Tapping the battery (icon or %) opens the 24h battery-history chart (logged to // SD on T-Deck, else internal SPIFFS — works on both builds). Generous ext-click @@ -37740,14 +37889,26 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_font(g_statusbar.clock, &g_font_12, LV_PART_MAIN); // Unified across all boards: extra slot reserved for the DND/sleep moon glyph // (T-Deck's sleep_icon and the all-board dnd_icon below both live at -SC(105)). - lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, -SC(160), 0); + lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -210, +#else + -SC(160), +#endif + 0); // Wi-Fi glyph (right of the Bluetooth glyph, left of the signal bars). g_statusbar.conn_icon = lv_label_create(g_statusbar.root); lv_label_set_text(g_statusbar.conn_icon, ""); lv_obj_set_style_text_color(g_statusbar.conn_icon, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.conn_icon, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -SC(73), 0); + lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -104, +#else + -SC(73), +#endif + 0); // Bluetooth glyph (left of the SD LED). Unified offset across all boards (see clock above). g_statusbar.ble_icon = lv_label_create(g_statusbar.root); @@ -37756,7 +37917,13 @@ static void buildGlobalStatusBar() { lv_obj_set_style_text_font(g_statusbar.ble_icon, &g_font_12, LV_PART_MAIN); // Narrow bars (V4 portrait) have no DND slot beside BLE and their clock sits at -126, so BLE // stays at -111 there; wide bars keep -SC(127) (tight to the DND moon at -144). - lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, (lv_disp_get_hor_res(nullptr) < 300) ? -111 : -SC(127), 0); + lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -142, +#else + (lv_disp_get_hor_res(nullptr) < 300) ? -111 : -SC(127), +#endif + 0); #if defined(HAS_TDECK_GT911) // Idle power-save readiness indicator — moon glyph, left of the SD LED. T-Deck only, @@ -37782,7 +37949,13 @@ static void buildGlobalStatusBar() { lv_label_set_text(g_statusbar.dnd_icon, TOUCH_SYM_MOON); lv_obj_set_style_text_color(g_statusbar.dnd_icon, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); lv_obj_set_style_text_font(g_statusbar.dnd_icon, &g_font_12, LV_PART_MAIN); - lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144), 0); + lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -164, +#else + -SC(144), +#endif + 0); lv_obj_add_flag(g_statusbar.dnd_icon, LV_OBJ_FLAG_HIDDEN); // shown only while DND is active lv_obj_add_flag(g_statusbar.dnd_icon, NAV_SKIP_FLAG); // passive status glyph, not a focus-nav target @@ -37797,7 +37970,13 @@ static void buildGlobalStatusBar() { lv_obj_set_style_bg_color(g_statusbar.sd_icon, lv_color_hex(0xF5A623), LV_PART_MAIN); // amber = SD activity lv_obj_set_style_bg_opa(g_statusbar.sd_icon, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -SC(91), 0); + lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -124, +#else + -SC(91), +#endif + 0); lv_obj_add_flag(g_statusbar.sd_icon, LV_OBJ_FLAG_HIDDEN); // shown only during SD I/O // Async mesh-request spinner — a refresh glyph centred in the bar, blinking @@ -37817,7 +37996,13 @@ static void buildGlobalStatusBar() { lv_obj_t* sb = lv_obj_create(g_statusbar.root); lv_obj_remove_style_all(sb); lv_obj_set_size(sb, 15, 12); - lv_obj_align(sb, LV_ALIGN_RIGHT_MID, -SC(54), 0); + lv_obj_align(sb, LV_ALIGN_RIGHT_MID, +#if defined(TLORA_PAGER) + -82, +#else + -SC(54), +#endif + 0); lv_obj_clear_flag(sb, LV_OBJ_FLAG_SCROLLABLE); g_statusbar.sig_box = sb; const int bw = 3, gap = 1; @@ -38067,8 +38252,13 @@ static void updateGlobalStatusBar() { if (s_fullscreen_view && s_fullscreen_title[0]) home_zone = false; #endif if (!home_zone && s_left_home_cfg) { +#if defined(TLORA_PAGER) + lv_obj_set_width(g_statusbar.left_label, 200); + lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_DOT); +#else lv_obj_set_width(g_statusbar.left_label, LV_SIZE_CONTENT); lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_WRAP); +#endif s_left_home_cfg = false; s_left_home_name[0] = '\0'; // force re-config on the next home entry } @@ -38147,7 +38337,12 @@ static void updateGlobalStatusBar() { // smaller font — otherwise on the narrow V4 portrait bar its end ("…Map") // runs into the Wi-Fi icon. Restore the normal font on other tabs. lv_obj_set_style_text_font(g_statusbar.left_label, - tab == MAP_TAB_INDEX ? &g_font_12 : &g_font_14, LV_PART_MAIN); +#if defined(TLORA_PAGER) + tab == MAP_TAB_INDEX ? &lv_font_montserrat_14 : &g_font_14, +#else + tab == MAP_TAB_INDEX ? &g_font_12 : &g_font_14, +#endif + LV_PART_MAIN); if (tab == MAP_TAB_INDEX) { // On the immersive map the left zone carries the required OSM attribution. lv_label_set_text(g_statusbar.left_label, s_map_style == 1 @@ -38184,6 +38379,8 @@ static void updateGlobalStatusBar() { // runs from the left inset up to just before the leftmost icon (Bluetooth, whose // right anchor is 126+inset from the right edge — reserve its glyph + a margin). lv_obj_set_width(g_statusbar.left_label, sb_w - (SB_INSET_X + 126 + 44)); +#elif defined(TLORA_PAGER) + lv_obj_set_width(g_statusbar.left_label, 200); #else lv_obj_set_width(g_statusbar.left_label, (sb_w >= 600) ? (sb_w - SC(190)) : (sb_w >= 300) ? 100 : 66); // #47a: narrower window on the 240px V4 bar so a long name can't run into the clock @@ -38272,8 +38469,16 @@ static void updateGlobalStatusBar() { // Dedicated slot — re-assert on entry/return so a CAP_ROTATABLE board // (V4) rotating out of portrait mid-DND self-heals from the borrowed // signal-bars position instead of staying stranded there. - const int d = batteryIsCharging(batteryMvSmoothed()) ? 32 : 0; -#if CAP_LARGE_SCREEN + const int d = batteryIsCharging(batteryMvSmoothed()) ? +#if defined(TLORA_PAGER) + 45 +#else + 32 +#endif + : 0; +#if defined(TLORA_PAGER) + lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); +#elif CAP_LARGE_SCREEN lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -SC(144) + SC(d), 0); #else lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -144 + d, 0); @@ -38334,6 +38539,14 @@ static void updateGlobalStatusBar() { // Round panel: re-apply the two-row layout, sliding the row-2 sub-battery cluster // right by the hidden %-column width so it stays snug against the bolt. statusBarLayoutTwoRow(charging ? 32 : 0); +#elif defined(TLORA_PAGER) + const int d = charging ? 45 : 0; + if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_RIGHT_MID, -82 + d, 0); + if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_RIGHT_MID, -104 + d, 0); + if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_RIGHT_MID, -124 + d, 0); + if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_RIGHT_MID, -142 + d, 0); + if (g_statusbar.dnd_icon) lv_obj_align(g_statusbar.dnd_icon, LV_ALIGN_RIGHT_MID, -164 + d, 0); + if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_RIGHT_MID, -232 + d, 0); #elif CAP_LARGE_SCREEN // Tanmatsu: the cluster is built with SC() UI-scaling, so this slide MUST scale too — the raw // offsets below marched the scaled glyphs (incl. the BLE icon) into each other at Large/Huge the @@ -38453,8 +38666,13 @@ static void updateGlobalStatusBar() { // Wide bar shifted 18px further left of its old -142/-110 to open a clean // ~16-17px gap for the DND moon icon (now at -SC(144)) on Bluetooth's left // side, instead of the old cramped 15px gap that used to sit here. - const int clk_x = narrow_bar ? (charging ? -94 : -126) + const int clk_x = +#if defined(TLORA_PAGER) + charging ? -165 : -210; +#else + narrow_bar ? (charging ? -94 : -126) : (charging ? -SC(128) : -SC(160)); +#endif lv_obj_align(g_statusbar.clock, LV_ALIGN_RIGHT_MID, clk_x, 0); } // Park the async-request spinner just LEFT of the clock wherever it lands, @@ -40447,7 +40665,11 @@ static void buildUiTree() { #endif lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS); // Slightly larger font for icons so they're easy to tap. +#if CAP_UI_SIZE + lv_obj_set_style_text_font(tab_btns, &g_font_tab, LV_PART_MAIN); +#else lv_obj_set_style_text_font(tab_btns, &g_font_16, LV_PART_MAIN); +#endif // Home and Set tabs scroll vertically at the tab page level for (lv_obj_t* t : {tab_home, tab_settings}) { diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index c451b48..6b176d7 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -139,6 +139,15 @@ #define CAP_LOCK_SCREEN 0 #endif +// Persisted, restart-to-apply UI-size selector. Large-screen boards already +// expose it; the Pager adds font-only presets because its 480x222 viewport is +// wide enough for larger type but too short for global geometry scaling. +#if CAP_LARGE_SCREEN || defined(TLORA_PAGER) + #define CAP_UI_SIZE 1 +#else + #define CAP_UI_SIZE 0 +#endif + // ---- Derived input capabilities --------------------------------------------- // Physical keyboard: T-Deck matrix, Tanmatsu keypad, the pager's TCA8418, or // the ThinkNode M9 keyboard. From 51617b404e6e0d8ffa13f1e8ecc0bc07d6e2c7e6 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 11:36:24 +0200 Subject: [PATCH 77/99] touch: SD mount ladders renegotiate the clock upward after a slow-rung init (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card whose power-up handshake only completed at 400 kHz kept 400 kHz as its SESSION clock — SD.begin's frequency is the operating clock, and neither the boot ladder (main.cpp) nor the runtime ladder (fmSdTryMount) ever raised it after a slow success. With the SD as the T-Deck's primary store that meant loading contacts + history at ~25 KB/s: the reported ~3-minute boot, f_getfree timeouts making an inserted card show as 0 KB/empty in the file manager, and backups wedging. Launcher installs hit it most (the Launcher's own SD use leaves the card needing the slow wake path more often). Standard SD bring-up is init-slow-then-raise: after any sub-4MHz success both ladders now re-begin at 4 MHz, falling back to the clock that just worked if the fast re-begin fails. A healthy card is unaffected (mounts at 4 MHz on the first rung). T-Deck + V4-R8 + M9 (the shared-SPI SD boards) build. Likely also behind the long-standing Launcher-mode weirdness cluster together with the torn-prefs fix (#195/#196): slow-clock sessions made every write window enormous. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/main.cpp | 25 ++++++++++++++++++++++++- src/ui-touch/UITask.cpp | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 97760fc..d844b5e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -519,11 +519,34 @@ void setup() { { 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 }, }; int tries = want_full_sd ? 7 : 3; + uint32_t mounted_hz = 0; for (int a = 0; a < tries && !sd_mounted; ++a) { SD.end(); delay(kBootMount[a].settle_ms); - if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE) + if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { sd_mounted = true; + mounted_hz = kBootMount[a].hz; + } + } + // RENEGOTIATE UPWARD after a slow-rung success (GH #194). Standard SD bring-up is + // "initialise at a conservative clock, then raise it" — but SD.begin's clock is the + // operating clock for the whole session, so a card that only WOKE at 400 kHz then ran + // its entire life at 400 kHz (~25 KB/s). With the card as the primary store that was a + // ~3-minute boot (contacts + history at modem speed), runtime f_getfree timeouts (the + // file manager showing an inserted card as 0 KB/empty), and wedged backups. An + // initialised card almost always sustains 4 MHz even when its power-up handshake needed + // 400 kHz; if the fast re-begin fails, fall back to the clock that just worked. + if (sd_mounted && mounted_hz < 4000000) { + SD.end(); + delay(60); + if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE) { + Serial.printf("[BOOT] SD renegotiated %lu -> 4000000 Hz\n", (unsigned long)mounted_hz); + } else { + SD.end(); + delay(120); + sd_mounted = SD.begin(PIN_SD_CS, *_spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + if (sd_mounted) Serial.printf("[BOOT] SD stays at %lu Hz (4 MHz renegotiation failed)\n", (unsigned long)mounted_hz); + } } } if (sd_mounted) { diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2c44a75..55fd08f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -18698,15 +18698,30 @@ static bool fmSdTryMount() { }; const int kAttempts = (int)(sizeof(kMountLadder) / sizeof(kMountLadder[0])); bool mounted = false; + uint32_t mounted_hz = 0; disableLoopWDT(); for (int attempt = 0; attempt < kAttempts; ++attempt) { SD.end(); delay(kMountLadder[attempt].settle_ms); if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { mounted = true; + mounted_hz = kMountLadder[attempt].hz; break; } } + // Renegotiate upward after a slow-rung success (GH #194): the succeeding rung's clock used + // to become the SESSION clock, so a card whose wake-up needed 400 kHz served contacts, + // history and tiles at ~25 KB/s forever after ("slower is fine" above predates the SD being + // the primary store). Init slow, then raise — fall back to the proven clock if 4 MHz fails. + if (mounted && mounted_hz < 4000000) { + SD.end(); + delay(60); + if (!(SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE)) { + SD.end(); + delay(120); + mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + } + } enableLoopWDT(); if (mounted) { s_sd_mounted = true; From f0fc356a05b41d97dcdcb0a60d48056093b3c314 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 11:39:34 +0200 Subject: [PATCH 78/99] =?UTF-8?q?touch:=20beta=5F55=20=E2=80=94=20prefs=20?= =?UTF-8?q?crash-safety=20+=20Pager=20UI=20sizes=20(PixPMusic),=20SD=20clo?= =?UTF-8?q?ck=20renegotiation,=20R8=20buzzer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- release-notes/beta_55.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 release-notes/beta_55.txt diff --git a/release-notes/beta_55.txt b/release-notes/beta_55.txt new file mode 100644 index 0000000..583b0a7 --- /dev/null +++ b/release-notes/beta_55.txt @@ -0,0 +1,17 @@ +# beta_55 - settings that stay put, fast SD boots, Pager text sizes, R8 sound +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Reliability (community) ----------------------------------------------------------- +Settings can no longer silently reset themselves. An interrupted settings write could truncate the settings file - the sound toggle turning itself back on, the setup wizard reappearing, a mystery #unknown channel. Settings now save through paired snapshots with checksums, written in the background: a torn write can only lose the very last change, never your configuration. By @PixPMusic (#195, #196). +Settings changes also no longer cause interface hitches - saves happen off the interface thread. +Note if you downgrade after running this build: the older firmware reads the settings file which can be one save behind. +Fixed the 3-minute boot with an SD card inserted (T-Deck, mostly Launcher installs). A card that woke up at a low speed kept that speed for the whole session, so contacts and history loaded at ~25 KB/s - and the file manager showed the card as 0KB/empty. The mount now raises the clock back to full speed after a slow wake, the standard way. Thanks to scsirob for the report and testing (#194). + +# --- T-Pager (community) --------------------------------------------------------------- +New UI size presets for the T-Pager: Small (current look, default), Medium and Large - bigger text without pushing controls off the short screen, with encoder scrolling for tall content. By @PixPMusic (#197). Please try Medium/Large and report anything that clips. + +# --- Heltec V4-R8 ----------------------------------------------------------------------- +The buzzer works. The Expansion Kit V2's buzzer sat behind a driver chip whose wiring was unconfirmed, so it shipped disabled - R8 users have never heard a chime. Heltec's schematic settled it. If it is still silent on your kit, check the small SW1 switch on the expansion board: it is a hardware mute. + +# --- General ---------------------------------------------------------------------------- +Long-pressing the GPS tile in the control centre now opens the GPS settings page instead of the radio page. From badad25305dd109f2ed63cac9a6de19955d07b25 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 12:13:08 +0200 Subject: [PATCH 79/99] =?UTF-8?q?touch:=20issue=20batch=20=E2=80=94=20unlo?= =?UTF-8?q?ck=20branding=20(#165),=20Discover=20row=20overflow=20(#191),?= =?UTF-8?q?=20scope=20in=20details=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #165: the animated post-unlock overlay still said MESHCOMOD; now WADAMESH. - #191: Discover app rows cap the name and use single-letter node types so the trailing last-heard age always fits the line. - #157: the Info popup only read the transport scope for FLOOD messages, so a region-scoped DIRECT message showed no scope; and senders that carry the region in transport_codes[1] with codes[0]=0 showed Scope 0000. The scope is now read for any RX and falls back to codes[1] when codes[0] is zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/MyMesh.h | 7 ++++++- src/ui-touch/UITask.cpp | 26 +++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/MyMesh.h b/src/MyMesh.h index 9a4e4ff..d1c65c8 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -602,7 +602,12 @@ public: } const uint8_t rt = pkt ? pkt->getRouteType() : 0xFF; _last_rx_has_scope = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT); - _last_rx_scope = (_last_rx_has_scope && pkt) ? pkt->transport_codes[0] : 0; + // #157: some senders carry the region in transport_codes[1] (reply-region hint) with + // codes[0] zero -- the Info popup then showed "Scope 0000" for a genuinely scoped message. + // Show whichever code is set; [0] (the scope proper) wins when both are. + _last_rx_scope = 0; + if (_last_rx_has_scope && pkt) + _last_rx_scope = pkt->transport_codes[0] ? pkt->transport_codes[0] : pkt->transport_codes[1]; } /** Track a freshly-sent flood TXT fingerprint (called from sendFloodScoped). */ diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 55fd08f..0e2301b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -21178,6 +21178,17 @@ static const char* discTypeName(uint8_t t) { default: return "node"; } } +// Single-letter type for the LIST rows (#191): the full word plus a long node name pushed the +// trailing last-heard age off the 320px line. The summary header keeps the full words. +static const char* discTypeShort(uint8_t t) { + switch (t) { + case ADV_TYPE_REPEATER: return "R"; + case ADV_TYPE_CHAT: return "C"; + case ADV_TYPE_ROOM: return "Rm"; + case ADV_TYPE_SENSOR: return "S"; + default: return "?"; + } +} // ---- Wardriving: log GPS-tagged sightings as we scan (a standalone, on-device MeshMapper) ---- // While the Discover app scans, sample our GPS position; at each sample, log every currently-heard @@ -21295,9 +21306,10 @@ static void discoverBuildFeed() { if (age < 60) snprintf(ago, sizeof ago, "%us", (unsigned)age); else snprintf(ago, sizeof ago, "%um", (unsigned)(age / 60)); const char* direct = (h.path_len == 0) ? " #53C06B direct#" : ""; + // #191: cap the name (%.14s) + single-letter type so the trailing age ALWAYS fits the line. q += snprintf(buf + q, sizeof(buf) - q, - "#%06X %s# %s %ddBm %.1f%s \xC2\xB7 %s\n", - (unsigned)discTypeColor(h.node_type), name, discTypeName(h.node_type), + "#%06X %.14s# %s %ddBm %.1f%s \xC2\xB7 %s\n", + (unsigned)discTypeColor(h.node_type), name, discTypeShort(h.node_type), (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, direct, ago); } if (q == 0) snprintf(buf, sizeof buf, "#7A7F87 Scanning\xE2\x80\xA6 nothing has answered yet#"); @@ -39212,7 +39224,7 @@ static void buildBootSplash() { // ---- Product line: MESHCOMOD (small, same size as the channel line, between // the WADAMESH wordmark and TOUCH BETA) ---- lv_obj_t* mc = lv_label_create(s_splash_root); - lv_label_set_text(mc, "MESHCOMOD"); + lv_label_set_text(mc, "WADAMESH"); // #165: the unlock overlay still said MESHCOMOD after the rebrand lv_obj_set_style_text_font(mc, &lv_font_unscii_8, LV_PART_MAIN); lv_obj_set_style_text_color(mc, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_letter_space(mc, 4, LV_PART_MAIN); @@ -46112,8 +46124,12 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex uint8_t in_path[MAX_UI_PATH]; uint8_t in_path_n = 0; uint16_t in_scope = 0; - if ((meta_flags & MSG_META_HAS_RX) && (meta_flags & MSG_META_IS_FLOOD)) { - in_path_n = the_mesh.lastRxPath(in_path, sizeof(in_path)); + if (meta_flags & MSG_META_HAS_RX) { + // Route hashes only exist on floods, but the transport SCOPE also rides scoped DIRECT + // messages (the "Scope direct msgs to region" feature) -- reading it only under IS_FLOOD + // meant a scoped DM's Info popup showed no scope at all (#157). + if (meta_flags & MSG_META_IS_FLOOD) + in_path_n = the_mesh.lastRxPath(in_path, sizeof(in_path)); bool has_scope = false; in_scope = the_mesh.lastRxScope(&has_scope); if (has_scope) meta_flags |= MSG_META_HAS_SCOPE; From a2548c39ab164b8b3772f02866473c076cdd691d Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 12:19:46 +0200 Subject: [PATCH 80/99] touch: T-Pager gets the full store-on-SD machinery (#193) The pager's SD support shipped in pieces (mount ladder with XL9555 card-detect, tile-cache fallback, shared TFT_eSPI SPIClass) but CAP_SD/CAP_FILESYSTEM were never flipped from the pre-wiring 0, so the Files app gates and the whole 'Store data on SD' machinery stayed compiled out -- an inserted, mounted, browsable card that nothing would store to. Flipped the caps, extended the boot storage selection + SPIFFS->SD migration + SdNvsPrefs routing gates to TLORA_PAGER, and exported tloraPagerSharedSPI() from the variant so main.cpp can mount the card at boot on the shared bus like the T-Deck. Both pager envs + T-Deck + V4 + R8 build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/main.cpp | 12 ++++++++---- src/ui-touch/UITask.cpp | 2 +- src/ui-touch/device_caps.h | 11 ++++++----- variants/lilygo_tlora_pager/target.cpp | 7 +++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index d844b5e..b70a7d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -48,7 +48,7 @@ static uint32_t _atoi(const char* sp) { DataStore store(LittleFS, rtc_clock); #elif defined(ESP32) #include <SPIFFS.h> - #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) + #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) #include <SD.h> #include <Preferences.h> #ifndef PIN_SD_CS @@ -196,7 +196,7 @@ void halt() { #include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56) -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) // ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ---- // Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED // (the flag never survived a reboot), so their identity/prefs/contacts kept living @@ -466,10 +466,12 @@ void setup() { // failure falls back to SPIFFS so the device always boots. bool spiffs_ok = SPIFFS.begin(false); // try first WITHOUT auto-format bool sd_storage = false; -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) { #if defined(HELTEC_LORA_V4_R8) extern SPIClass* heltecV4R8SharedSPI(); // FSPI, shared with the TFT (CS=3) + #elif defined(TLORA_PAGER) + extern SPIClass* tloraPagerSharedSPI(); // the display/radio's shared SPIClass (#193) #else extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus #endif @@ -497,6 +499,8 @@ void setup() { #if defined(HELTEC_LORA_V4_R8) SPIClass* _spi = heltecV4R8SharedSPI(); + #elif defined(TLORA_PAGER) + SPIClass* _spi = tloraPagerSharedSPI(); #else SPIClass* _spi = tdeckSharedSPI(); #endif @@ -613,7 +617,7 @@ void setup() { // Route touch settings + Wi-Fi creds to the active filesystem (SD when that's // the data store, else SPIFFS) instead of NVS. Old NVS values still load and // migrate on their next save, so this is a transparent in-place upgrade. - #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) + #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS, sd_storage ? "/meshcomod" : "/prefs"); #else diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 0e2301b..11a0c60 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -12048,7 +12048,7 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, useSdStorageToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } -#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) +#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) /* Where contacts ACTUALLY live this boot. The toggle above is only an intent — if the card failed to mount at boot (cold/slow card), contacts silently stay on internal flash even with it ON. This line shows the truth and flags that mismatch. */ diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index 6b176d7..0b5e2ee 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -44,11 +44,12 @@ // file manager's SD-vs-FFat backend selection) is still hardcoded to // HAS_TDECK_GT911/HAS_TANMATSU specifically, never migrated to be CAP_SD- // generic — turning these on here just hits "SD"/"CARD_NONE"/"fmSdTryMount" - // undeclared, not real SD support. A real mount needs pager-specific wiring - // (CS 21, its own shared-SPI helper), which is unscheduled follow-up work, - // not part of this milestone. - #define CAP_SD 0 - #define CAP_FILESYSTEM 0 + // SD support shipped after this note was written: fmSdTryMount has a pager branch + // (TFT_eSPI shared SPIClass + XL9555 card-detect) and the tile cache falls back to the + // card under Launcher. Flags flipped for #193 so the Files app + "Store data on SD" + // machinery light up like the T-Deck's. + #define CAP_SD 1 + #define CAP_FILESYSTEM 1 #define CAP_GPS 1 // u-blox MIA-M10Q #define CAP_OTA 1 // dual-OTA partition layout, same shape as the T-Deck #define CAP_LOCK_SCREEN 1 diff --git a/variants/lilygo_tlora_pager/target.cpp b/variants/lilygo_tlora_pager/target.cpp index 99b2009..49e7d43 100644 --- a/variants/lilygo_tlora_pager/target.cpp +++ b/variants/lilygo_tlora_pager/target.cpp @@ -125,3 +125,10 @@ mesh::LocalIdentity radio_new_identity() { RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity } + +// Shared-SPI accessor for main.cpp's boot-time SD storage mount (#193): the micro-SD rides the +// SAME SPIClass the display/radio already own (see the bus-sharing note at the top of this file). +// UITask's runtime mount uses TFT_eSPI::getSPIinstance() directly; main.cpp cannot cheaply +// include TFT_eSPI, so it links this instead. +#include <TFT_eSPI.h> +SPIClass* tloraPagerSharedSPI() { return &TFT_eSPI::getSPIinstance(); } From bcc084820c7dc8fb8a9c10d25092309f8080b258 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 12:23:18 +0200 Subject: [PATCH 81/99] =?UTF-8?q?touch:=20radio=20presets=20set=20a=20REAL?= =?UTF-8?q?=20duty=20limit=20=E2=80=94=20airtime=20factor=20was=2010x=20of?= =?UTF-8?q?f=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshCore's airtime_factor is the Dispatcher's TX pacing multiplier: duty = 1/(1+af), so a 10% duty limit needs af = 9. The presets wrote af = pct/100 (0.10), which configured every preset user's radio to ~91% duty -- the Home meter's 'always 91%' was truthfully reporting the misconfiguration, on every region preset including UK/EU. - applyMeshRadioPresetFields converts duty% -> factor correctly (clamped to the core's 0..9). - findMatchingMeshRadioPreset accepts both the correct and the old buggy value so previously-configured devices still show their preset in the dropdown. - healPresetAirtimeFactor (run once at UI begin) rewrites the factor for devices whose params exactly match a preset and whose stored factor matches the OLD buggy write -- the exact-match requirement is the guard against touching deliberate user-set values. NOTE for the web client: Meshcomod-client/shared/radio_presets.ts documents the same pct/100 convention and needs the matching fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 43 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 11a0c60..39fe12b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2214,6 +2214,14 @@ static bool g_radio_preset_cb_silent = false; static void kbMirrorSyncToReal(); // keyboard mirror strip; defined below +// #161: duty% -> the core's airtime_factor (Dispatcher: duty = 1/(1+af)), clamped to the +// core's 0..9 range (af 9 = 10% duty, the regulatory floor the UI offers). +static float meshPresetAirtimeFactor(uint8_t pct) { + if (pct == 0 || pct >= 100) return 0.0f; // unlimited + float af = (100.0f / (float)pct) - 1.0f; + return af > 9.0f ? 9.0f : af; +} + static int findMatchingMeshRadioPreset(const NodePrefs* prefs) { if (!prefs) return -1; for (size_t i = 0; i < k_mesh_radio_preset_count; ++i) { @@ -2223,13 +2231,34 @@ static int findMatchingMeshRadioPreset(const NodePrefs* prefs) { if (prefs->sf != p.sf) continue; if (prefs->cr != p.cr) continue; if (prefs->tx_power_dbm != p.tx_dbm) continue; - const double exp_af = static_cast<double>(p.airtime_limit_pct) / 100.0; - if (std::fabs(static_cast<double>(prefs->airtime_factor) - exp_af) > 0.05) continue; + const double exp_af = (double)meshPresetAirtimeFactor(p.airtime_limit_pct); + const double old_af = static_cast<double>(p.airtime_limit_pct) / 100.0; // pre-#161 buggy write + const double cur = static_cast<double>(prefs->airtime_factor); + if (std::fabs(cur - exp_af) > 0.05 && std::fabs(cur - old_af) > 0.05) continue; return static_cast<int>(i); } return -1; } +// #161 boot heal: devices configured by the OLD preset code carry af = pct/100 (~91% duty on a +// 10% preset). If the radio params match a preset exactly and the stored factor matches the OLD +// buggy value (and NOT a deliberate user choice near it -- the exact-preset match is the guard), +// rewrite it to the correct factor once and persist. +static void healPresetAirtimeFactor() { + NodePrefs* prefs = the_mesh.getNodePrefs(); + if (!prefs) return; + const int m = findMatchingMeshRadioPreset(prefs); + if (m < 0) return; + const MeshRadioPreset& p = k_mesh_radio_presets[m]; + const float good = meshPresetAirtimeFactor(p.airtime_limit_pct); + const float bad = (float)p.airtime_limit_pct / 100.0f; + if (std::fabs(prefs->airtime_factor - bad) <= 0.05f && + std::fabs(prefs->airtime_factor - good) > 0.05f) { + prefs->airtime_factor = good; + the_mesh.savePrefs(); + } +} + static void applyMeshRadioPresetFields(unsigned preset_idx) { if (preset_idx >= k_mesh_radio_preset_count || !g_set_modal.freq_ta) return; kbMirrorSyncToReal(); @@ -2245,7 +2274,10 @@ static void applyMeshRadioPresetFields(unsigned preset_idx) { lv_textarea_set_text(g_set_modal.cr_ta, buf); snprintf(buf, sizeof(buf), "%d", static_cast<int>(p.tx_dbm)); lv_textarea_set_text(g_set_modal.tx_ta, buf); - const float af = static_cast<float>(p.airtime_limit_pct) / 100.0f; + // #161: MeshCore's airtime_factor is a TX pacing multiplier -- the core's Dispatcher runs at + // duty = 1/(1+af). A 10% duty limit therefore needs af = 9, NOT 0.10: the old pct/100 write + // configured every preset user's radio to ~91% duty (and the Home meter honestly said so). + const float af = meshPresetAirtimeFactor(p.airtime_limit_pct); snprintf(buf, sizeof(buf), "%.2f", static_cast<double>(af)); lv_textarea_set_text(g_set_modal.airtime_ta, buf); } @@ -44181,6 +44213,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _sensors = sensors; _node_prefs = node_prefs; + // #161 one-time heal: presets used to write airtime_factor = pct/100 (a 10% preset + // configured ~91% duty). Exact-preset matches with the old value get the correct + // factor rewritten + persisted; see healPresetAirtimeFactor for the guard. + healPresetAirtimeFactor(); + #if defined(ESP32) // Clock floor (#89): restore the highest epoch this device ever handed out so // a power cycle without a time source cannot step protocol timestamps From a68ea66805be4f2f02177bbbcbef99a67910ba69 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Sat, 1 Aug 2026 12:28:58 +0200 Subject: [PATCH 82/99] =?UTF-8?q?touch:=20beta=5F56=20=E2=80=94=20duty-cyc?= =?UTF-8?q?le=20preset=20fix,=20Pager=20SD=20storage,=20issue-sweep=20batc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- release-notes/beta_56.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 release-notes/beta_56.txt diff --git a/release-notes/beta_56.txt b/release-notes/beta_56.txt new file mode 100644 index 0000000..83ceb32 --- /dev/null +++ b/release-notes/beta_56.txt @@ -0,0 +1,13 @@ +# beta_56 - duty-cycle fix (important), Pager SD storage, issue-sweep fixes +# One user-facing note per non-blank, non-# line; # lines are section comments. + +# --- Read this first ------------------------------------------------------------------- +IMPORTANT - if you ever picked a region preset, your radio was transmitting at roughly 9x the duty limit (about 91% instead of 10%). The preset math set the airtime limit 10x too loose; the home-screen meter showing "91% max duty cycle" was correctly reporting it (#161). This build fixes the presets AND automatically corrects devices whose settings exactly match a preset. If you tuned your airtime factor by hand, nothing is touched. + +# --- T-Pager ---------------------------------------------------------------------------- +The SD card is now actually used (#193). The card mounted and browsed fine but nothing stored to it - the storage feature was never enabled for this board. The Pager now gets the same "Store data on SD" option, boot storage selection and migration as the T-Deck. + +# --- Fixes across boards ---------------------------------------------------------------- +Message details now show the region scope for scoped direct messages, and no longer show "Scope 0000" for senders that carry the region in the alternate slot (#157). +The Discover app keeps the last-heard time on screen with long node names - names are trimmed and node types shown as single letters (#191). +The screen-unlock animation says WADAMESH instead of the old MESHCOMOD (#165). From 04d5f335752c4bbf6cc15b64a0444570b9cdaf40 Mon Sep 17 00:00:00 2001 From: mikecarper <mikecarper@gmail.com> Date: Sat, 1 Aug 2026 17:13:03 -0700 Subject: [PATCH 83/99] Retry messages until an echo is heard --- src/MyMesh.cpp | 616 ++++++++++++++++++++++++++- src/MyMesh.h | 85 +++- src/helpers/CompanionRetryPolicy.h | 64 +++ src/ui-touch/TouchSleep.cpp | 21 +- src/ui-touch/TouchSleep.h | 2 +- src/ui-touch/UITask.cpp | 21 +- test/test_companion_retry_policy.cpp | 55 +++ 7 files changed, 826 insertions(+), 38 deletions(-) create mode 100644 src/helpers/CompanionRetryPolicy.h create mode 100644 test/test_companion_retry_policy.cpp diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index 621c690..5d2be6f 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -13,6 +13,7 @@ #endif #endif #include <helpers/AdvertDataHelpers.h> +#include "helpers/CompanionRetryPolicy.h" #include <helpers/HttpOtaDisplayState.h> #include <helpers/RepeaterTcpOtaEmit.h> #include "WiFiConfig.h" @@ -1391,7 +1392,500 @@ static inline bool isMsgFloodType(uint8_t t) { return t == PAYLOAD_TYPE_TXT_MSG || t == PAYLOAD_TYPE_GRP_TXT; } +uint8_t MyMesh::companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[], + uint32_t scheduled_for[]) { + uint8_t count = 0; + int queue_idx = 0; + const uint32_t now = _ms->getMillis(); + while (queue_idx < _mgr->getOutboundTotal() + && count < COMPANION_TEXT_QUEUE_CAPACITY) { + mesh::Packet* packet = _mgr->getOutboundByIdx(queue_idx); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) { + queue_idx++; + continue; + } + + packet = _mgr->removeOutboundByIdx(queue_idx); + if (!packet) continue; + + packets[count] = packet; + priorities[count] = packet->isRouteDirect() ? 0 : 1; + scheduled_for[count] = now; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && slot.queued_packet == packet) { + priorities[count] = slot.priority; + scheduled_for[count] = slot.retry_at; + break; + } + } + count++; + } + return count; +} + +bool MyMesh::companionRestoreQueuedText(mesh::Packet* packets[], const uint8_t priorities[], + const uint32_t scheduled_for[], uint8_t count) { + bool restored_all = true; + for (uint8_t i = 0; i < count; i++) { + mesh::Packet* packet = packets[i]; + if (!packet) continue; + + _mgr->queueOutbound(packet, priorities[i], scheduled_for[i]); + bool found = false; + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) == packet) { + found = true; + break; + } + } + if (found) continue; + + // queueOutbound() returned a rejected packet to the pool. Retire any + // retry metadata that still referred to that pool object. + restored_all = false; + for (int j = 0; j < COMPANION_RETRY_SLOTS; j++) { + if (_companion_retries[j].active + && _companion_retries[j].queued_packet == packet) { + companionRetryResetSlot(j); + } + } + } + return restored_all; +} + +mesh::Packet* MyMesh::companionDetachQueuedTextByHash4( + uint32_t packet_hash4, uint8_t retry_key[MAX_HASH_SIZE]) { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + mesh::Packet* packet = _mgr->getOutboundByIdx(i); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) continue; + + uint8_t candidate_key[MAX_HASH_SIZE]; + uint32_t candidate_hash4 = 0; + packet->calculatePacketHash(candidate_key); + memcpy(&candidate_hash4, candidate_key, sizeof(candidate_hash4)); + if (candidate_hash4 != packet_hash4) continue; + + memcpy(retry_key, candidate_key, MAX_HASH_SIZE); + return _mgr->removeOutboundByIdx(i); + } + return nullptr; +} + +int MyMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint32_t* out_packet_hash4, TxtTxDebugInfo* out_dbg) { + if (!text) { + expected_ack = 0; + est_timeout = 0; + return MSG_SEND_FAILED; + } + + mesh::Packet* held[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint8_t held_priorities[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint32_t held_schedules[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + const uint8_t held_count = companionDetachQueuedText( + held, held_priorities, held_schedules); + + _last_plain_tx_meta_valid = false; + uint32_t packet_hash4 = 0; + int result = BaseChatMesh::sendMessage(recipient, timestamp, attempt, text, + expected_ack, est_timeout, + &packet_hash4, out_dbg); + + uint8_t retry_key[MAX_HASH_SIZE] = {}; + mesh::Packet* newest = result == MSG_SEND_FAILED + ? nullptr + : companionDetachQueuedTextByHash4(packet_hash4, retry_key); + + const bool restored_old = companionRestoreQueuedText( + held, held_priorities, held_schedules, held_count); + bool restored_new = newest != nullptr; + if (newest) { + mesh::Packet* one[] = {newest}; + const uint8_t priority[] = {static_cast<uint8_t>(newest->isRouteDirect() ? 0 : 1)}; + const uint32_t scheduled[] = {_ms->getMillis()}; + restored_new = companionRestoreQueuedText(one, priority, scheduled, 1); + } + + if (!restored_old) { + MESH_DEBUG_PRINTLN("%s MyMesh::sendMessage(): failed to restore queued TXT", getLogDateTime()); + } + if (result != MSG_SEND_FAILED && !restored_new) { + expected_ack = 0; + est_timeout = 0; + result = MSG_SEND_FAILED; + } + + if (out_packet_hash4) *out_packet_hash4 = packet_hash4; + if (result != MSG_SEND_FAILED) { + _last_plain_tx_ack = expected_ack; + memcpy(_last_plain_tx_retry_key, retry_key, sizeof(_last_plain_tx_retry_key)); + mesh::Utils::sha256(_last_plain_tx_fingerprint, + sizeof(_last_plain_tx_fingerprint), + recipient.id.pub_key, PUB_KEY_SIZE, + reinterpret_cast<const uint8_t*>(text), strlen(text)); + _last_plain_tx_meta_valid = true; + } + return result; +} + +int MyMesh::sendCommandData(const ContactInfo& recipient, uint32_t timestamp, + uint8_t attempt, const char* text, uint32_t& est_timeout, + uint32_t* out_packet_hash4, TxtTxDebugInfo* out_dbg) { + if (!text) { + est_timeout = 0; + return MSG_SEND_FAILED; + } + + mesh::Packet* held[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint8_t held_priorities[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + uint32_t held_schedules[COMPANION_TEXT_QUEUE_CAPACITY] = {}; + const uint8_t held_count = companionDetachQueuedText( + held, held_priorities, held_schedules); + + _last_plain_tx_meta_valid = false; + uint32_t packet_hash4 = 0; + int result = BaseChatMesh::sendCommandData(recipient, timestamp, attempt, text, + est_timeout, &packet_hash4, out_dbg); + + uint8_t retry_key[MAX_HASH_SIZE] = {}; + mesh::Packet* newest = result == MSG_SEND_FAILED + ? nullptr + : companionDetachQueuedTextByHash4(packet_hash4, retry_key); + + const bool restored_old = companionRestoreQueuedText( + held, held_priorities, held_schedules, held_count); + bool restored_new = newest != nullptr; + if (newest) { + mesh::Packet* one[] = {newest}; + const uint8_t priority[] = {static_cast<uint8_t>(newest->isRouteDirect() ? 0 : 1)}; + const uint32_t scheduled[] = {_ms->getMillis()}; + restored_new = companionRestoreQueuedText(one, priority, scheduled, 1); + } + + if (!restored_old) { + MESH_DEBUG_PRINTLN("%s MyMesh::sendCommandData(): failed to restore queued TXT", getLogDateTime()); + } + if (result != MSG_SEND_FAILED && !restored_new) { + est_timeout = 0; + result = MSG_SEND_FAILED; + } + if (out_packet_hash4) *out_packet_hash4 = packet_hash4; + return result; +} + +uint32_t MyMesh::companionRetryDelay(const mesh::Packet* packet, bool direct, + uint8_t attempt_idx) { + if (!packet || !_radio) return 0; + + const uint32_t packet_airtime = _radio->getEstAirtimeFor(packet->getRawLength()); + if (direct) { + return CompanionRetryPolicy::directDelay(packet_airtime, attempt_idx); + } + + const uint32_t max_packet_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT); + const uint32_t jitter_percent = getRNG()->nextInt(0, 201); + return CompanionRetryPolicy::floodDelay(max_packet_airtime, packet_airtime, + jitter_percent); +} + +void MyMesh::companionRetryResetSlot(int slot_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + if (slot.active && _active_companion_retries > 0) { + _active_companion_retries--; + } + slot.queued_packet = nullptr; + slot.retry_at = 0; + slot.retry_delay = 0; + slot.missing_since = 0; + memset(slot.retry_key, 0, sizeof(slot.retry_key)); + slot.attempts_sent = 0; + slot.max_attempts = 0; + slot.priority = 0; + slot.progress_marker = 0; + slot.payload_type = 0; + slot.direct = false; + slot.waiting_final_echo = false; + slot.active = false; +} + +void MyMesh::companionRetryCancelSlot(int slot_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + mesh::Packet* queued = slot.queued_packet; + if (slot.active && queued) { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) != queued) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + queued->calculatePacketHash(queued_key); + if (memcmp(queued_key, slot.retry_key, sizeof(queued_key)) == 0) { + mesh::Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) releasePacket(removed); + } + break; + } + } + companionRetryResetSlot(slot_idx); +} + +void MyMesh::companionRetryCancelKey(const uint8_t retry_key[MAX_HASH_SIZE]) { + if (!CompanionRetryPolicy::keyIsSet(retry_key, MAX_HASH_SIZE)) return; + + // First retire tracked retry trains. companionRetryCancelSlot() also removes + // their queued clone when it has not entered the radio yet. + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && CompanionRetryPolicy::keysEqual( + slot.retry_key, retry_key, MAX_HASH_SIZE)) { + companionRetryCancelSlot(i); + } + } + + // A semantic replacement can arrive before the original packet's first TX, + // before a CompanionRetrySlot exists. Remove that exact queued packet too. + for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) { + mesh::Packet* packet = _mgr->getOutboundByIdx(i); + if (!packet || packet->getPayloadType() != PAYLOAD_TYPE_TXT_MSG) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + packet->calculatePacketHash(queued_key); + if (!CompanionRetryPolicy::keysEqual( + queued_key, retry_key, MAX_HASH_SIZE)) continue; + + mesh::Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) releasePacket(removed); + } +} + +bool MyMesh::companionRetryQueueClone(int slot_idx, const mesh::Packet* packet, + uint8_t attempt_idx) { + if (slot_idx < 0 || slot_idx >= COMPANION_RETRY_SLOTS || !packet) return false; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + mesh::Packet* retry = obtainNewPacket(); + if (!retry) return false; + + *retry = *packet; // exact duplicate: timestamp and ciphertext stay fixed + slot.retry_delay = companionRetryDelay(packet, slot.direct, attempt_idx); + slot.retry_at = _ms->getMillis() + slot.retry_delay; + _mgr->queueOutbound(retry, slot.priority, slot.retry_at); + + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) == retry) { + slot.queued_packet = retry; + slot.missing_since = 0; + return true; + } + } + + // StaticPoolPacketManager already returned a rejected packet to its pool. + return false; +} + +void MyMesh::companionRetryStart(const mesh::Packet* packet, + const uint8_t retry_key[MAX_HASH_SIZE]) { + if (!packet || !retry_key) return; + + const uint8_t payload_type = packet->getPayloadType(); + const uint8_t path_count = packet->getPathHashCount(); + const bool text_from_self = packet->payload_len >= 2U * PATH_HASH_SIZE + && self_id.isHashMatch(&packet->payload[PATH_HASH_SIZE], PATH_HASH_SIZE); + + bool direct = false; + uint8_t max_attempts = 0; + uint8_t priority = 0; + if (packet->isRouteDirect() && payload_type == PAYLOAD_TYPE_TXT_MSG + && path_count > 0 && text_from_self) { + direct = true; + max_attempts = CompanionRetryPolicy::DIRECT_MAX_ATTEMPTS; + } else if (packet->isRouteFlood() && path_count == 0 + && ((payload_type == PAYLOAD_TYPE_TXT_MSG && text_from_self) + || payload_type == PAYLOAD_TYPE_GRP_TXT)) { + max_attempts = CompanionRetryPolicy::FLOOD_MAX_ATTEMPTS; + priority = 1; + } else { + return; + } + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (slot.active && slot.direct == direct + && memcmp(slot.retry_key, retry_key, MAX_HASH_SIZE) == 0) { + return; + } + } + + int slot_idx = -1; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + if (!_companion_retries[i].active) { + slot_idx = i; + break; + } + } + if (slot_idx < 0) return; + + CompanionRetrySlot& slot = _companion_retries[slot_idx]; + slot.queued_packet = nullptr; + slot.missing_since = 0; + memcpy(slot.retry_key, retry_key, MAX_HASH_SIZE); + slot.attempts_sent = 0; + slot.max_attempts = max_attempts; + slot.priority = priority; + slot.progress_marker = path_count; + slot.payload_type = payload_type; + slot.direct = direct; + slot.waiting_final_echo = false; + slot.active = true; + _active_companion_retries++; + if (!companionRetryQueueClone(slot_idx, packet, 0)) { + companionRetryResetSlot(slot_idx); + } +} + +void MyMesh::logTx(mesh::Packet* packet, int len) { + (void)len; + if (!packet) return; + + uint8_t packet_key[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_key); + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.queued_packet != packet) continue; + + // If a pool object was unexpectedly reused, do not mistake the new packet + // for this retry. + if (memcmp(slot.retry_key, packet_key, MAX_HASH_SIZE) != 0) { + companionRetryResetSlot(i); + break; + } + + slot.queued_packet = nullptr; + slot.missing_since = 0; + slot.attempts_sent++; + if (slot.attempts_sent >= slot.max_attempts) { + // Keep the metadata for one last echo window. Dispatcher releases the + // just-transmitted pool packet after this hook returns. + slot.waiting_final_echo = true; + slot.retry_at = _ms->getMillis() + slot.retry_delay; + } else if (!companionRetryQueueClone(i, packet, slot.attempts_sent)) { + companionRetryResetSlot(i); + } + return; + } + + companionRetryStart(packet, packet_key); +} + +void MyMesh::logTxFail(mesh::Packet* packet, int len) { + (void)len; + if (!packet) return; + + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.queued_packet != packet) continue; + + // Dispatcher owns and releases this in-flight packet after the hook. + slot.queued_packet = nullptr; + companionRetryResetSlot(i); + return; + } +} + +void MyMesh::companionRetryObserveRaw(const uint8_t raw[], int len) { + if (_active_companion_retries == 0 || !raw || len <= 0) return; + + const uint8_t payload_type = (raw[0] >> PH_TYPE_SHIFT) & PH_TYPE_MASK; + if (payload_type != PAYLOAD_TYPE_TXT_MSG && payload_type != PAYLOAD_TYPE_GRP_TXT) { + return; + } + + mesh::Packet packet; + if (!tryParsePacket(&packet, raw, len)) return; + + const bool direct = packet.isRouteDirect(); + if ((!direct && !packet.isRouteFlood()) + || (direct && payload_type != PAYLOAD_TYPE_TXT_MSG)) { + return; + } + + uint8_t retry_key[MAX_HASH_SIZE]; + packet.calculatePacketHash(retry_key); + const uint8_t received_path_count = packet.getPathHashCount(); + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active || slot.direct != direct + || memcmp(slot.retry_key, retry_key, MAX_HASH_SIZE) != 0) { + continue; + } + + const bool is_echo = direct + ? CompanionRetryPolicy::isDirectEcho(slot.progress_marker, received_path_count) + : CompanionRetryPolicy::isFloodEcho(slot.progress_marker, received_path_count); + if (is_echo) companionRetryCancelSlot(i); + } +} + +void MyMesh::companionRetryService() { + if (_active_companion_retries == 0) return; + + const uint32_t now = _ms->getMillis(); + const uint32_t missing_grace = 1000UL + + (2UL * _radio->getEstAirtimeFor(MAX_TRANS_UNIT)); + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active) continue; + + if (slot.waiting_final_echo) { + if (millisHasNowPassed(slot.retry_at)) companionRetryResetSlot(i); + continue; + } + + if (slot.queued_packet) { + bool found = false; + bool hash_matches = false; + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + mesh::Packet* queued = _mgr->getOutboundByIdx(j); + if (queued != slot.queued_packet) continue; + + uint8_t queued_key[MAX_HASH_SIZE]; + queued->calculatePacketHash(queued_key); + found = true; + hash_matches = memcmp(queued_key, slot.retry_key, MAX_HASH_SIZE) == 0; + break; + } + + if (found) { + if (!hash_matches) { + // The pool object was reused after another send removed our retry. + companionRetryResetSlot(i); + } else { + slot.missing_since = 0; + } + } else if (slot.missing_since == 0) { + // Usually this means Dispatcher moved the packet from the queue to the + // radio. Allow enough time for its normal TX-complete/fail callback. + slot.missing_since = now == 0 ? 1 : now; + } else if ((uint32_t)(now - slot.missing_since) > missing_grace) { + // The pool object is no longer ours, so only retire the metadata here. + companionRetryResetSlot(i); + } + continue; + } + + // A non-final active slot always owns one queued or in-flight clone. + companionRetryResetSlot(i); + } +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { + companionRetryObserveRaw(raw, len); + const int8_t snr_q4 = (int8_t)(snr * 4.0f); const uint32_t now_ms = millis(); // Parse route + path length (hop count). Header byte layout is @@ -1844,6 +2338,74 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); } +void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retry) { + if (cancel_retry) companionRetryCancelKey(entry.retry_key); + memset(&entry, 0, sizeof(entry)); +} + +MyMesh::AckTableEntry* MyMesh::findPendingTextMessage( + const uint8_t text_fingerprint[MAX_HASH_SIZE]) { + if (!text_fingerprint) return nullptr; + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + AckTableEntry& entry = expected_ack_table[i]; + if (CompanionRetryPolicy::shouldReplacePendingText( + entry.ack, entry.text_fingerprint, text_fingerprint, MAX_HASH_SIZE)) { + return &entry; + } + } + return nullptr; +} + +void MyMesh::uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]) { + if (expected_ack == 0 || !pub_key) return; + + // Transport retries of the same command frame reuse the ACK. Do not create + // another table entry or disturb the packet's existing retry ownership. + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + if (expected_ack_table[i].ack == expected_ack) { + _last_plain_tx_meta_valid = false; + return; + } + } + + ContactInfo* contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (!contact) { + _last_plain_tx_meta_valid = false; + return; + } + + const bool has_send_meta = _last_plain_tx_meta_valid + && _last_plain_tx_ack == expected_ack; + AckTableEntry* replacement = has_send_meta + ? findPendingTextMessage(_last_plain_tx_fingerprint) + : nullptr; + + AckTableEntry* entry; + if (replacement) { + // Only the same recipient+text supersedes an older pending message. + // Unrelated messages retain both their ACK record and retry train. + clearExpectedAck(*replacement, true); + entry = replacement; + } else { + entry = &expected_ack_table[next_ack_idx]; + // Circular-table eviction is bookkeeping only: do not cancel an unrelated + // message's lower-level retries merely because its ACK slot is reused. + clearExpectedAck(*entry, false); + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + } + + entry->msg_sent = _ms->getMillis(); + entry->ack = expected_ack; + entry->contact = contact; + if (has_send_meta) { + memcpy(entry->text_fingerprint, _last_plain_tx_fingerprint, + sizeof(entry->text_fingerprint)); + memcpy(entry->retry_key, _last_plain_tx_retry_key, + sizeof(entry->retry_key)); + } + _last_plain_tx_meta_valid = false; +} + ContactInfo* MyMesh::processAck(const uint8_t *data) { #if defined(DISPLAY_CLASS) // Diag: log every processAck call so we can see whether the ACK matching @@ -1858,7 +2420,7 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { #endif // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { - if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient + if (CompanionRetryPolicy::ackMatches(expected_ack_table[i].ack, data)) { out_frame[0] = PUSH_CODE_SEND_CONFIRMED; memcpy(&out_frame[1], data, 4); uint32_t trip_time = _ms->getMillis() - expected_ack_table[i].msg_sent; @@ -1874,9 +2436,11 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { } #endif - // NOTE: the same ACK can be received multiple times! - expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK - return expected_ack_table[i].contact; + // An ACK is stronger delivery evidence than a local repeater echo. Stop + // the exact retry train before clearing its bookkeeping entry. + ContactInfo* contact = expected_ack_table[i].contact; + clearExpectedAck(expected_ack_table[i], true); + return contact; } } return checkConnectionsAck(data); @@ -2589,6 +3153,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe app_target_ver = 0; clearPendingReqs(); _ui_pending_status = 0; + memset(expected_ack_table, 0, sizeof(expected_ack_table)); next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; @@ -3021,7 +3586,6 @@ void MyMesh::handleCmdFrame(size_t len) { _ui->appendDiag(line); } } - // TODO: add expected ACK to table if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { @@ -3030,10 +3594,7 @@ void MyMesh::handleCmdFrame(size_t len) { s_last_cmd_txt_est_timeout = est_timeout; } if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = recipient; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + uiRegisterExpectedAck(expected_ack, recipient->id.pub_key); } out_frame[0] = RESP_CODE_SENT; @@ -4496,6 +5057,7 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); + companionRetryService(); // Session keep-alives for logged-in servers (rooms). The core pinger sends the // 9-byte REQ_TYPE_KEEP_ALIVE (+ our sync_since) a room server expects; the ACK @@ -4662,7 +5224,37 @@ bool MyMesh::sendAdvert(bool flood) { return true; } -// To check if there is pending work -bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; +bool MyMesh::getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const { + if (_active_companion_retries == 0) return false; + + const uint32_t now = _ms->getMillis(); + bool found = false; + uint32_t shortest_delay = 0; + for (int i = 0; i < COMPANION_RETRY_SLOTS; i++) { + const CompanionRetrySlot& slot = _companion_retries[i]; + if (!slot.active) continue; + + const uint32_t candidate = CompanionRetryPolicy::wakeDelay(now, slot.retry_at); + if (!found || candidate < shortest_delay) { + shortest_delay = candidate; + found = true; + } + } + if (found) delay_millis = shortest_delay; + return found; +} + +// Future queue entries, retry echo windows, and contact-write timers are wake +// deadlines; they should not prevent the MCU's idle power-saving path. Report +// only work that is due now. An in-flight retry keeps retry_at in the past until +// its TX callback runs, so the CPU remains awake while the radio is transmitting. +bool MyMesh::hasPendingWork() const { + const uint32_t now = _ms->getMillis(); + if (_mgr->getOutboundCount(now) > 0) return true; + if (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) { + return true; + } + + uint32_t retry_delay = 0; + return getNextCompanionRetryWakeDelay(retry_delay) && retry_delay == 0; } diff --git a/src/MyMesh.h b/src/MyMesh.h index d1c65c8..a881059 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -130,6 +130,17 @@ public: void begin(bool has_display); void startInterface(BaseSerialInterface &serial); + // Keep WadaMesh's queued text packets intact around the pinned core's + // sendMessage/sendCommandData implementations. That core currently drops + // every queued TXT before enqueueing a new one; these wrappers restore FIFO + // behavior and let MyMesh replace only the same logical private message. + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr); + int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& est_timeout, + uint32_t* out_packet_hash4 = nullptr, TxtTxDebugInfo* out_dbg = nullptr); + const char *getNodeName(); NodePrefs *getNodePrefs(); uint32_t getBLEPin(); @@ -245,6 +256,8 @@ protected: void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logTx(mesh::Packet* packet, int len) override; + void logTxFail(mesh::Packet* packet, int len) override; bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -434,18 +447,7 @@ public: * onMessageAcked back to the UI. The companion-serial CMD_SEND_TXT_MSG * handler already does this for app-originated messages; the touch UI * reaches sendMessage directly and skipped this until now. */ - void uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]) { - if (expected_ack == 0) return; - ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (!c) return; - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = c; - // EXPECTED_ACK_TABLE_SIZE is #defined further down in this header next - // to the table itself; hard-code 8 here so this inline helper compiles - // wherever it's used. - next_ack_idx = (next_ack_idx + 1) % 8; - } + void uiRegisterExpectedAck(uint32_t expected_ack, const uint8_t pub_key[32]); // ---- "Repeats heard" for sent floods + route of the last received flood ---- // When we originate a flood TXT, repeaters re-broadcast it and our own radio @@ -970,6 +972,11 @@ public: // To check if there is pending work bool hasPendingWork() const; + /** Return the delay until the next companion retry/final-echo deadline. + * Future retries are wake deadlines, not work that should hold the CPU + * awake. Returns false when no retry train is active. */ + bool getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const; + // Number of companion clients currently connected on any transport. // Used by the idle light-sleep gate (TouchSleep) to confirm no one is // actively talking to us before the node parks in light sleep. @@ -981,6 +988,24 @@ public: bool isRadioReceiving() const { return _radio && _radio->isReceiving(); } private: + static const uint8_t COMPANION_TEXT_QUEUE_CAPACITY = 16; + uint8_t companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[], + uint32_t scheduled_for[]); + bool companionRestoreQueuedText(mesh::Packet* packets[], const uint8_t priorities[], + const uint32_t scheduled_for[], uint8_t count); + mesh::Packet* companionDetachQueuedTextByHash4(uint32_t packet_hash4, + uint8_t retry_key[MAX_HASH_SIZE]); + void companionRetryCancelKey(const uint8_t retry_key[MAX_HASH_SIZE]); + + void companionRetryObserveRaw(const uint8_t raw[], int len); + void companionRetryStart(const mesh::Packet* packet, const uint8_t retry_key[MAX_HASH_SIZE]); + void companionRetryService(); + void companionRetryCancelSlot(int slot_idx); + void companionRetryResetSlot(int slot_idx); + bool companionRetryQueueClone(int slot_idx, const mesh::Packet* packet, + uint8_t attempt_idx); + uint32_t companionRetryDelay(const mesh::Packet* packet, bool direct, uint8_t attempt_idx); + void writeOKFrame(); void writeErrFrame(uint8_t err_code); void writeDisabledFrame(); @@ -1143,17 +1168,47 @@ private: int proto_num_clients; struct AckTableEntry { - unsigned long msg_sent; - uint32_t ack; - ContactInfo* contact; + unsigned long msg_sent = 0; + uint32_t ack = 0; + ContactInfo* contact = nullptr; + uint8_t text_fingerprint[MAX_HASH_SIZE] = {}; + uint8_t retry_key[MAX_HASH_SIZE] = {}; }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; + AckTableEntry* findPendingTextMessage(const uint8_t text_fingerprint[MAX_HASH_SIZE]); + void clearExpectedAck(AckTableEntry& entry, bool cancel_retry); + + uint32_t _last_plain_tx_ack = 0; + uint8_t _last_plain_tx_fingerprint[MAX_HASH_SIZE] = {}; + uint8_t _last_plain_tx_retry_key[MAX_HASH_SIZE] = {}; + bool _last_plain_tx_meta_valid = false; #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table + // Client-side retry-until-echo state. Only one exact packet clone is retained + // in the outbound queue per active slot, matching the companion behavior. + static const uint8_t COMPANION_RETRY_SLOTS = 6; + struct CompanionRetrySlot { + mesh::Packet* queued_packet = nullptr; + uint32_t retry_at = 0; + uint32_t retry_delay = 0; + uint32_t missing_since = 0; + uint8_t retry_key[MAX_HASH_SIZE] = {}; + uint8_t attempts_sent = 0; + uint8_t max_attempts = 0; + uint8_t priority = 0; + uint8_t progress_marker = 0; + uint8_t payload_type = 0; + bool direct = false; + bool waiting_final_echo = false; + bool active = false; + }; + CompanionRetrySlot _companion_retries[COMPANION_RETRY_SLOTS]; + uint8_t _active_companion_retries = 0; + // One-shot auto-advert on boot. Recipients with auto-add ON pick up our // current pubkey, which is critical when the touch firmware regenerates // identity after a SPIFFS wipe (otherwise old contacts have stale pubkey diff --git a/src/helpers/CompanionRetryPolicy.h b/src/helpers/CompanionRetryPolicy.h new file mode 100644 index 0000000..826d120 --- /dev/null +++ b/src/helpers/CompanionRetryPolicy.h @@ -0,0 +1,64 @@ +#pragma once + +#include <stddef.h> +#include <stdint.h> +#include <string.h> + +namespace CompanionRetryPolicy { + +// These are the companion defaults from MeshCore's keymindCascade branch. +static const uint8_t DIRECT_MAX_ATTEMPTS = 21; +static const uint8_t FLOOD_MAX_ATTEMPTS = 15; + +inline uint32_t directDelay(uint32_t packet_airtime_ms, uint8_t attempt_idx) { + return 200UL + (7UL * packet_airtime_ms) + (100UL * attempt_idx); +} + +inline uint32_t floodDelay(uint32_t max_packet_airtime_ms, + uint32_t packet_airtime_ms, + uint32_t jitter_percent) { + return max_packet_airtime_ms + (20UL * packet_airtime_ms) + + ((packet_airtime_ms * jitter_percent) / 100UL); +} + +inline bool isDirectEcho(uint8_t original_path_count, uint8_t received_path_count) { + return received_path_count < original_path_count; +} + +inline bool isFloodEcho(uint8_t original_path_count, uint8_t received_path_count) { + return received_path_count > original_path_count; +} + +inline bool keyIsSet(const uint8_t* key, size_t key_size) { + if (!key) return false; + for (size_t i = 0; i < key_size; i++) { + if (key[i] != 0) return true; + } + return false; +} + +inline bool keysEqual(const uint8_t* first, const uint8_t* second, size_t key_size) { + return first && second && memcmp(first, second, key_size) == 0; +} + +inline bool shouldReplacePendingText(uint32_t pending_ack, + const uint8_t* pending_fingerprint, + const uint8_t* new_fingerprint, + size_t fingerprint_size) { + return pending_ack != 0 + && keysEqual(pending_fingerprint, new_fingerprint, fingerprint_size); +} + +inline bool ackMatches(uint32_t expected_ack, const uint8_t received_ack[4]) { + return expected_ack != 0 && received_ack + && memcmp(received_ack, &expected_ack, sizeof(expected_ack)) == 0; +} + +// Wrap-safe for deadlines less than INT32_MAX milliseconds away. Retry delays +// are measured in seconds, so they remain comfortably inside that window. +inline uint32_t wakeDelay(uint32_t now_millis, uint32_t deadline_millis) { + const int32_t signed_delay = static_cast<int32_t>(deadline_millis - now_millis); + return signed_delay > 0 ? static_cast<uint32_t>(signed_delay) : 0; +} + +} // namespace CompanionRetryPolicy diff --git a/src/ui-touch/TouchSleep.cpp b/src/ui-touch/TouchSleep.cpp index 85102c1..1a571b2 100644 --- a/src/ui-touch/TouchSleep.cpp +++ b/src/ui-touch/TouchSleep.cpp @@ -47,7 +47,7 @@ void onTransition(TransitionCb cb) { g_transition = cb; } void setEnabled(bool on) { g_enabled = on; } bool enabled() { return g_enabled; } -void loopEnd(uint32_t /*now_ms*/) { +void loopEnd(uint32_t now_ms) { const bool pass = gatePasses(); if (!pass) { if (g_asleep_regime) { g_asleep_regime = false; emitTransition(false); } // sun — resumed activity @@ -55,10 +55,23 @@ void loopEnd(uint32_t /*now_ms*/) { } if (!g_asleep_regime) { g_asleep_regime = true; emitTransition(true); } // moon — parked - // Yield the CPU to the idle task for THROTTLE_MS (replaces esp_light_sleep_start — - // see the note above). Standard FreeRTOS call: cannot starve a watchdog or hang. + // Yield the CPU to the idle task, capped by the earliest retry/UI deadline. + // This is the wake timer for these builds: unlike manual esp_light_sleep_start, + // a timed FreeRTOS block remains watchdog-safe and resumes the loop on schedule. + uint32_t park_ms = THROTTLE_MS; + if (g_hooks.nextWakeForcingDueMs) { + const uint32_t wake_due_ms = g_hooks.nextWakeForcingDueMs(now_ms); + if (wake_due_ms < park_ms) park_ms = wake_due_ms; + } + if (park_ms == 0) { + g_last_reason = WakeReason::Timer; + return; + } + const uint64_t t0 = esp_timer_get_time(); - vTaskDelay(pdMS_TO_TICKS(THROTTLE_MS)); + TickType_t park_ticks = pdMS_TO_TICKS(park_ms); + if (park_ticks == 0) park_ticks = 1; + vTaskDelay(park_ticks); g_acc_idle_us += (uint64_t)(esp_timer_get_time() - t0); g_cycle_count++; g_last_reason = WakeReason::Timer; // a throttle is a timed yield diff --git a/src/ui-touch/TouchSleep.h b/src/ui-touch/TouchSleep.h index 1360718..f93622c 100644 --- a/src/ui-touch/TouchSleep.h +++ b/src/ui-touch/TouchSleep.h @@ -20,7 +20,7 @@ struct Hooks { bool (*bleOff)(); // BLE fully off bool (*onBattery)(); // running on battery (no USB / charge source) bool (*meshIdle)(); // radio not mid-RX AND send queue empty - // ms until the soonest wake-forcing deadline (advert / clock alarm), + // ms until the soonest wake-forcing deadline (retry / advert / clock alarm), // or UINT32_MAX when nothing must wake us: uint32_t (*nextWakeForcingDueMs)(uint32_t now_ms); uint32_t (*epochNow)(); // wall-clock epoch seconds (for the event log) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 39fe12b..eabb778 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -15231,6 +15231,7 @@ static void actionSheetRangeTestCb(lv_event_t* e) { uint32_t ack_hash = 0; int r = the_mesh.sendMessage(c, ts, 0, "RangeTest \xe2\x80\x94 ACK?", ack_hash, est, &hash4); if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) { + the_mesh.uiRegisterExpectedAck(ack_hash, c.id.pub_key); markMeshRequest(); // status-bar async spinner g_lv.task->showAlert(r == MSG_SEND_SENT_DIRECT ? TR("RangeTest sent (direct)") : TR("RangeTest sent (flood)"), 1400); @@ -37697,15 +37698,23 @@ static bool tsBleOff() { // — batteryIsCharging(batteryMvSmoothed())). static bool tsOnBattery() { return !batteryIsCharging(batteryMvSmoothed()); } // tsMeshIdle: true when the radio is NOT mid-receive (preamble→RxDone race guarded), -// AND no outbound packets are queued / no dirty contacts expiry is pending. -// hasPendingWork() checks _mgr->getOutboundTotal() + dirty_contacts_expiry. +// AND no outbound packet / retry / contact write is due now. Future retry and +// write deadlines are allowed to use the timed idle-power-saving path. // isRadioReceiving() delegates to Dispatcher::_radio->isReceiving() (RadioLibWrapper override). static bool tsMeshIdle() { return !the_mesh.hasPendingWork() && !the_mesh.isRadioReceiving(); } -// tsNextWakeForcingDueMs: advert/sig-probe is the only wake-forcing deadline today; -// clock alarms TBD. s_sig_probe_at is promoted to file scope so we can read it here. +// Bound the timed idle park by the earliest UI probe or companion retry. static uint32_t tsNextWakeForcingDueMs(uint32_t now_ms) { - if (s_sig_probe_at == 0) return UINT32_MAX; - return (uint32_t)(s_sig_probe_at > now_ms ? (s_sig_probe_at - now_ms) : 0); + uint32_t next_delay = UINT32_MAX; + if (s_sig_probe_at != 0) { + next_delay = (uint32_t)(s_sig_probe_at > now_ms ? (s_sig_probe_at - now_ms) : 0); + } + + uint32_t retry_delay = 0; + if (the_mesh.getNextCompanionRetryWakeDelay(retry_delay) + && retry_delay < next_delay) { + next_delay = retry_delay; + } + return next_delay; } static uint32_t tsEpochNow() { return (uint32_t)time(nullptr); } diff --git a/test/test_companion_retry_policy.cpp b/test/test_companion_retry_policy.cpp new file mode 100644 index 0000000..96da551 --- /dev/null +++ b/test/test_companion_retry_policy.cpp @@ -0,0 +1,55 @@ +#include <assert.h> + +#include "helpers/CompanionRetryPolicy.h" + +int main() { + using namespace CompanionRetryPolicy; + + static_assert(DIRECT_MAX_ATTEMPTS == 21, "direct retry count changed"); + static_assert(FLOOD_MAX_ATTEMPTS == 15, "flood retry count changed"); + + assert(directDelay(100, 0) == 900); + assert(directDelay(100, 20) == 2900); + + assert(floodDelay(300, 100, 0) == 2300); + assert(floodDelay(300, 100, 100) == 2400); + assert(floodDelay(300, 100, 200) == 2500); + + assert(isDirectEcho(5, 4)); + assert(isDirectEcho(5, 0)); + assert(!isDirectEcho(5, 5)); + assert(!isDirectEcho(5, 6)); + + assert(isFloodEcho(0, 1)); + assert(isFloodEcho(2, 5)); + assert(!isFloodEcho(2, 2)); + assert(!isFloodEcho(2, 1)); + + const uint8_t empty_key[8] = {}; + const uint8_t retry_key[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + const uint8_t same_retry_key[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + const uint8_t prefix_collision[8] = {1, 2, 3, 4, 5, 6, 7, 9}; + assert(!keyIsSet(empty_key, sizeof(empty_key))); + assert(keyIsSet(retry_key, sizeof(retry_key))); + assert(keysEqual(retry_key, same_retry_key, sizeof(retry_key))); + assert(!keysEqual(retry_key, prefix_collision, sizeof(retry_key))); + + assert(shouldReplacePendingText(123, retry_key, same_retry_key, + sizeof(retry_key))); + assert(!shouldReplacePendingText(123, retry_key, prefix_collision, + sizeof(retry_key))); + assert(!shouldReplacePendingText(0, retry_key, same_retry_key, + sizeof(retry_key))); + + uint32_t expected_ack = 0x12345678UL; + uint8_t received_ack[4]; + memcpy(received_ack, &expected_ack, sizeof(received_ack)); + assert(ackMatches(expected_ack, received_ack)); + assert(!ackMatches(0, empty_key)); + + assert(wakeDelay(1000, 1500) == 500); + assert(wakeDelay(1500, 1500) == 0); + assert(wakeDelay(1600, 1500) == 0); + assert(wakeDelay(0xFFFFFFF0UL, 0x00000020UL) == 48); + return 0; +} From 5604bfa697080235055d203bce08f11ae674e0a8 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 20:33:03 -0700 Subject: [PATCH 84/99] fix(tpager): use vendor TCXO voltage Signed-off-by: Pixel Perfect <me@pixp.cc> --- platformio.ini | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/platformio.ini b/platformio.ini index e7a3e10..824c84b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -922,13 +922,13 @@ build_flags = -D SX126X_DIO2_AS_RF_SWITCH=true -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 - ; Deliberately NOT setting SX126X_DIO3_TCXO_VOLTAGE (unlike the T-Deck's - ; 1.8f) -- that IS a board-specific analog value (this board's actual TCXO - ; circuit), and trail-mate's own working reference for this exact PCB never - ; overrides it either, relying on CustomSX1262::std_init()'s built-in 1.6V - ; fallback (RadioLib's own SX1262::begin() default) -- which also already - ; auto-retries at 0.0f on an SPI-cmd failure if 1.6V doesn't suit this board's - ; real hardware. Revisit with a measured value if bring-up shows otherwise. + ; LilyGoLib's SX1262 transmit/receive examples for this exact Pager explicitly + ; drive the module's DIO3 TCXO supply at 3.0 V. The generic CustomSX1262 + ; fallback is only 1.6 V; that can leave this board's oscillator outside its + ; specified supply range while still working at very short range. Pass the + ; board value into begin() so calibration and all later RX/TX use the correct + ; reference from the start. + -D SX126X_DIO3_TCXO_VOLTAGE=3.0f -D P_LORA_DIO_1=14 -D P_LORA_NSS=36 -D P_LORA_RESET=47 From 603217312e2295c88aec1f643baf6e27ce12bd46 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 20:33:04 -0700 Subject: [PATCH 85/99] fix(storage): commit rooted preference updates Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/DataStore.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/DataStore.cpp b/src/DataStore.cpp index c645cf2..55b1395 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -426,8 +426,17 @@ bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ _fs->remove(_rp("/new_prefs.tmp")); // short write (storage full?) — keep the old main file return false; } - _fs->remove(_rp("/new_prefs")); - return _fs->rename(_rp("/new_prefs.tmp"), _rp("/new_prefs")); + // _rp() returns a pointer to one shared scratch buffer when the store has + // a root prefix (for example SD:/meshcomod). Calling it twice in rename() + // therefore aliases both arguments to whichever path was formatted last, + // leaving new_prefs.tmp stranded even though the data itself was written. + // Keep the source and destination in independent buffers for the swap. + char tmp_path[48]; + char live_path[48]; + snprintf(tmp_path, sizeof(tmp_path), "%s/new_prefs.tmp", _root); + snprintf(live_path, sizeof(live_path), "%s/new_prefs", _root); + _fs->remove(live_path); + return _fs->rename(tmp_path, live_path); } return false; } From 9202ad0a10509f4ca993636e579eba93934d8856 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 20:33:06 -0700 Subject: [PATCH 86/99] fix(storage): preserve loop watchdog state Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/WdtHeavyGuard.h | 15 +++++ src/ui-touch/UITask.cpp | 108 +++++++++++++++++------------- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index f7fbe4c..d39df54 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -27,13 +27,28 @@ // ----------------------------------------------------------------------------- #if defined(ESP32) #include <Arduino.h> // disableCore0WDT / enableCore0WDT +#include <esp_task_wdt.h> inline int& _wdtHeavyDepth() { static int d = 0; return d; } inline void wdtHeavyBegin() { int& d = _wdtHeavyDepth(); if (d++ == 0) disableCore0WDT(); } inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) enableCore0WDT(); } + +// Arduino's enableLoopWDT() subscribes loopTask even when it was not watched +// before. Preserve the caller's actual task-WDT state around slow filesystem +// work instead of silently arming a new watchdog on the way out. +struct LoopWdtGuard { + bool subscribed; + LoopWdtGuard() : subscribed(esp_task_wdt_status(nullptr) == ESP_OK) { + if (subscribed) disableLoopWDT(); + } + ~LoopWdtGuard() { + if (subscribed) enableLoopWDT(); + } +}; #else inline void wdtHeavyBegin() {} inline void wdtHeavyEnd() {} +struct LoopWdtGuard {}; #endif struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } }; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 39fe12b..a8771e7 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -10826,9 +10826,11 @@ static void sdRestoreApply() { // Land everything in RAM first — this path used to reboot WITHOUT persisting, // silently dropping every message since the last lazy flush. g_lv.task->persistHistoryNow(); - disableLoopWDT(); - const bool ok = meshcomodMigrateSpiffsToSd(true); - enableLoopWDT(); + bool ok = false; + { + LoopWdtGuard loop_wdt_guard; + ok = meshcomodMigrateSpiffsToSd(true); + } if (!ok) { g_lv.task->showAlert(TR("Copy failed (no internal identity)"), 2200); return; } meshcomodClearSdMigLatch(); // manual copy succeeded -> re-arm boot auto-adoption (GH #142/#148) touchPrefsSetUseSdStorage(true); // make sure the reboot adopts the card @@ -18731,30 +18733,31 @@ static bool fmSdTryMount() { const int kAttempts = (int)(sizeof(kMountLadder) / sizeof(kMountLadder[0])); bool mounted = false; uint32_t mounted_hz = 0; - disableLoopWDT(); - for (int attempt = 0; attempt < kAttempts; ++attempt) { - SD.end(); - delay(kMountLadder[attempt].settle_ms); - if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { - mounted = true; - mounted_hz = kMountLadder[attempt].hz; - break; - } - } - // Renegotiate upward after a slow-rung success (GH #194): the succeeding rung's clock used - // to become the SESSION clock, so a card whose wake-up needed 400 kHz served contacts, - // history and tiles at ~25 KB/s forever after ("slower is fine" above predates the SD being - // the primary store). Init slow, then raise — fall back to the proven clock if 4 MHz fails. - if (mounted && mounted_hz < 4000000) { - SD.end(); - delay(60); - if (!(SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE)) { + { + LoopWdtGuard loop_wdt_guard; + for (int attempt = 0; attempt < kAttempts; ++attempt) { SD.end(); - delay(120); - mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + delay(kMountLadder[attempt].settle_ms); + if (SD.begin(PIN_SD_CS, *spi, kMountLadder[attempt].hz, "/sd", 6) && SD.cardType() != CARD_NONE) { + mounted = true; + mounted_hz = kMountLadder[attempt].hz; + break; + } + } + // Renegotiate upward after a slow-rung success (GH #194): the succeeding rung's clock used + // to become the SESSION clock, so a card whose wake-up needed 400 kHz served contacts, + // history and tiles at ~25 KB/s forever after ("slower is fine" above predates the SD being + // the primary store). Init slow, then raise — fall back to the proven clock if 4 MHz fails. + if (mounted && mounted_hz < 4000000) { + SD.end(); + delay(60); + if (!(SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE)) { + SD.end(); + delay(120); + mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE; + } } } - enableLoopWDT(); if (mounted) { s_sd_mounted = true; s_sd_size = SD.cardSize(); @@ -35997,6 +36000,8 @@ static void powerOffCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring @@ -36047,6 +36052,8 @@ static void powerDownloadCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; if (g_lv.task) { g_lv.task->persistHistoryNow(); // flush chat before we go down discoveredFlushNow(); // and the Discovered ring @@ -46060,6 +46067,8 @@ void UITask::persistHistoryNow() { } void UITask::rebootDevice() { + WdtHeavyGuard idle_wdt_guard; + LoopWdtGuard loop_wdt_guard; // Persist chat history synchronously before we reboot — the periodic // flush is rate-capped, so without this a reboot could drop the most // recent chat history. @@ -47426,32 +47435,33 @@ void UITask::loop() { // watchdog around it (CPU0 idle keeps running, so no reset) to avoid a // mid-format reset that would corrupt the card. if (s_sd_format_pending && --s_sd_format_pending == 0) { - disableLoopWDT(); - SPIClass* spi = tdeckSharedSPI(); bool ok = false; - if (spi) { - // Explicit reformat (not SD.begin's format_if_empty, which only formats an - // already-unreadable card): init the card to register the FatFs diskio at - // pdrv (no mount), f_mkfs to FAT32, write the MESHCOMOD label by hand while - // unmounted, release, then remount the fresh FS via SD.begin. Works on any - // card (FAT32, exFAT, blank) since f_mkfs hardware-inits + overwrites it. - SD.end(); // drop any existing mount - uint8_t pdrv = sdcard_init(PIN_SD_CS, spi, 4000000); - if (pdrv != 0xFF) { - char drv[3] = { (char)('0' + pdrv), ':', 0 }; - void* work = malloc(4096); // f_mkfs scratch (>= FF_MAX_SS) - if (work) { - if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { - sdWriteFatLabel(pdrv, "MESHCOMOD"); - ok = true; + { + LoopWdtGuard loop_wdt_guard; + SPIClass* spi = tdeckSharedSPI(); + if (spi) { + // Explicit reformat (not SD.begin's format_if_empty, which only formats an + // already-unreadable card): init the card to register the FatFs diskio at + // pdrv (no mount), f_mkfs to FAT32, write the MESHCOMOD label by hand while + // unmounted, release, then remount the fresh FS via SD.begin. Works on any + // card (FAT32, exFAT, blank) since f_mkfs hardware-inits + overwrites it. + SD.end(); // drop any existing mount + uint8_t pdrv = sdcard_init(PIN_SD_CS, spi, 4000000); + if (pdrv != 0xFF) { + char drv[3] = { (char)('0' + pdrv), ':', 0 }; + void* work = malloc(4096); // f_mkfs scratch (>= FF_MAX_SS) + if (work) { + if (f_mkfs(drv, MC_FM_FAT32, 0, work, 4096) == 0 /*FR_OK*/) { + sdWriteFatLabel(pdrv, "MESHCOMOD"); + ok = true; + } + free(work); } - free(work); + sdcard_uninit(pdrv); } - sdcard_uninit(pdrv); + if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); } - if (ok) ok = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6); } - enableLoopWDT(); fmHideFormatOverlay(); if (ok && SD.cardType() != CARD_NONE) { s_sd_mounted = true; @@ -47476,9 +47486,11 @@ void UITask::loop() { } // Deferred copy/move (paste). WDT off — a big tree can take a while. if (s_fm_paste_pending && --s_fm_paste_pending == 0) { - disableLoopWDT(); - bool ok = fmDoPaste(); - enableLoopWDT(); + bool ok = false; + { + LoopWdtGuard loop_wdt_guard; + ok = fmDoPaste(); + } fmHideFormatOverlay(); showAlert(ok ? TR("Pasted") : TR("Paste failed"), ok ? 1500 : 2400); if (s_fm_list) fmRefresh(); From 958ace84aa6256ccf8a1c4ae1dcace6fa30ff06f Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 20:37:01 -0700 Subject: [PATCH 87/99] fix(storage): make file-backed preferences concurrency-safe Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 144 +++++++++++++++++++++---------- src/helpers/esp32/SdNvsPrefs.h | 2 +- 2 files changed, 99 insertions(+), 47 deletions(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index ffee892..5df56a0 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -218,6 +218,27 @@ static FileCache* findCacheLocked(const char* path) { return nullptr; } +// File-mode caches are shared by every SdNvsPrefs instance and by both the UI +// and worker cores. Copy a value while holding the cache mutex; returning a Kv* +// would let a concurrent setter invalidate the vector element or its payload as +// soon as the lock was released. +static bool copyCachedValue(const char* path, const char* key, std::vector<uint8_t>& value) { + value.clear(); + if (!s_cache_mutex || !path || !key) return false; + bool found = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(path)) { + for (const auto& e : cache->kv) { + if (strncmp(e.key, key, sizeof(e.key)) != 0) continue; + value = e.val; + found = true; + break; + } + } + xSemaphoreGive(s_cache_mutex); + return found; +} + static void prefsWriterTask(void*) { for (;;) { WriteJob* job = nullptr; @@ -331,18 +352,10 @@ void SdNvsPrefs::end() { // ----------------------------- file helpers ----------------------------- SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) { - if (fileMode()) { - if (!s_cache_mutex) return nullptr; - xSemaphoreTake(s_cache_mutex, portMAX_DELAY); - FileCache* cache = findCacheLocked(_path); - Kv* found = nullptr; - if (cache) { - for (auto& e : cache->kv) - if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } - } - xSemaphoreGive(s_cache_mutex); - return found; - } + // Shared file-mode entries must never escape s_cache_mutex. File-mode + // callers use copyCachedValue() instead; this pointer helper is only for the + // legacy per-instance vector below. + if (fileMode()) return nullptr; for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e; return nullptr; } @@ -496,7 +509,7 @@ static bool scheduleOne(uint32_t now, bool force) { job->revision = cache.revision; job->slot = cache.active_slot == 'a' ? 'b' : cache.active_slot == 'b' ? 'a' - : cache.legacy_present ? 'b' : 'a'; + : 'b'; // first snapshot must not overwrite the legacy .kv path cache.writing = true; strncpy(armed_path, cache.legacy_path, sizeof(armed_path) - 1); break; @@ -521,6 +534,7 @@ void SdNvsPrefs::tick(uint32_t now_ms) { bool SdNvsPrefs::flush(uint32_t timeout_ms) { if (!fileMode()) return true; + if (!ensureWorker()) return false; const uint32_t start = millis(); for (;;) { scheduleOne(millis(), true); @@ -594,7 +608,7 @@ bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, std::vector<Kv> kv; uint32_t generation = 0; char active = 0; - const bool existed = loadExplicitNamespace(fs, legacy_path, kv, generation, active); + loadExplicitNamespace(fs, legacy_path, kv, generation, active); Kv* found = nullptr; for (auto& e : kv) if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } @@ -604,17 +618,28 @@ bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, strncpy(found->key, key, sizeof(found->key) - 1); } found->val.assign(1, value ? 1 : 0); - const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : existed ? 'b' : 'a'; + const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : 'b'; if (!writeSnapshot(fs, legacy_path, slot, generation + 1, kv)) return false; return true; } -uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) { - Kv* e = sdFind(key); - if (!e || e->val.empty()) return def; +uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width, bool* found) { + std::vector<uint8_t> cached; + const std::vector<uint8_t>* value = nullptr; + if (fileMode()) { + const bool exists = copyCachedValue(_path, key, cached); + if (found) *found = exists; + if (!exists || cached.empty()) return def; + value = &cached; + } else { + Kv* e = sdFind(key); + if (found) *found = e != nullptr; + if (!e || e->val.empty()) return def; + value = &e->val; + } uint64_t v = 0; - int n = (int)e->val.size(); if (n > width) n = width; - for (int i = 0; i < n; i++) v |= (uint64_t)e->val[i] << (8 * i); + int n = (int)value->size(); if (n > width) n = width; + for (int i = 0; i < n; i++) v |= (uint64_t)(*value)[i] << (8 * i); return v; } @@ -630,7 +655,10 @@ size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) { // ever touch the file — nothing new is written to NVS. bool SdNvsPrefs::isKey(const char* key) { - if (fileMode()) return sdFind(key) != nullptr || (_nvs_open && _nvs.isKey(key)); + if (fileMode()) { + std::vector<uint8_t> value; + return copyCachedValue(_path, key, value) || (_nvs_open && _nvs.isKey(key)); + } return useNvs() ? (_nvs_open && _nvs.isKey(key)) : (sdFind(key) != nullptr); } @@ -704,7 +732,9 @@ bool SdNvsPrefs::clear() { uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) { if (fileMode()) { - if (sdFind(k)) return (uint8_t)sdGetInt(k, def, 1); + bool found = false; + const uint8_t value = (uint8_t)sdGetInt(k, def, 1, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUChar(k, def); return def; } @@ -716,7 +746,9 @@ size_t SdNvsPrefs::putUChar(const char* k, uint8_t v) { } int8_t SdNvsPrefs::getChar(const char* k, int8_t def) { if (fileMode()) { - if (sdFind(k)) return (int8_t)sdGetInt(k, (uint8_t)def, 1); + bool found = false; + const int8_t value = (int8_t)sdGetInt(k, (uint8_t)def, 1, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getChar(k, def); return def; } @@ -728,7 +760,9 @@ size_t SdNvsPrefs::putChar(const char* k, int8_t v) { } uint16_t SdNvsPrefs::getUShort(const char* k, uint16_t def) { if (fileMode()) { - if (sdFind(k)) return (uint16_t)sdGetInt(k, def, 2); + bool found = false; + const uint16_t value = (uint16_t)sdGetInt(k, def, 2, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUShort(k, def); return def; } @@ -740,7 +774,9 @@ size_t SdNvsPrefs::putUShort(const char* k, uint16_t v) { } uint32_t SdNvsPrefs::getUInt(const char* k, uint32_t def) { if (fileMode()) { - if (sdFind(k)) return (uint32_t)sdGetInt(k, def, 4); + bool found = false; + const uint32_t value = (uint32_t)sdGetInt(k, def, 4, &found); + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getUInt(k, def); return def; } @@ -752,7 +788,9 @@ size_t SdNvsPrefs::putUInt(const char* k, uint32_t v) { } bool SdNvsPrefs::getBool(const char* k, bool def) { if (fileMode()) { - if (sdFind(k)) return sdGetInt(k, def ? 1 : 0, 1) != 0; + bool found = false; + const bool value = sdGetInt(k, def ? 1 : 0, 1, &found) != 0; + if (found) return value; if (_nvs_open && _nvs.isKey(k)) return _nvs.getBool(k, def); return def; } @@ -764,29 +802,38 @@ size_t SdNvsPrefs::putBool(const char* k, bool v) { } String SdNvsPrefs::getString(const char* k, const String& def) { - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; - } else { - if (useNvs()) return _nvs.getString(k, def); - e = sdFind(k); - if (!e) return def; + std::vector<uint8_t> value; + if (!copyCachedValue(_path, k, value)) + return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; + String s; s.reserve(value.size()); + for (uint8_t c : value) s += (char)c; + return s; } + if (useNvs()) return _nvs.getString(k, def); + Kv* e = sdFind(k); + if (!e) return def; String s; s.reserve(e->val.size()); for (uint8_t c : e->val) s += (char)c; return s; } size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { if (!buf || !maxLen) return 0; - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) { if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); buf[0] = '\0'; return 0; } - } else { - if (useNvs()) return _nvs.getString(k, buf, maxLen); - e = sdFind(k); + std::vector<uint8_t> value; + if (!copyCachedValue(_path, k, value)) { + if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); + buf[0] = '\0'; + return 0; + } + size_t n = value.size(); + if (n > maxLen - 1) n = maxLen - 1; + if (n) memcpy(buf, value.data(), n); + buf[n] = '\0'; + return n; } + if (useNvs()) return _nvs.getString(k, buf, maxLen); + Kv* e = sdFind(k); size_t n = e ? e->val.size() : 0; if (n > maxLen - 1) n = maxLen - 1; if (e && n) memcpy(buf, e->val.data(), n); @@ -800,15 +847,20 @@ size_t SdNvsPrefs::putString(const char* k, const char* v) { } size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { - Kv* e = nullptr; if (fileMode()) { - e = sdFind(k); - if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0; - } else { - if (useNvs()) return _nvs.getBytes(k, buf, maxLen); - e = sdFind(k); - if (!e) return 0; + std::vector<uint8_t> value; + if (!copyCachedValue(_path, k, value)) + return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0; + const size_t n = value.size(); + if (buf && maxLen) { + const size_t copy_len = n > maxLen ? maxLen : n; + if (copy_len) memcpy(buf, value.data(), copy_len); + } + return n; } + if (useNvs()) return _nvs.getBytes(k, buf, maxLen); + Kv* e = sdFind(k); + if (!e) return 0; size_t n = e->val.size(); if (buf && maxLen) { size_t c = n > maxLen ? maxLen : n; memcpy(buf, e->val.data(), c); } return n; diff --git a/src/helpers/esp32/SdNvsPrefs.h b/src/helpers/esp32/SdNvsPrefs.h index 0284078..7be0f36 100644 --- a/src/helpers/esp32/SdNvsPrefs.h +++ b/src/helpers/esp32/SdNvsPrefs.h @@ -86,7 +86,7 @@ private: bool sdSet(const char* key, const uint8_t* data, size_t len); bool sdLoad(); bool sdSave(); // queue current RAM mirror (no filesystem I/O) - uint64_t sdGetInt(const char* key, uint64_t def, int width); + uint64_t sdGetInt(const char* key, uint64_t def, int width, bool* found = nullptr); size_t sdPutInt(const char* key, uint64_t v, int width); }; From 1e6a6b4b35aed6629b56a98a225269f97d2562a8 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 21:20:48 -0700 Subject: [PATCH 88/99] fix(storage): inspect the Arduino loop task watchdog Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/WdtHeavyGuard.h | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index d39df54..b8d0c34 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -35,10 +35,14 @@ inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) // Arduino's enableLoopWDT() subscribes loopTask even when it was not watched // before. Preserve the caller's actual task-WDT state around slow filesystem -// work instead of silently arming a new watchdog on the way out. +// work instead of silently arming a new watchdog on the way out. The guard can +// be constructed by a storage worker, so query Arduino's loopTask handle rather +// than `nullptr` (which means the current worker task to ESP-IDF). +extern TaskHandle_t loopTaskHandle; struct LoopWdtGuard { bool subscribed; - LoopWdtGuard() : subscribed(esp_task_wdt_status(nullptr) == ESP_OK) { + LoopWdtGuard() : subscribed(loopTaskHandle != nullptr && + esp_task_wdt_status(loopTaskHandle) == ESP_OK) { if (subscribed) disableLoopWDT(); } ~LoopWdtGuard() { From 8252e43b320edfeea5aeb3a9817698c6f48e2e2a Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 21:20:48 -0700 Subject: [PATCH 89/99] fix(storage): deduplicate concurrent preference loads Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 5df56a0..c70b470 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -470,6 +470,14 @@ bool SdNvsPrefs::sdLoad() { ++cache.revision; } xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + // Another task may have loaded this namespace while file I/O ran outside the + // mutex. Keep a single authoritative cache entry instead of inserting an + // invisible duplicate that the writer would still iterate. + if (FileCache* existing = findCacheLocked(_path)) { + const bool ok = existing->loaded; + xSemaphoreGive(s_cache_mutex); + return ok; + } s_caches.push_back(std::move(cache)); xSemaphoreGive(s_cache_mutex); return true; @@ -509,7 +517,7 @@ static bool scheduleOne(uint32_t now, bool force) { job->revision = cache.revision; job->slot = cache.active_slot == 'a' ? 'b' : cache.active_slot == 'b' ? 'a' - : 'b'; // first snapshot must not overwrite the legacy .kv path + : cache.legacy_present ? 'b' : 'a'; cache.writing = true; strncpy(armed_path, cache.legacy_path, sizeof(armed_path) - 1); break; @@ -608,7 +616,7 @@ bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, std::vector<Kv> kv; uint32_t generation = 0; char active = 0; - loadExplicitNamespace(fs, legacy_path, kv, generation, active); + const bool existed = loadExplicitNamespace(fs, legacy_path, kv, generation, active); Kv* found = nullptr; for (auto& e : kv) if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; } @@ -618,7 +626,7 @@ bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, strncpy(found->key, key, sizeof(found->key) - 1); } found->val.assign(1, value ? 1 : 0); - const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : 'b'; + const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : existed ? 'b' : 'a'; if (!writeSnapshot(fs, legacy_path, slot, generation + 1, kv)) return false; return true; } From a92afa191c8258aebfc93d37e50ba73c2c8476e2 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 21:59:17 -0700 Subject: [PATCH 90/99] fix(storage): gate loop watchdog APIs in IDF apps Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/WdtHeavyGuard.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index b8d0c34..8e6d646 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -38,6 +38,7 @@ inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) // work instead of silently arming a new watchdog on the way out. The guard can // be constructed by a storage worker, so query Arduino's loopTask handle rather // than `nullptr` (which means the current worker task to ESP-IDF). +#if defined(CONFIG_AUTOSTART_ARDUINO) extern TaskHandle_t loopTaskHandle; struct LoopWdtGuard { bool subscribed; @@ -50,6 +51,11 @@ struct LoopWdtGuard { } }; #else +// Tanmatsu and T-Display-P4 supply app_main() themselves. Their Arduino +// component has no loopTaskHandle or loop-WDT helpers to preserve. +struct LoopWdtGuard {}; +#endif +#else inline void wdtHeavyBegin() {} inline void wdtHeavyEnd() {} struct LoopWdtGuard {}; From 7f4776e20d936c3581d4334d39203e18ba5dcfe1 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 22:03:33 -0700 Subject: [PATCH 91/99] fix(storage): let clean preference flushes succeed Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index c70b470..49fb07f 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -542,7 +542,19 @@ void SdNvsPrefs::tick(uint32_t now_ms) { bool SdNvsPrefs::flush(uint32_t timeout_ms) { if (!fileMode()) return true; - if (!ensureWorker()) return false; + if (!ensureWorker()) { + // A writer-allocation failure is only a flush failure when RAM contains + // work that still needs landing. A clean store has nothing to wait for and + // must not permanently block unrelated migration/reboot workflows. + if (!s_cache_mutex) return true; + bool pending = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + for (const auto& cache : s_caches) { + if (cache.dirty || cache.writing) { pending = true; break; } + } + xSemaphoreGive(s_cache_mutex); + return !pending; + } const uint32_t start = millis(); for (;;) { scheduleOne(millis(), true); From 42a545a570e07cb787f14d627dc9fc48e49ee614 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 22:34:41 -0700 Subject: [PATCH 92/99] fix(storage): keep IDF watchdog guards nontrivial Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/WdtHeavyGuard.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index 8e6d646..0b9972d 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -53,12 +53,12 @@ struct LoopWdtGuard { #else // Tanmatsu and T-Display-P4 supply app_main() themselves. Their Arduino // component has no loopTaskHandle or loop-WDT helpers to preserve. -struct LoopWdtGuard {}; +struct LoopWdtGuard { LoopWdtGuard() {} ~LoopWdtGuard() {} }; #endif #else inline void wdtHeavyBegin() {} inline void wdtHeavyEnd() {} -struct LoopWdtGuard {}; +struct LoopWdtGuard { LoopWdtGuard() {} ~LoopWdtGuard() {} }; #endif struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } }; From 4418b1f598c95c4677456a944db6b6746890f2e9 Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sat, 1 Aug 2026 23:57:39 -0700 Subject: [PATCH 93/99] fix(prefs): avoid heap churn on cached reads Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/SdNvsPrefs.cpp | 86 ++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/src/helpers/esp32/SdNvsPrefs.cpp b/src/helpers/esp32/SdNvsPrefs.cpp index 49fb07f..19f5808 100644 --- a/src/helpers/esp32/SdNvsPrefs.cpp +++ b/src/helpers/esp32/SdNvsPrefs.cpp @@ -219,18 +219,23 @@ static FileCache* findCacheLocked(const char* path) { } // File-mode caches are shared by every SdNvsPrefs instance and by both the UI -// and worker cores. Copy a value while holding the cache mutex; returning a Kv* -// would let a concurrent setter invalidate the vector element or its payload as -// soon as the lock was released. -static bool copyCachedValue(const char* path, const char* key, std::vector<uint8_t>& value) { - value.clear(); +// and worker cores. Copy into caller-owned storage while holding the cache +// mutex; returning a Kv* would let a concurrent setter invalidate the vector +// element or its payload as soon as the lock was released. Scalar and buffer +// getters stay allocation-free, avoiding tiny heap churn on every preference +// read. value_size reports the stored length even when the output is truncated. +static bool copyCachedValue(const char* path, const char* key, void* out, + size_t out_size, size_t& value_size) { + value_size = 0; if (!s_cache_mutex || !path || !key) return false; bool found = false; xSemaphoreTake(s_cache_mutex, portMAX_DELAY); if (FileCache* cache = findCacheLocked(path)) { for (const auto& e : cache->kv) { if (strncmp(e.key, key, sizeof(e.key)) != 0) continue; - value = e.val; + value_size = e.val.size(); + const size_t copy_size = value_size < out_size ? value_size : out_size; + if (out && copy_size) memcpy(out, e.val.data(), copy_size); found = true; break; } @@ -644,22 +649,23 @@ bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns, } uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width, bool* found) { - std::vector<uint8_t> cached; - const std::vector<uint8_t>* value = nullptr; + uint8_t cached[8] = {}; if (fileMode()) { - const bool exists = copyCachedValue(_path, key, cached); + size_t value_size = 0; + const bool exists = copyCachedValue(_path, key, cached, sizeof cached, value_size); if (found) *found = exists; - if (!exists || cached.empty()) return def; - value = &cached; - } else { - Kv* e = sdFind(key); - if (found) *found = e != nullptr; - if (!e || e->val.empty()) return def; - value = &e->val; + if (!exists || value_size == 0) return def; + uint64_t v = 0; + int n = (int)value_size; if (n > width) n = width; + for (int i = 0; i < n; i++) v |= (uint64_t)cached[i] << (8 * i); + return v; } + Kv* e = sdFind(key); + if (found) *found = e != nullptr; + if (!e || e->val.empty()) return def; uint64_t v = 0; - int n = (int)value->size(); if (n > width) n = width; - for (int i = 0; i < n; i++) v |= (uint64_t)(*value)[i] << (8 * i); + int n = (int)e->val.size(); if (n > width) n = width; + for (int i = 0; i < n; i++) v |= (uint64_t)e->val[i] << (8 * i); return v; } @@ -676,8 +682,9 @@ size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) { bool SdNvsPrefs::isKey(const char* key) { if (fileMode()) { - std::vector<uint8_t> value; - return copyCachedValue(_path, key, value) || (_nvs_open && _nvs.isKey(key)); + size_t value_size = 0; + return copyCachedValue(_path, key, nullptr, 0, value_size) || + (_nvs_open && _nvs.isKey(key)); } return useNvs() ? (_nvs_open && _nvs.isKey(key)) : (sdFind(key) != nullptr); } @@ -823,12 +830,23 @@ size_t SdNvsPrefs::putBool(const char* k, bool v) { String SdNvsPrefs::getString(const char* k, const String& def) { if (fileMode()) { - std::vector<uint8_t> value; - if (!copyCachedValue(_path, k, value)) + if (!s_cache_mutex) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; + String value; + bool found = false; + xSemaphoreTake(s_cache_mutex, portMAX_DELAY); + if (FileCache* cache = findCacheLocked(_path)) { + for (const auto& e : cache->kv) { + if (strncmp(e.key, k, sizeof(e.key)) != 0) continue; + value.reserve(e.val.size()); + for (uint8_t c : e.val) value += (char)c; + found = true; + break; + } + } + xSemaphoreGive(s_cache_mutex); + if (!found) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def; - String s; s.reserve(value.size()); - for (uint8_t c : value) s += (char)c; - return s; + return value; } if (useNvs()) return _nvs.getString(k, def); Kv* e = sdFind(k); @@ -840,15 +858,14 @@ String SdNvsPrefs::getString(const char* k, const String& def) { size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { if (!buf || !maxLen) return 0; if (fileMode()) { - std::vector<uint8_t> value; - if (!copyCachedValue(_path, k, value)) { + size_t value_size = 0; + if (!copyCachedValue(_path, k, buf, maxLen - 1, value_size)) { if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); buf[0] = '\0'; return 0; } - size_t n = value.size(); + size_t n = value_size; if (n > maxLen - 1) n = maxLen - 1; - if (n) memcpy(buf, value.data(), n); buf[n] = '\0'; return n; } @@ -868,15 +885,10 @@ size_t SdNvsPrefs::putString(const char* k, const char* v) { size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { if (fileMode()) { - std::vector<uint8_t> value; - if (!copyCachedValue(_path, k, value)) + size_t value_size = 0; + if (!copyCachedValue(_path, k, buf, maxLen, value_size)) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0; - const size_t n = value.size(); - if (buf && maxLen) { - const size_t copy_len = n > maxLen ? maxLen : n; - if (copy_len) memcpy(buf, value.data(), copy_len); - } - return n; + return value_size; } if (useNvs()) return _nvs.getBytes(k, buf, maxLen); Kv* e = sdFind(k); From afd41c393fda6bdbdf302102e65519fcb40834ef Mon Sep 17 00:00:00 2001 From: Pixel Perfect <me@pixp.cc> Date: Sun, 2 Aug 2026 13:15:06 -0700 Subject: [PATCH 94/99] fix(storage): make the heavy-WDT depth counter atomic _wdtHeavyDepth() is a plain int, but WdtHeavyGuard is taken from both the UI/loop task and the core-1 history worker (histFlushTaskFn -> uiSegRunArmedJob -> uiSegAppendRecords/uiSegCompactWrite, each of which holds one). Both run at priority 1 on core 1, so ordinary tick round-robin can preempt between the load and the store of d++ / --d. That race predates this PR, but this PR makes it likely rather than theoretical: the new guard in rebootDevice() is constructed immediately before uiHistWaitWorkerIdle(), a deliberate wait of up to 9 s on the very worker that also takes the guard. Interleave the two and either - the depth strands above zero, so enableCore0WDT() is never called again and IDLE0 stays unsubscribed from the task watchdog for the session, or - the depth drops to zero while a guard is still held, re-arming the watchdog during the flash burst it exists to cover -- which panics mid-save and loses exactly the chat history rebootDevice() runs to persist. Use std::atomic with an acq_rel fetch_add and a compare-exchange decrement. The CAS preserves the original `d > 0` underflow guard, which still matters: doFactoryReset() calls wdtHeavyBegin() with no matching end because it reboots, so the counter can legitimately stay elevated and an unbalanced end must not drive it negative. Signed-off-by: Pixel Perfect <me@pixp.cc> --- src/helpers/esp32/WdtHeavyGuard.h | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/helpers/esp32/WdtHeavyGuard.h b/src/helpers/esp32/WdtHeavyGuard.h index 0b9972d..94b95b6 100644 --- a/src/helpers/esp32/WdtHeavyGuard.h +++ b/src/helpers/esp32/WdtHeavyGuard.h @@ -28,10 +28,34 @@ #if defined(ESP32) #include <Arduino.h> // disableCore0WDT / enableCore0WDT #include <esp_task_wdt.h> +#include <atomic> -inline int& _wdtHeavyDepth() { static int d = 0; return d; } -inline void wdtHeavyBegin() { int& d = _wdtHeavyDepth(); if (d++ == 0) disableCore0WDT(); } -inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) enableCore0WDT(); } +// The depth is shared: the UI/loop task and the core-1 history worker both take +// this guard around multi-second flash bursts, and they run at the same priority +// on the same core, so plain tick round-robin can preempt between the load and +// the store of a d++/--d. rebootDevice() makes that collision likely rather than +// theoretical -- it takes the guard immediately before waiting up to 9 s on the +// very worker that also takes it. A lost update either strands the depth above +// zero (IDLE0 never re-subscribed to the task watchdog for the rest of the +// session) or drops it to zero while a guard is still held (watchdog re-armed +// during the burst the guard exists to cover, which panics and loses the +// unflushed chat history rebootDevice() was written to save). +inline std::atomic<int>& _wdtHeavyDepth() { static std::atomic<int> d{0}; return d; } +inline void wdtHeavyBegin() { + if (_wdtHeavyDepth().fetch_add(1, std::memory_order_acq_rel) == 0) disableCore0WDT(); +} +inline void wdtHeavyEnd() { + // Keep the original underflow guard rather than a bare fetch_sub: + // doFactoryReset() calls wdtHeavyBegin() with no matching end (it reboots), + // so the counter can legitimately stay elevated and an unbalanced end must + // never drive it negative. + std::atomic<int>& d = _wdtHeavyDepth(); + int prev = d.load(std::memory_order_relaxed); + while (prev > 0 && !d.compare_exchange_weak(prev, prev - 1, + std::memory_order_acq_rel, + std::memory_order_relaxed)) {} + if (prev == 1) enableCore0WDT(); +} // Arduino's enableLoopWDT() subscribes loopTask even when it was not watched // before. Preserve the caller's actual task-WDT state around slow filesystem From 78fed3a09d6e8629bce10532a32b44a19e9e72c0 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Mon, 3 Aug 2026 15:23:19 +0200 Subject: [PATCH 95/99] touch: Radio & Mesh toggle for the #207 auto-retry (default ON, opt-out) 'Retry sends until heard' switch under Radio & Mesh gates mikecarper's retry train (#207): OFF stops new retry trains (an in-flight one finishes its schedule), applied live and persisted (cfg v44 retry_echo, default ON). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/MyMesh.cpp | 1 + src/MyMesh.h | 4 ++++ src/helpers/esp32/TouchPrefsStore.cpp | 14 +++++++++++++- src/helpers/esp32/TouchPrefsStore.h | 2 ++ src/ui-touch/UITask.cpp | 23 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index 5d2be6f..929521e 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -1689,6 +1689,7 @@ bool MyMesh::companionRetryQueueClone(int slot_idx, const mesh::Packet* packet, void MyMesh::companionRetryStart(const mesh::Packet* packet, const uint8_t retry_key[MAX_HASH_SIZE]) { + if (!_companion_retry_enabled) return; // Radio & Mesh toggle (default ON) — no train, no retries if (!packet || !retry_key) return; const uint8_t payload_type = packet->getPayloadType(); diff --git a/src/MyMesh.h b/src/MyMesh.h index a881059..127e7e5 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -976,6 +976,9 @@ public: * Future retries are wake deadlines, not work that should hold the CPU * awake. Returns false when no retry train is active. */ bool getNextCompanionRetryWakeDelay(uint32_t& delay_millis) const; + /** Radio & Mesh toggle: auto-retry sends until the mesh echoes/ACKs them (default ON). + * Live-settable; OFF stops NEW retry trains, an in-flight train finishes its schedule. */ + void setCompanionRetryEnabled(bool on) { _companion_retry_enabled = on; } // Number of companion clients currently connected on any transport. // Used by the idle light-sleep gate (TouchSleep) to confirm no one is @@ -988,6 +991,7 @@ public: bool isRadioReceiving() const { return _radio && _radio->isReceiving(); } private: + bool _companion_retry_enabled = true; // see setCompanionRetryEnabled static const uint8_t COMPANION_TEXT_QUEUE_CAPACITY = 16; uint8_t companionDetachQueuedText(mesh::Packet* packets[], uint8_t priorities[], uint32_t scheduled_for[]); diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 365121d..5837aab 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -36,7 +36,7 @@ static bool s_begun = false; // short read (→ treat as absent → defaults); `ver` lets later builds add fields. static const char* KEY_CFG = "cfg"; static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) -static const uint8_t TOUCH_CFG_VER = 43; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once) +static const uint8_t TOUCH_CFG_VER = 44; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once); v44 retry_echo (auto-retry sends until the mesh echoes/ACKs them, default ON; opt-out toggle in Radio & Mesh) // Defaults (kept identical to the historical per-key defaults). static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; @@ -104,6 +104,7 @@ struct __attribute__((packed)) TouchCfg { uint8_t compact_chat; // IRC-style dense chat rows instead of bubbles (bool, 0=off) — v31 (trailing) uint32_t clock_floor; // highest epoch this device handed out (ClockFloorRTC persistence) — v32 (trailing) uint8_t rx_queue; // buffered LoRa receive: drain task + packet ring (bool, 0=off, experimental) — v33 (trailing) + uint8_t retry_echo; // auto-retry sends until echo/ACK heard (bool, default ON) — v44 (trailing) uint8_t web_mirror; // web control panel: mirror the live UI to a phone browser + inject taps (bool, 0=off) — v34 (trailing) uint8_t remote_mode; // render the UI off-screen at a web resolution instead of the panel (bool, 0=off) — v35 (trailing) uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing) @@ -191,6 +192,7 @@ static void cfgSetDefaults(TouchCfg& c) { c.compact_chat = 0; // OFF: bubble chat layout (opt-in IRC-style dense rows) c.clock_floor = 0; // no persisted send-timestamp floor yet c.rx_queue = 1; // ON: buffered receive (test-channel default; opt-out toggle in Radio & Mesh) + c.retry_echo = 1; // ON: retry sends until the mesh confirms them (opt-out toggle in Radio & Mesh) c.sleep_idle = 0; // default: idle light-sleep OFF { const char* d = "ertui"; for (int i = 0; i < 5; i++) c.nav_keys[i] = (uint8_t)d[i]; } // default tab hotkeys E/R/T/U/I c.map_zoom_buttons = 0; // default: map zoom = slider @@ -280,6 +282,7 @@ static void cfgLoadOrMigrate() { if (s_cfg.ver < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default if (s_cfg.ver < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89) if (s_cfg.ver < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh) + if (s_cfg.ver < 44) s_cfg.retry_echo = 1; // auto-retry ON by default (opt-out toggle in Radio & Mesh) if (s_cfg.ver < 34) s_cfg.web_mirror = 0; // new trailing field: web control panel off by default (opt-in remote control) if (s_cfg.ver < 35) s_cfg.remote_mode = 0; // new trailing field: remote mode off by default (opt-in, reboots to apply) if (s_cfg.ver < 36) s_cfg.remote_landscape = 0; @@ -1134,6 +1137,15 @@ bool touchPrefsGetRxQueue() { if (!s_begun) touchPrefsBegin(); return s_cfg.rx_queue != 0; } +bool touchPrefsGetRetryEcho() { + if (!s_begun) touchPrefsBegin(); + return s_cfg.retry_echo != 0; +} +bool touchPrefsSetRetryEcho(bool on) { + if (!s_begun) touchPrefsBegin(); + s_cfg.retry_echo = on ? 1 : 0; + return cfgFlush(); +} bool touchPrefsSetRxQueue(bool on) { if (!s_begun) touchPrefsBegin(); s_cfg.rx_queue = on ? 1 : 0; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 0c2704e..7823b65 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -200,6 +200,8 @@ bool touchPrefsSetCompactChat(bool on); * (the missed-messages class). Opt-in, default off = stock receive path. */ bool touchPrefsGetRxQueue(); bool touchPrefsSetRxQueue(bool on); +bool touchPrefsGetRetryEcho(); // v44: auto-retry sends until echoed/ACKed (default ON) +bool touchPrefsSetRetryEcho(bool on); uint32_t touchPrefsGetClockFloor(); // monotonic send-timestamp floor (#89) bool touchPrefsSetClockFloor(uint32_t epoch); // only ever grows; no-op below current diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index eabb778..710e8a6 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9814,6 +9814,14 @@ static void radioRxQueueToggleCb(lv_event_t* e) { touchPrefsSetRxQueue(on); radio_driver.rxQueueEnable(on); } +// Auto-retry sends until the mesh confirms them (echo/ACK) — mikecarper's #207 retry train. +// Persists + applies live; OFF stops new trains, an in-flight one finishes its schedule. +static void radioRetryEchoToggleCb(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); + touchPrefsSetRetryEcho(on); + the_mesh.setCompanionRetryEnabled(on); +} static void radioSigPollSaveCb(lv_event_t* e) { 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; @@ -10133,6 +10141,18 @@ static void buildRadioSettings() { COLOR_SUB, &g_font_12, 0) + 2; } + // Auto-retry until heard (#207): resend until the mesh echoes/ACKs the packet. + { + int rh = settingsRowLabel(body, y, 4, TR("Retry sends until heard"), COLOR_TEXT, &g_font_12, 56); + lv_obj_t* sw = lv_switch_create(body); + lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y); + if (touchPrefsGetRetryEcho()) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, radioRetryEchoToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); + y += LV_MAX(34, rh + 10); + y += settingsRowLabel(body, y, 0, TR("Resends a message until a repeater echoes it or the recipient ACKs. Turn off to send once only."), + COLOR_SUB, &g_font_12, 0) + 2; + } + // Signal probe — same setting as the home-graph Signal popup. A periodic // discover advert keeps the signal bars / graph fresh. Poll interval in whole // minutes (1..1440); the toggle saves immediately, the field on its Set button. @@ -44227,6 +44247,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // factor rewritten + persisted; see healPresetAirtimeFactor for the guard. healPresetAirtimeFactor(); + // #207 auto-retry: apply the persisted Radio & Mesh toggle (default ON). + the_mesh.setCompanionRetryEnabled(touchPrefsGetRetryEcho()); + #if defined(ESP32) // Clock floor (#89): restore the highest epoch this device ever handed out so // a power cycle without a time source cannot step protocol timestamps From f4a6c1b3ad44f379d6a4d7567de85f60bfa7cb98 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Mon, 3 Aug 2026 16:52:34 +0200 Subject: [PATCH 96/99] touch: recolor-safe node names in Discover feed + msg-info trace (#223) A repeater name with an emoji (multi-byte UTF-8 cut at a byte cap) or a literal '#' desynced the LVGL recolor runs in the Discover feed, visually killing every row after it; same class in the message-info repeat list. Names are now UTF-8-boundary-cleaned and '#'-escaped at both sites, and copyUtf8ReplacingMissingGlyphs no longer emits a split glyph at its cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 266082d..15dd79d 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2087,7 +2087,9 @@ static void copyUtf8ReplacingMissingGlyphs(const lv_font_t* font, char* out, siz } if (uiFontHasGlyph(font, cp)) { - for (const char* q = seq_beg; q < p && w + 1 < out_cap; ++q) out[w++] = *q; + // Whole sequence or nothing: a glyph split at the cap leaves invalid UTF-8. + if (w + (size_t)(p - seq_beg) + 1 > out_cap) break; + for (const char* q = seq_beg; q < p; ++q) out[w++] = *q; } else { out[w++] = '*'; } @@ -21326,6 +21328,8 @@ static uint8_t s_disc_row_type[DISC_MAXROWS]; static char s_disc_row_name[DISC_MAXROWS][26]; static int s_disc_row_n = 0; +static void recolorEscape(char* dst, size_t cap, const char* src); // defined with the chat helpers + // Rebuild the feed label from the engine's _discover[] table, strongest-signal first. static void discoverBuildFeed() { if (!s_discover_feed) return; @@ -21349,9 +21353,14 @@ static void discoverBuildFeed() { const MyMesh::DiscoverHit& h = hits[idx[k]]; if (h.node_type == ADV_TYPE_REPEATER) rpt++; else if (h.node_type == ADV_TYPE_CHAT) comp++; + // #223: a name can carry emoji (multi-byte UTF-8) or a literal '#'. Cutting it at + // a byte count splits a sequence, and an unescaped '#' toggles this label's + // recolor mode — either desyncs the '#RRGGBB …#' runs and visually kills every + // row after this one. Keep a structurally-clean copy here (font=nullptr: emoji + // preserved for the tap-to-add snapshot), and escape a capped display copy below. char name[26]; ContactInfo* c = the_mesh.lookupContactByPubKey((uint8_t*)h.pubkey, 6); - if (c && c->name[0]) snprintf(name, sizeof name, "%s", c->name); + if (c && c->name[0]) copyUtf8ReplacingMissingGlyphs(nullptr, name, sizeof name, c->name); else snprintf(name, sizeof name, "Node \xC2\xB7%02X%02X", h.pubkey[0], h.pubkey[1]); memcpy(s_disc_row_key[k], h.pubkey, 32); // snapshot this row for tap-to-add s_disc_row_type[k] = h.node_type; @@ -21362,10 +21371,15 @@ static void discoverBuildFeed() { if (age < 60) snprintf(ago, sizeof ago, "%us", (unsigned)age); else snprintf(ago, sizeof ago, "%um", (unsigned)(age / 60)); const char* direct = (h.path_len == 0) ? " #53C06B direct#" : ""; - // #191: cap the name (%.14s) + single-letter type so the trailing age ALWAYS fits the line. + // #191: cap the name to 14 bytes (on a codepoint boundary, glyphs mapped to the + // feed's font) + single-letter type so the trailing age ALWAYS fits the line. + // recolorEscape doubles '#' so a hash in a name can't break the colour run. + char disp[15], disp_esc[30]; + copyUtf8ReplacingMissingGlyphs(&g_font_12, disp, sizeof disp, name); + recolorEscape(disp_esc, sizeof disp_esc, disp); q += snprintf(buf + q, sizeof(buf) - q, - "#%06X %.14s# %s %ddBm %.1f%s \xC2\xB7 %s\n", - (unsigned)discTypeColor(h.node_type), name, discTypeShort(h.node_type), + "#%06X %s# %s %ddBm %.1f%s \xC2\xB7 %s\n", + (unsigned)discTypeColor(h.node_type), disp_esc, discTypeShort(h.node_type), (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, direct, ago); } if (q == 0) snprintf(buf, sizeof buf, "#7A7F87 Scanning\xE2\x80\xA6 nothing has answered yet#"); @@ -29844,7 +29858,7 @@ static void openMessageInfoPopup(int msg_idx) { // Bounded (<= ECHO_MAX_HOPS) + hard buffer guard + read-only lookup -> no risk. uint8_t rhc = the_mesh.uiRepeatHopCount(m.sent_fp); for (uint8_t r = 0; r < rhc; r++) { - if (blen >= (int)sizeof(body) - 56) break; + if (blen >= (int)sizeof(body) - 96) break; // escaped name (<=64B) + markers must fit whole uint8_t hh[4]; uint8_t hsz = the_mesh.uiRepeatHop(m.sent_fp, r, hh, sizeof(hh)); if (hsz == 0) continue; @@ -29852,10 +29866,15 @@ static void openMessageInfoPopup(int msg_idx) { for (uint8_t b = 0; b < hsz && b < 4; ++b) snprintf(hashstr + b * 2, sizeof(hashstr) - b * 2, "%02X", hh[b]); char nm[33]; // ContactInfo.name is 32B; hold the full name (emoji eat 4B each) - if (the_mesh.uiHopName(hh, hsz, nm, sizeof(nm))) + if (the_mesh.uiHopName(hh, hsz, nm, sizeof(nm))) { + // #223 class: this name sits inside a '#RRGGBB …#' run — a literal '#' or a + // broken UTF-8 tail in it desyncs every colour block after this line. + char nm_c[33], nm_esc[66]; + copyUtf8ReplacingMissingGlyphs(nullptr, nm_c, sizeof nm_c, nm); + recolorEscape(nm_esc, sizeof nm_esc, nm_c); blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", - (unsigned)nodeSigColorHex(nm), nm); // name resolved -> no redundant hash - else + (unsigned)nodeSigColorHex(nm), nm_esc); // name resolved -> no redundant hash + } else blen += snprintf(body + blen, sizeof(body) - blen, "\n #%06X %s#", (unsigned)nodeSigColorHex(hashstr), hashstr); } From 169971205d0418676ef2bd5d6a27a9621335d60c Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Mon, 3 Aug 2026 17:02:14 +0200 Subject: [PATCH 97/99] touch: run the SD format + paste workers on every board that offers them (#172) The Format button (and its busy overlay) is compiled for T-Deck, M9 and V4-R8, and Copy/Paste for every Files-app board, but both deferred workers in UITask::loop were gated T-Deck-only. Elsewhere the tap set a pending counter nothing consumed: the notice stayed up forever and the format latch blocked SD remounting until reboot. Widen the format worker to the button's gate (via the per-board sdSharedSPI accessor), ungate paste. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/ui-touch/UITask.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 15dd79d..1b393a5 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -47480,16 +47480,20 @@ void UITask::loop() { lv_group_focus_obj(s_nav_last); } #endif -#if defined(HAS_TDECK_GT911) +#if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // Deferred microSD FAT32 format (runs a couple ticks after the notice paints). // f_mkfs blocks the loop for tens of seconds on a big card, so drop the loop // watchdog around it (CPU0 idle keeps running, so no reset) to avoid a // mid-format reset that would corrupt the card. + // Gate matches fmSdDoFormat (T-Deck + M9 + V4-R8): the Format button existed on + // all three, but this worker was T-Deck-only — on the others the formatting + // notice stayed up forever with no format running, and the pending latch even + // blocked remounting until reboot (#172). if (s_sd_format_pending && --s_sd_format_pending == 0) { bool ok = false; { LoopWdtGuard loop_wdt_guard; - SPIClass* spi = tdeckSharedSPI(); + SPIClass* spi = sdSharedSPI(); // this board's SD bus (T-Deck: LoRa SPI; R8: TFT FSPI; M9: shared SPI) if (spi) { // Explicit reformat (not SD.begin's format_if_empty, which only formats an // already-unreadable card): init the card to register the FatFs diskio at @@ -47535,7 +47539,10 @@ void UITask::loop() { } if (s_fm_list) fmShowRoots(); } - // Deferred copy/move (paste). WDT off — a big tree can take a while. +#endif + // Deferred copy/move (paste). WDT off — a big tree can take a while. Ungated + // like fmActPasteCb: every board with the Files app can paste, and with this + // worker T-Deck-only the busy overlay stayed up forever on the rest (#172 class). if (s_fm_paste_pending && --s_fm_paste_pending == 0) { bool ok = false; { @@ -47547,7 +47554,6 @@ void UITask::loop() { if (s_fm_list) fmRefresh(); } #endif -#endif } void UITask::setBootPhase(const char* label) { From 3c0f454d32474c73f3d5c0aa77153f8f0112849e Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Mon, 3 Aug 2026 17:10:10 +0200 Subject: [PATCH 98/99] =?UTF-8?q?touch:=20beta=5F57=20=E2=80=94=20communit?= =?UTF-8?q?y=20drop:=20auto-retry=20(#207),=20Pager=20TCXO=20(#209),=20SD-?= =?UTF-8?q?store=20fixes=20(#211/#212/#213),=20Discover=20emoji=20+=20SD?= =?UTF-8?q?=20format=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- release-notes/beta_57.txt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 release-notes/beta_57.txt diff --git a/release-notes/beta_57.txt b/release-notes/beta_57.txt new file mode 100644 index 0000000..59210ec --- /dev/null +++ b/release-notes/beta_57.txt @@ -0,0 +1,19 @@ +# beta_57 — community drop: auto-retry sends, Pager RF fix, SD-store repairs +# Most of this beta is community work: PixPMusic (storage + Pager) and mikecarper (auto-retry). + +# sending +Sends now auto-retry until the mesh actually hears them (echo or ACK), with smart backoff (mikecarper, PR #207) +New Radio & Mesh toggle "Retry sends until heard" (default on) if you want the old one-shot behaviour + +# storage +Settings saves no longer fail with "Couldn't save name to storage" on SD-based storage (PixPMusic, PR #211) +Watchdog state is preserved around long SD/flash work — kills a class of spurious reboots (PixPMusic, PR #213) +Preference reads are now thread-safe on the file-backed store (PixPMusic, PR #212) + +# pager +T-Lora Pager SX1262: the radio's TCXO is now driven at the vendor-specified 3.0 V — better RF, fewer missed ACKs (PixPMusic, PR #209) + +# fixes +Discover no longer freezes its list when a node name contains an emoji or a hash sign (#223) +SD format on the Heltec V4-R8 and ThinkNode M9 now actually runs instead of sitting on the notice forever (#172) +Files app: Copy/Paste now works on every board, not only the T-Deck From 84378c06b96411fc2abaaf3756402e0a6cdfefec Mon Sep 17 00:00:00 2001 From: Kaj Schittecat <kaj@schittecat.com> Date: Mon, 3 Aug 2026 18:02:31 +0200 Subject: [PATCH 99/99] touch: beta_57 notes rewritten to the house style (fuller why-focused prose) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- release-notes/beta_57.txt | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/release-notes/beta_57.txt b/release-notes/beta_57.txt index 59210ec..b08ab6f 100644 --- a/release-notes/beta_57.txt +++ b/release-notes/beta_57.txt @@ -1,19 +1,20 @@ -# beta_57 — community drop: auto-retry sends, Pager RF fix, SD-store repairs -# Most of this beta is community work: PixPMusic (storage + Pager) and mikecarper (auto-retry). +# beta_57 - sends retry until heard, SD settings-save fix, Pager radio fix +# One user-facing note per non-blank, non-# line; # lines are section comments. +# Community beta: PixPMusic (storage + Pager) and mikecarper (send retry). -# sending -Sends now auto-retry until the mesh actually hears them (echo or ACK), with smart backoff (mikecarper, PR #207) -New Radio & Mesh toggle "Retry sends until heard" (default on) if you want the old one-shot behaviour +# --- Sending -------------------------------------------------------------------------- +Sends now retry until the mesh has actually heard them. Until now a message was transmitted exactly once - if that one transmission collided or a repeater was mid-hop, it was gone while the app showed it as sent. The device now listens for its own message coming back (a repeater echo or the delivery ACK) and retransmits with a growing backoff until something confirms it was heard. Works for direct and flood sends, including sends from the phone app. Built by mikecarper (PR #207). +New toggle under Settings > Radio & Mesh: "Retry sends until heard" (on by default) - turn it off if you want the old single-shot behaviour. -# storage -Settings saves no longer fail with "Couldn't save name to storage" on SD-based storage (PixPMusic, PR #211) -Watchdog state is preserved around long SD/flash work — kills a class of spurious reboots (PixPMusic, PR #213) -Preference reads are now thread-safe on the file-backed store (PixPMusic, PR #212) +# --- Storage -------------------------------------------------------------------------- +Saving settings on SD storage works again. On devices that keep their data on the SD card (T-Deck, V4-R8, Pager, Launcher installs) every settings save failed with "Couldn't save name to storage" - the commit step renamed a file onto itself, so it could never succeed. Found and fixed by PixPMusic (PR #211). +Watchdog state is preserved around long SD and flash work instead of being blindly re-armed afterwards - removes a cause of spurious watchdog reboots (PixPMusic, PR #213). +Settings reads no longer hand out pointers into a shared cache another task could invalidate mid-read (PixPMusic, PR #212). -# pager -T-Lora Pager SX1262: the radio's TCXO is now driven at the vendor-specified 3.0 V — better RF, fewer missed ACKs (PixPMusic, PR #209) +# --- Pager ---------------------------------------------------------------------------- +T-Lora Pager (SX1262): the radio oscillator is now driven at the 3.0 V LilyGo's own reference code uses, instead of a generic 1.6 V fallback. Weak transmissions and missing ACKs on this board came down to this - confirmed on hardware by PixPMusic (PR #209). -# fixes -Discover no longer freezes its list when a node name contains an emoji or a hash sign (#223) -SD format on the Heltec V4-R8 and ThinkNode M9 now actually runs instead of sitting on the notice forever (#172) -Files app: Copy/Paste now works on every board, not only the T-Deck +# --- Fixes ---------------------------------------------------------------------------- +Discover no longer freezes when a node's name contains an emoji or a "#". A name cut mid-emoji broke the list's colour markup for every row after it, so the list looked frozen from that point on. Names are cleaned and escaped before display, here and in the message-info repeat list. Thanks to 4nuts for reporting with the exact culprit name (#223). +Formatting an SD card on the V4-R8 or ThinkNode M9 no longer hangs forever. The Format button existed on both boards but the code doing the format was only compiled for the T-Deck, so the notice stayed up with nothing running behind it. Thanks to TrustYourTechnoLust (#172). +Files app: Copy and Paste now work on every board - outside the T-Deck they showed the busy overlay forever for the same reason as the format hang.