Files
wadamesh/src/helpers/esp32/TouchPrefsStore.cpp
T
Michael A. CojocariandClaude Opus 5.5 dbecef0b6f feat(crowpanel-35): add Elecrow CrowPanel 3.5" (ESP32-S3, ILI9488 + GT911) touch target
- New crowpanel_35 variant: display, SD, board and target files, plus board JSON
- Portrait mode is drawn rotated 180°; landscape stays at rotation 3
- Share the GT911 touch driver between Wio Tracker L2 and CrowPanel
  (WioTrackerL2Touch.cpp -> src/helpers/input/LgfxGt911Touch.cpp)
- platformio envs, flasher/site entries, release and merge-bin support
- Tests for the CrowPanel SD codec and merge-bin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132MkJ7RUV32mV9F7o4PJMm
2026-10-02 20:16:06 -04:00

2714 lines
109 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "TouchPrefsStore.h"
#include "TouchPrefsSchema.h"
#if defined(ESP32)
#include "WifiRuntimeStore.h"
#include "SdNvsPrefs.h" // NVS, or SD /meshcomod fallback when NVS is unusable (Launcher)
#include <Preferences.h>
#include <SPIFFS.h>
#include <stddef.h> // offsetof
#include <string.h> // memcpy
static const char* TOUCH_NS = "touch";
static SdNvsPrefs s_prefs;
static bool s_begun = false;
// ---------------------------------------------------------------------------
// Packed scalar config blob ("cfg")
// ---------------------------------------------------------------------------
//
// NVS is a tiny (~20 KB) partition shared across firmwares; every distinct key
// costs an entry and the namespace was filling up. The many per-key SCALAR
// settings (brightness, kb backlight, accent colour, language, …) are therefore
// packed into ONE versioned blob keyed "cfg". Strings (tile_srv, rgn_scope,
// lk_wall, channel-scopes, quick-replies), the byte blobs (fav / ign / rpw) and
// the Wi-Fi slots keep their own keys — and "use_sd" / "setup_ok" stay standalone
// 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, 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 = TouchPrefsSchema::MAGIC;
static const uint8_t TOUCH_CFG_VER = TouchPrefsSchema::CURRENT_VERSION; // 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 broken retry_echo mid-struct insertion; v45 moves retry_echo to the actual tail and resets the ambiguous v44 suffix; v46 app_hide; v47 MQTT hidden by default; v48 lang_file; v49 fem_lna default ON on the V4-R8 (KCT8103L FEM; one-time flip of existing installs, no new field); v50 map_show_tilexyz default OFF (tile z/x/y line hidden; one-time flip, no new field)
// v56 appends theme_mode (Night by default). It was written as v54 on the
// contributing branch; v54 and v55 were taken by boot_wifi_* and loud_alerts
// before this merged, so the field moved to the tail behind them.
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 30;
static const uint8_t DEFAULT_BRIGHTNESS = 100;
#if defined(HAS_TDECK_MAX)
static const uint8_t DEFAULT_KB_BL = 0; // T-Deck Max: off -- no light ever comes on by itself
#else
static const uint8_t DEFAULT_KB_BL = 2; // auto
#endif
static const uint8_t DEFAULT_KB_LAYOUT = 0; // English
static const uint8_t DEFAULT_KB_SECONDARY = 0; // None
static const uint32_t DEFAULT_LOCK_COLOR = 0xE6F2FFu; // soft white
static const uint32_t DEFAULT_ACCENT = 0x15B6A6u; // brand teal
static const bool DEFAULT_DC_SHOW = true;
static const uint8_t DEFAULT_SIG_PROBE_EN = 1; // signal discover probe ON
static const uint16_t DEFAULT_SIG_POLL_MIN = 5; // minutes between probes
#if defined(HAS_CROWPANEL_35)
static const uint8_t DEFAULT_UI_ROTATION = 3; // upright ILI9488 landscape
#else
static const uint8_t DEFAULT_UI_ROTATION = 0;
#endif
using TouchCfg = TouchPrefsSchema::Config;
static TouchCfg s_cfg;
static bool s_cfg_loaded = false;
// Legacy per-key names — only referenced by the one-time migration below.
static const char* LK_SCR_TO = "scr_to_s";
static const char* LK_DC_SHOW = "dc_show";
static const char* LK_BRIGHTNESS = "bright";
static const char* LK_KB_BL = "kb_bl";
static const char* LK_KB_LAYOUT = "kblang";
static const char* LK_KB_SECONDARY = "kbsec";
static const char* LK_KB_ENABLED = "kbenab";
static const char* LK_LOCK_COLOR = "lk_col";
static const char* LK_CLR_BUBBLES = "clr_bub";
static const char* LK_KB_ACCENT = "kb_accent";
static const char* LK_ACCENT = "accent";
static const char* LK_TIME_OFFS = "time_offs";
static const char* LK_USE_MILES = "use_miles";
static const char* LK_TILES_FROM_SD= "tiles_sd";
static const char* LK_UI_LANG = "ui_lang";
static const char* LK_UI_ROTATION = "uirot";
static const char* LK_BATT_FULL = "battfull";
static const char* LK_GPS_BAUD = "gps_baud";
static void cfgSetDefaults(TouchCfg& c) {
c.magic = TOUCH_CFG_MAGIC;
c.ver = TOUCH_CFG_VER;
c.bright = DEFAULT_BRIGHTNESS;
c.kb_bl = DEFAULT_KB_BL;
c.kb_layout = DEFAULT_KB_LAYOUT;
c.kb_secondary = DEFAULT_KB_SECONDARY;
c.ui_lang = 0;
c.ui_rotation = DEFAULT_UI_ROTATION;
c.dc_show = DEFAULT_DC_SHOW ? 1 : 0;
c.use_miles = 0;
c.tiles_from_sd = 0;
c.clr_bubbles = 1; // default ON
c.kb_accent = 1; // default ON
c.time_offs = 0;
c.scr_to_s = DEFAULT_SCREEN_TIMEOUT_S;
c.kb_enabled = 0;
c.batt_full_mv = 0;
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;
c.tz_zone = 0; // 0 = Europe (CET/CEST) — preserves prior behaviour
c.hide_node_name = 0; // default: show the device name
c.map_night = 0; // default: normal (light) tiles
c.map_zoom = 0; // 0 = unset -> auto-snap on first map open
c.map_show_coords = 1; // default: show coords / tile line / contacts
c.map_show_tilexyz = 0; // v50: the "z12 12/2105/1376" tile-path line is developer clutter on the map; opt-in via Map options
c.map_show_contacts = 1;
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
#if defined(TLORA_PAGER) || defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9)
c.ui_scale = 0; // Font-only presets start at the existing typography.
#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
c.kbd_nav = 0; // T-Deck / V4: keyboard navigation OFF by default (opt-in; persists once toggled on)
#endif
c.tb_nav = 0; // T-Deck trackball: soft-cursor by default. D-pad UI nav is EXPERIMENTAL (opt-in)
c.scope_direct = 0; // OFF: direct/login floods stay unscoped (cross-region safe). Opt-in per issue #64.
#if defined(HELTEC_LORA_V4_R8)
c.fem_lna = 1; // ON (v49): the V4-R8 is a V4.3.1-generation board with the KCT8103L FEM, whose
// switchable ~17 dB LNA is the whole point of that FEM revision — shipping it
// bypassed left RX sensitivity on the table. Toggle stays in Radio & Mesh.
#else
c.fem_lna = 0; // OFF: V4.3 FEM LNA bypassed (matches the hardware default). Opt-in high-gain RX.
#endif
c.msg_flash = 0; // OFF: opt-in new-message keyboard/screen flash
c.flood_adv_hrs = 0; // OFF: no periodic flood self-advert (advertise manually)
c.local_adv_min = 0; // OFF: no periodic zero-hop self-advert
c.beta_updates = 0; // OFF: stable update channel (opt-in to beta/test firmware)
c.report_ping = 0; // OFF: no anonymous install count until asked for (v64)
c.report_done_n = 0; // no beta reported on yet
c.boot_advert = 0; // OFF: no automatic advert on boot — opt-in (#76)
c.console_mode = 0; // OFF: boot into the graphical UI (CONSOLE_MODE.md)
c.console_monitor = 1; // ON: the console shows messages as they arrive
c.kb_force_legacy = 0; // OFF: detect the keyboard protocol automatically
c.boot_wifi_time = 0; // OFF: no cold-boot Wi-Fi time sync (#383) — opt-in
c.boot_wifi_open = 0; // OFF: and never a saved OPEN network even then
c.loud_alerts = 0; // OFF: the standard chime pitch unless asked for
c.theme_mode = 0; // Night: preserves the existing firmware appearance
c.gps_fuzz_m = 0; // OFF: advertise the real position unless asked otherwise
c.telem_loc_exact = 0; // OFF: a position ANSWER carries the same displacement as the advert
c.attaky_notify_enabled = 0; // OFF: incoming messages do not blink the keyboard indicators
c.attaky_notify_room_color = 0; // red
c.attaky_notify_dm_color = 1; // green
c.compact_chat = 0; // OFF: bubble chat layout (opt-in IRC-style dense rows)
c.home_key_keeps_drawer = 0; // OFF: preserve Home-key Commander/drawer toggle
c.ble_kbd_mode = 0; // Bluetooth serves the phone app
c.ble_kbd_layout = 0; // US
memset(c.ble_kbd_addr, 0, sizeof c.ble_kbd_addr); // no keyboard paired
c.ble_kbd_addr_type = 0;
memset(c.ble_kbd_name, 0, sizeof c.ble_kbd_name);
c.ble_kbd_back = 0; // only Esc goes back
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 = 0; // OFF: auto-retry is opt-in (toggle in Radio & Mesh)
c.app_hide = (1u << 12); // APPHIDE_MQTT: the MQTT bridge starts hidden (experimental + privacy)
memset(c.lang_file, 0, sizeof c.lang_file); // no file language: built-in ui_lang column
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
#if defined(HAS_TANMATSU)
{ const char* d = "wxads"; for (int i = 0; i < 6; i++) c.nav_dir_keys[i] = (uint8_t)d[i]; } // Tanmatsu: W up/X down/A left/D right/S select; no Back letter (Esc/F-key), d[5]='\0'
#else
{ const char* d = "wzadsq"; for (int i = 0; i < 6; i++) c.nav_dir_keys[i] = (uint8_t)d[i]; } // default W/Z/A/D/S/Q
#endif
c.home_is_drawer = 0; // default: Home = Commander screen
#if defined(HAS_TANMATSU)
c.nav_scroll_keys[0] = 'f'; c.nav_scroll_keys[1] = 'v'; // Tanmatsu scroll-up F / scroll-down V
#else
c.nav_scroll_keys[0] = 'f'; c.nav_scroll_keys[1] = 'c'; // default scroll-up F / scroll-down C
#endif
c.notify_new_contact = 1; // default: show the new-contact toast (preserve prior behaviour)
c.show_sensors_tab = 1; // default: show the V4 Expansion-Kit Sensors tab + Home env widget
c.map_show_links = 1; // default: show self->contact link lines (PR #61)
c.map_style = 0; // default: OpenStreetMap (OpenTopoMap is opt-in)
c.web_mirror = 0; // OFF: web control panel is opt-in (remote control over the LAN)
c.remote_mode = 0; // OFF: render to the physical panel (remote mode is opt-in, reboots to apply)
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
}
// 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; }
bool ok = s_prefs.putBytes(KEY_CFG, &s_cfg, sizeof(s_cfg)) == sizeof(s_cfg);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
// Load "cfg" once; on absence run the one-time legacy migration. Must be called
// with the namespace already open (RO is fine for the load + legacy reads; the
// migration write reopens RW via cfgFlush). Idempotent: a no-op after the first
// successful run because "cfg" then exists.
static void cfgLoadOrMigrate() {
if (s_cfg_loaded) return;
cfgSetDefaults(s_cfg);
// isKey() does NOT emit the [E] NOT_FOUND log that getBytes() would on a miss,
// so probe first to keep the (USB-CDC) console clean on a fresh device.
if (s_prefs.isKey(KEY_CFG)) {
uint8_t blob[sizeof(TouchCfg)] = {};
size_t n = s_prefs.getBytes(KEY_CFG, blob, sizeof(blob));
uint8_t stored_version = 0;
// Need at least magic(2)+ver(1) to trust the header; reject anything shorter
// (a half-written / garbage blob) and re-derive from legacy keys / defaults.
if (TouchPrefsSchema::overlayStored(s_cfg, blob, n, &stored_version)) {
// Older blobs overlay their established prefix on the defaults. Broken
// beta-57 v44 blobs stop at rx_queue: their suffix is byte-shifted and
// ambiguous, so web/remote/history tuning intentionally returns to safe
// defaults instead of guessing and silently enabling another boot mode.
if (stored_version == TouchPrefsSchema::BROKEN_MID_INSERT_VERSION)
Serial.println("[PREFS] repairing beta_57 v44 suffix with safe defaults");
if (stored_version < TOUCH_CFG_VER) {
// v2->v3: a manual hour offset used to mean "CET base + offset". Preserve
// that under the new zone picker by mapping such users onto the Custom
// (UTC-offset) zone, so their clock doesn't jump to CET. 0xFE is resolved
// to the real Custom index on the first touchPrefsGetTimezone() call (the
// zone count isn't known here). offset 0 stays zone 0 (Europe) = unchanged.
if (stored_version < 3 && s_cfg.time_offs != 0) s_cfg.tz_zone = 0xFE;
// v18: keyboard navigation is now OFF by default (it was force-enabled at v15, but it
// complicated the touch UX more than it helped). Reset existing installs to off ONCE so
// they match the new default; the user's later explicit on/off then persists (fires only
// for ver < 18, never again). The Tanmatsu is exempt — it has no touchscreen, so keyboard
// nav is its only input and must stay on.
#if !defined(HAS_TANMATSU)
if (stored_version < 18) s_cfg.kbd_nav = 0;
#endif
// v22: new trailing field — default the T-Deck trackball to D-pad UI nav on existing installs.
if (stored_version < 22) s_cfg.tb_nav = 1;
// v23: new trailing field — scope-direct-floods OFF on existing installs (opt-in).
if (stored_version < 23) s_cfg.scope_direct = 0;
// v24: trackball D-pad UI nav demoted to EXPERIMENTAL — default OFF (was on at v22). Flip
// existing installs back to the soft cursor; the toggle lets users opt back in.
if (stored_version < 24) s_cfg.tb_nav = 0;
// v25: new trailing field — V4.3 FEM LNA OFF on existing installs (matches hardware default).
if (stored_version < 25) s_cfg.fem_lna = 0;
#if defined(HELTEC_LORA_V4_R8)
// v49: FEM LNA ON by default on the V4-R8 (KCT8103L). One-time flip of existing
// installs so they match the new default; an explicit later off/on persists.
if (stored_version < 49) s_cfg.fem_lna = 1;
#endif
// v50: map tile z/x/y overlay line OFF by default (one-time flip; the Map-options toggle persists afterwards).
if (stored_version < 50) s_cfg.map_show_tilexyz = 0;
if (stored_version < 26) s_cfg.msg_flash = 0;
if (stored_version < 27) { s_cfg.flood_adv_hrs = 0; s_cfg.local_adv_min = 0; }
if (stored_version < 28) s_cfg.beta_updates = 0;
// v64 new trailing fields. Forced off rather than inherited: a garbage 1
// would start sending an install count nobody opted into.
if (stored_version < 64) { s_cfg.report_ping = 0; s_cfg.report_done_n = 0; }
if (stored_version < 29 && s_cfg.ui_scale == 0) s_cfg.ui_scale = 1; // bump old 100% default -> Large (150%)
if (stored_version < 30) s_cfg.boot_advert = 0; // #76 new trailing field: advert-on-boot off by default
// v51 new trailing field. Anything older than 51 never stored it, so it
// must be forced OFF rather than inherited from whatever byte was there:
// a garbage 1 would boot a user into a console they did not ask for.
if (stored_version < 51) s_cfg.console_mode = 0;
if (stored_version < 52) s_cfg.console_monitor = 1; // new trailing field: on by default
if (stored_version < 53) s_cfg.kb_force_legacy = 0; // new trailing field: auto-detect
// v54 new trailing fields (#383). Anything older never stored them, so
// force both OFF rather than inherit whatever byte happened to be there:
// a garbage 1 would spend boot time on a Wi-Fi session nobody asked for.
if (stored_version < 54) { s_cfg.boot_wifi_time = 0; s_cfg.boot_wifi_open = 0; }
if (stored_version < 55) { s_cfg.loud_alerts = 0; }
if (stored_version < 56) { s_cfg.theme_mode = 0; } // Night: unchanged appearance
if (stored_version < 57) { s_cfg.gps_fuzz_m = 0; } // OFF: real position
if (stored_version < 59) { s_cfg.telem_loc_exact = 0; } // OFF: answers stay displaced
if (stored_version < 60) { s_cfg.home_key_keeps_drawer = 0; } // preserve Home-key toggle
if (stored_version < 61) { // no keyboard: Bluetooth stays with the phone app
s_cfg.ble_kbd_mode = 0;
s_cfg.ble_kbd_layout = 0;
memset(s_cfg.ble_kbd_addr, 0, sizeof s_cfg.ble_kbd_addr);
s_cfg.ble_kbd_addr_type = 0;
memset(s_cfg.ble_kbd_name, 0, sizeof s_cfg.ble_kbd_name);
}
if (stored_version < 62) s_cfg.ble_kbd_back = 0; // only Esc goes back
#if defined(HELTEC_LORA_V4_R8)
// ui_scale existed before the R8 exposed a selector and inherited the
// unrelated large-screen default. Preserve its current appearance once.
if (stored_version < 63) s_cfg.ui_scale = 0;
#endif
#if defined(HAS_THINKNODE_M9)
// Same story on the M9, which gains the selector in v65: the field has
// been in the blob since v8 holding a default this board never applied,
// so without this every M9 in the field would come back from the update
// rendering Large text nobody asked for.
if (stored_version < 65) s_cfg.ui_scale = 0;
#endif
if (stored_version < 58) {
s_cfg.attaky_notify_enabled = 0;
s_cfg.attaky_notify_room_color = 0;
s_cfg.attaky_notify_dm_color = 1;
}
if (stored_version < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
if (stored_version < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
if (stored_version < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
if (stored_version < 45) s_cfg.retry_echo = 0; // v45: correctly appended auto-retry preference; opt-in per user feedback
// v45 also lands Hungarian INSERTED at UiLang slot 1 (#227), which shifts every
// stored non-English choice by one. Remap once; RO (old max 12) becomes 13.
if (stored_version < 45 && s_cfg.ui_lang >= 1 && s_cfg.ui_lang <= 12) s_cfg.ui_lang += 1;
if (stored_version < 46) s_cfg.app_hide = 0; // v46 trailing field: nothing hidden
if (stored_version < 47) s_cfg.app_hide |= (1u << 12); // v47: MQTT bridge starts hidden (experimental + privacy)
if (stored_version < 48) memset(s_cfg.lang_file, 0, sizeof s_cfg.lang_file); // v48 trailing field: no file language
if (stored_version < 34) s_cfg.web_mirror = 0; // new trailing field: web control panel off by default (opt-in remote control)
if (stored_version < 35) s_cfg.remote_mode = 0; // new trailing field: remote mode off by default (opt-in, reboots to apply)
if (stored_version < 36) s_cfg.remote_landscape = 0;
if (stored_version < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle)
if (stored_version < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in)
if (stored_version < 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 (stored_version < 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
}
s_cfg_loaded = true;
return;
}
// Garbage / short / wrong-magic read -> fall through and re-derive from
// legacy keys (or defaults), overwriting the bad blob.
}
// No (valid) "cfg" yet: build it from the legacy per-key values, applying the
// exact same defaults the old getters used. On a fresh device every isKey()
// is false, so this just keeps the defaults set above.
if (s_prefs.isKey(LK_SCR_TO)) s_cfg.scr_to_s = s_prefs.getUShort(LK_SCR_TO, DEFAULT_SCREEN_TIMEOUT_S);
if (s_prefs.isKey(LK_BRIGHTNESS)) s_cfg.bright = s_prefs.getUChar(LK_BRIGHTNESS, DEFAULT_BRIGHTNESS);
if (s_prefs.isKey(LK_KB_BL)) s_cfg.kb_bl = s_prefs.getUChar(LK_KB_BL, DEFAULT_KB_BL);
if (s_prefs.isKey(LK_KB_LAYOUT)) s_cfg.kb_layout = s_prefs.getUChar(LK_KB_LAYOUT, DEFAULT_KB_LAYOUT);
if (s_prefs.isKey(LK_KB_SECONDARY)) s_cfg.kb_secondary = s_prefs.getUChar(LK_KB_SECONDARY, DEFAULT_KB_SECONDARY);
if (s_prefs.isKey(LK_TIME_OFFS)) s_cfg.time_offs = s_prefs.getChar(LK_TIME_OFFS, 0);
if (s_prefs.isKey(LK_LOCK_COLOR)) s_cfg.lock_color = s_prefs.getUInt(LK_LOCK_COLOR, DEFAULT_LOCK_COLOR) & 0xFFFFFFu;
if (s_prefs.isKey(LK_ACCENT)) s_cfg.accent = s_prefs.getUInt(LK_ACCENT, DEFAULT_ACCENT) & 0xFFFFFFu;
if (s_prefs.isKey(LK_CLR_BUBBLES)) s_cfg.clr_bubbles = s_prefs.getBool(LK_CLR_BUBBLES, true) ? 1 : 0;
if (s_prefs.isKey(LK_KB_ACCENT)) s_cfg.kb_accent = s_prefs.getBool(LK_KB_ACCENT, true) ? 1 : 0;
if (s_prefs.isKey(LK_DC_SHOW)) s_cfg.dc_show = s_prefs.getBool(LK_DC_SHOW, DEFAULT_DC_SHOW) ? 1 : 0;
if (s_prefs.isKey(LK_USE_MILES)) s_cfg.use_miles = s_prefs.getBool(LK_USE_MILES, false) ? 1 : 0;
if (s_prefs.isKey(LK_TILES_FROM_SD))s_cfg.tiles_from_sd= s_prefs.getBool(LK_TILES_FROM_SD, false) ? 1 : 0;
if (s_prefs.isKey(LK_UI_LANG)) s_cfg.ui_lang = s_prefs.getUChar(LK_UI_LANG, 0);
if (s_prefs.isKey(LK_UI_ROTATION)) s_cfg.ui_rotation = s_prefs.getUChar(LK_UI_ROTATION, DEFAULT_UI_ROTATION);
if (s_prefs.isKey(LK_BATT_FULL)) s_cfg.batt_full_mv = s_prefs.getUShort(LK_BATT_FULL, 0);
if (s_prefs.isKey(LK_GPS_BAUD)) s_cfg.gps_baud = s_prefs.getUInt(LK_GPS_BAUD, 0);
// Enabled-layout mask: legacy used 0xFFFF as the "never written" sentinel and
// derived a one-bit mask from the secondary layout. Reproduce that here.
{
uint16_t v = s_prefs.isKey(LK_KB_ENABLED) ? s_prefs.getUShort(LK_KB_ENABLED, 0xFFFF) : 0xFFFF;
if (v == 0xFFFF) {
uint8_t sec = s_cfg.kb_secondary;
s_cfg.kb_enabled = (sec != 0 && sec < 16) ? (uint16_t)(1u << sec) : 0;
} else {
s_cfg.kb_enabled = v;
}
}
// 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)) {
const char* legacy[] = {
LK_SCR_TO, LK_DC_SHOW, LK_BRIGHTNESS, LK_KB_BL, LK_KB_LAYOUT,
LK_KB_SECONDARY, LK_KB_ENABLED, LK_LOCK_COLOR, LK_CLR_BUBBLES,
LK_KB_ACCENT, LK_ACCENT, LK_TIME_OFFS, LK_USE_MILES, LK_TILES_FROM_SD,
LK_UI_LANG, LK_UI_ROTATION, LK_BATT_FULL, LK_GPS_BAUD,
};
for (const char* k : legacy) {
if (s_prefs.isKey(k)) s_prefs.remove(k);
}
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
}
s_cfg_loaded = true;
}
void touchPrefsBegin() {
if (s_begun) {
if (!s_cfg_loaded) cfgLoadOrMigrate();
return;
}
s_begun = s_prefs.begin(TOUCH_NS, true);
if (!s_begun) {
/* Namespace may not exist yet — open RW once to create it, then reopen RO. */
if (s_prefs.begin(TOUCH_NS, false)) {
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
}
}
if (s_begun) cfgLoadOrMigrate();
}
// Re-read settings from scratch. Used at boot AFTER SdNvsPrefs::useFile() flips
// the backend to files: an earlier pref read (the boot-wordmark rotation) had
// already loaded + cached the cfg blob from legacy NVS, so without this the
// file-saved values (theme accent, brightness, language, …) would be ignored
// until a later boot, and a theme change would appear to "revert" on restart.
void touchPrefsReload() {
s_prefs.end();
s_begun = false;
s_cfg_loaded = false;
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)
// does NOT log, so probe with it before reading an optional string key.
static String prefsGetStr(const char* key, const String& def) {
if (!s_begun) touchPrefsBegin();
return s_prefs.isKey(key) ? s_prefs.getString(key, def) : def;
}
uint16_t touchPrefsGetScreenTimeoutSecs() {
if (!s_begun) touchPrefsBegin();
return TOUCH_SCREEN_TIMEOUT_SECS[touchPrefsScreenTimeoutIndex(s_cfg.scr_to_s)];
}
bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) {
if (!s_begun) touchPrefsBegin();
s_cfg.scr_to_s = TOUCH_SCREEN_TIMEOUT_SECS[touchPrefsScreenTimeoutIndex(seconds)];
return cfgFlush();
}
// --- Mesh signal auto-discover probe (toggle + poll interval) ---------------
// 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;
// --- 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.
// --- 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;
}
bool touchPrefsSetSigProbeEnabled(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.sig_probe_en = on ? 1 : 0;
return cfgFlush();
}
uint16_t touchPrefsGetSigPollMins() {
if (!s_begun) touchPrefsBegin();
uint16_t m = s_cfg.sig_poll_min;
if (m < SIG_POLL_MIN_MINS) m = SIG_POLL_MIN_MINS;
if (m > SIG_POLL_MAX_MINS) m = SIG_POLL_MAX_MINS;
return m;
}
bool touchPrefsSetSigPollMins(uint16_t mins) {
if (mins < SIG_POLL_MIN_MINS) mins = SIG_POLL_MIN_MINS;
if (mins > SIG_POLL_MAX_MINS) mins = SIG_POLL_MAX_MINS;
if (!s_begun) touchPrefsBegin();
s_cfg.sig_poll_min = mins;
return cfgFlush();
}
uint8_t touchPrefsGetBrightness() {
if (!s_begun) touchPrefsBegin();
uint8_t v = s_cfg.bright;
if (v < 5) v = 5;
if (v > 100) v = 100;
return v;
}
bool touchPrefsSetBrightness(uint8_t pct) {
if (pct < 5) pct = 5;
if (pct > 100) pct = 100;
if (!s_begun) touchPrefsBegin();
s_cfg.bright = pct;
return cfgFlush();
}
uint8_t touchPrefsGetKbBacklight() {
if (!s_begun) touchPrefsBegin();
uint8_t v = s_cfg.kb_bl;
return v > 2 ? DEFAULT_KB_BL : v;
}
bool touchPrefsSetKbBacklight(uint8_t mode) {
if (mode > 2) mode = DEFAULT_KB_BL;
if (!s_begun) touchPrefsBegin();
s_cfg.kb_bl = mode;
return cfgFlush();
}
uint8_t touchPrefsGetKeyboardLayout() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kb_layout;
}
bool touchPrefsSetKeyboardLayout(uint8_t layout) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_layout = layout;
return cfgFlush();
}
uint8_t touchPrefsGetSecondaryKeyboard() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kb_secondary;
}
bool touchPrefsSetSecondaryKeyboard(uint8_t secondary) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_secondary = secondary;
return cfgFlush();
}
uint16_t touchPrefsGetEnabledLayouts() {
if (!s_begun) touchPrefsBegin();
// The legacy "never written -> derive a one-bit mask from the secondary
// layout" migration ran once at cfg-migration time (see cfgLoadOrMigrate);
// the resolved mask now lives in s_cfg.kb_enabled.
return s_cfg.kb_enabled;
}
bool touchPrefsSetEnabledLayouts(uint16_t mask) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_enabled = mask;
return cfgFlush();
}
static const char* KEY_TILE_SRV = "tile_srv";
static const char* DEFAULT_TILE_SERVER = "http://tiles.wadamesh.com";
int touchPrefsGetTileServer(char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (!s_begun) touchPrefsBegin();
String v = prefsGetStr(KEY_TILE_SRV, String(DEFAULT_TILE_SERVER));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_TILE_SERVER_MAXLEN - 1) n = TOUCH_TILE_SERVER_MAXLEN - 1;
memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsSetTileServer(const char* url) {
if (!url) return false;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putString(KEY_TILE_SRV, url) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
static const char* KEY_RGN_SCOPE = "rgn_scope";
int touchPrefsGetRegionScope(char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (!s_begun) touchPrefsBegin();
String v = prefsGetStr(KEY_RGN_SCOPE, String(""));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_REGION_SCOPE_MAXLEN - 1) n = TOUCH_REGION_SCOPE_MAXLEN - 1;
memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsSetRegionScope(const char* name) {
if (!name) return false;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putString(KEY_RGN_SCOPE, name) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
// Per-channel region-scope override, keyed by channel slot (0..63). Overrides the
// default flood scope for that channel's outgoing messages. Blank = inherit the
// default. Stored as "csc<slot>" -> region name.
static void chanScopeKey(int slot, char out[8]) {
snprintf(out, 8, "csc%d", slot & 0x3F);
}
int touchPrefsGetChannelScope(int slot, char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (slot < 0) return 0;
if (!s_begun) touchPrefsBegin();
char k[8]; chanScopeKey(slot, k);
String v = prefsGetStr(k, String(""));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_REGION_SCOPE_MAXLEN - 1) n = TOUCH_REGION_SCOPE_MAXLEN - 1;
if (n > 0) memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsSetChannelScope(int slot, const char* name) {
if (slot < 0) return false;
if (!s_begun) touchPrefsBegin();
char k[8]; chanScopeKey(slot, k);
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putString(k, name ? name : "") > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
static const char* KEY_LOCK_WALL = "lk_wall";
static const char* DEFAULT_LOCK_WALL = "/lock/placeholder.jpg";
int touchPrefsGetLockWallpaper(char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (!s_begun) touchPrefsBegin();
String v = prefsGetStr(KEY_LOCK_WALL, String(DEFAULT_LOCK_WALL));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_LOCK_WALLPAPER_MAXLEN - 1) n = TOUCH_LOCK_WALLPAPER_MAXLEN - 1;
memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsSetLockWallpaper(const char* path) {
if (!path) return false;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putString(KEY_LOCK_WALL, path) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
static const char* soundFileKey(int slot) {
// Distinct from the on/off keys snd_msg/snd_dm/snd_men (those are uchar) —
// a uchar and a String must not share an NVS key.
switch (slot) {
case TOUCH_SND_DM: return "sndf_dm";
case TOUCH_SND_MEN: return "sndf_men";
default: return "sndf_msg";
}
}
int touchPrefsGetSoundFile(int slot, char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (!s_begun) touchPrefsBegin();
String v = prefsGetStr(soundFileKey(slot), String(""));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_SOUND_PATH_MAXLEN - 1) n = TOUCH_SOUND_PATH_MAXLEN - 1;
memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsSetSoundFile(int slot, const char* path) {
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok;
if (!path || !path[0]) { s_prefs.remove(soundFileKey(slot)); ok = true; } // empty => built-in chime
else ok = s_prefs.putString(soundFileKey(slot), path) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
int touchPrefsGetTimeOffsetHours() {
if (!s_begun) touchPrefsBegin();
int v = (int)s_cfg.time_offs;
if (v < -23) v = -23;
if (v > 23) v = 23;
return v;
}
bool touchPrefsSetTimeOffsetHours(int hours) {
if (hours < -23) hours = -23;
if (hours > 23) hours = 23;
if (!s_begun) touchPrefsBegin();
s_cfg.time_offs = (int8_t)hours;
return cfgFlush();
}
// Curated time zones, each with the correct POSIX DST rules for that region, so
// non-European users get the right time year-round instead of the CET base + EU
// DST dates. Index 0 (Europe/CET) is the default and matches the old behaviour.
struct TzZone { const char* label; const char* posix; };
static const TzZone TZ_ZONES[] = {
{ "Europe (CET/CEST)", "CET-1CEST,M3.5.0,M10.5.0/3" },
{ "UK (GMT/BST)", "GMT0BST,M3.5.0/1,M10.5.0" },
{ "UTC", "UTC0" },
{ "US Eastern", "EST5EDT,M3.2.0,M11.1.0" },
{ "US Central", "CST6CDT,M3.2.0,M11.1.0" },
{ "US Mountain", "MST7MDT,M3.2.0,M11.1.0" },
{ "US Arizona (no DST)", "MST7" },
{ "US Pacific", "PST8PDT,M3.2.0,M11.1.0" },
{ "US Alaska", "AKST9AKDT,M3.2.0,M11.1.0" },
{ "US Hawaii", "HST10" },
{ "Canada Atlantic", "AST4ADT,M3.2.0,M11.1.0" },
{ "Brazil (Brasilia)", "<-03>3" },
{ "India (IST)", "IST-5:30" },
{ "China (CST)", "CST-8" },
{ "Japan (JST)", "JST-9" },
{ "Sydney (AEST/AEDT)", "AEST-10AEDT,M10.1.0,M4.1.0/3" },
};
static const int TZ_ZONE_N = (int)(sizeof(TZ_ZONES) / sizeof(TZ_ZONES[0]));
int touchPrefsTimezoneCount() { return TZ_ZONE_N + 1; } // +1 = "Custom (UTC offset)"
const char* touchPrefsTimezoneLabel(int idx) {
if (idx >= 0 && idx < TZ_ZONE_N) return TZ_ZONES[idx].label;
if (idx == TZ_ZONE_N) return "Custom (UTC offset)";
return "";
}
uint8_t touchPrefsGetTimezone() {
if (!s_begun) touchPrefsBegin();
uint8_t z = s_cfg.tz_zone;
if (z == 0xFE) { // v2->v3 migration sentinel: had a manual offset -> Custom zone
z = (uint8_t)(touchPrefsTimezoneCount() - 1);
s_cfg.tz_zone = z;
cfgFlush();
}
if (z >= (uint8_t)touchPrefsTimezoneCount()) z = 0; // stale/garbage -> default
return z;
}
void touchPrefsSetTimezone(uint8_t idx) {
if (!s_begun) touchPrefsBegin();
if (idx >= (uint8_t)touchPrefsTimezoneCount()) idx = 0;
s_cfg.tz_zone = idx;
cfgFlush();
}
void touchPrefsBuildLocalTz(char* out, int out_cap) {
if (!out || out_cap <= 0) return;
const uint8_t z = touchPrefsGetTimezone();
if (z < (uint8_t)TZ_ZONE_N) {
snprintf(out, out_cap, "%s", TZ_ZONES[z].posix);
return;
}
// "Custom (UTC offset)": a fixed offset, no DST. POSIX std-offset is the
// negation of the UTC offset (it's the time to ADD to local to reach UTC), so
// UTC-7 -> "<-07>7", UTC+2 -> "<+02>-2".
const int off = touchPrefsGetTimeOffsetHours();
if (off == 0) { snprintf(out, out_cap, "UTC0"); return; }
snprintf(out, out_cap, "<%+03d>%d", off, -off);
}
uint32_t touchPrefsGetLockTextColor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.lock_color & 0xFFFFFFu;
}
bool touchPrefsSetLockTextColor(uint32_t rgb) {
if (!s_begun) touchPrefsBegin();
s_cfg.lock_color = rgb & 0xFFFFFFu;
return cfgFlush();
}
// Colourful chat bubbles: colour each bubble + sender name by a hash of the
// sender's display name (same name -> same colour). Default ON.
bool touchPrefsGetColorfulBubbles() {
if (!s_begun) touchPrefsBegin();
return s_cfg.clr_bubbles != 0;
}
bool touchPrefsSetColorfulBubbles(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.clr_bubbles = on ? 1 : 0;
return cfgFlush();
}
// Keyboard accent-popup picker: when a typed Latin letter has accented variants,
// a tap-to-pick box appears. Default ON. (Distinct from the accent THEME colour.)
bool touchPrefsGetAccentPopups() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kb_accent != 0;
}
bool touchPrefsSetAccentPopups(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_accent = on ? 1 : 0;
return cfgFlush();
}
// Web control panel: mirror the live UI to a phone browser (Settings > Wi-Fi).
bool touchPrefsGetWebMirror() {
if (!s_begun) touchPrefsBegin();
return s_cfg.web_mirror != 0;
}
bool touchPrefsSetWebMirror(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.web_mirror = on ? 1 : 0;
return cfgFlush();
}
// Remote mode: render the UI off-screen at a web resolution (boot mode; REMOTE app).
bool touchPrefsGetRemoteMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.remote_mode != 0;
}
bool touchPrefsSetRemoteMode(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.remote_mode = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetRemoteLandscape() {
if (!s_begun) touchPrefsBegin();
return s_cfg.remote_landscape != 0;
}
bool touchPrefsSetRemoteLandscape(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.remote_landscape = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetWebTerminal() {
if (!s_begun) touchPrefsBegin();
return s_cfg.web_terminal != 0;
}
bool touchPrefsSetWebTerminal(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.web_terminal = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapTileDebug() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_tile_debug != 0;
}
bool touchPrefsSetMapTileDebug(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_tile_debug = on ? 1 : 0;
return cfgFlush();
}
// UI accent colour (buttons, active tab, keyboard, highlights) as 0xRRGGBB.
// Default = the WADAMESH brand teal (the logo dots). The picker clamps it dark
// enough that the off-white button text stays readable on any hue.
uint32_t touchPrefsGetAccentColor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.accent & 0xFFFFFFu;
}
bool touchPrefsSetAccentColor(uint32_t rgb) {
if (!s_begun) touchPrefsBegin();
s_cfg.accent = rgb & 0xFFFFFFu;
return cfgFlush();
}
uint8_t touchPrefsGetThemeMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.theme_mode <= TOUCH_THEME_NIGHT_HIGH_CONTRAST
? s_cfg.theme_mode : TOUCH_THEME_NIGHT;
}
bool touchPrefsSetThemeMode(uint8_t mode) {
if (!s_begun) touchPrefsBegin();
const uint8_t old_mode = s_cfg.theme_mode;
s_cfg.theme_mode = mode <= TOUCH_THEME_NIGHT_HIGH_CONTRAST
? mode : TOUCH_THEME_NIGHT;
if (cfgFlush()) return true;
s_cfg.theme_mode = old_mode;
return false;
}
// Quick-reply macros. Stored as NVS strings keyed "qr0".."qr5".
// Default factory set on first read so the picker isn't useless out of the
// box and the user has examples to edit.
// Factory defaults skew tactical / radio-comms style — mesh radios get used
// for field ops a lot more than for "calling now" social texting, so seed
// the picker with phrases that actually pull weight on the air. ASCII-only
// so they render identically with or without the extras font fallback.
static const char* k_qr_defaults[TOUCH_QUICK_REPLY_COUNT] = {
"copy", // generic acknowledgment
"wilco", // will comply
"stand by", // wait one
"moving to RP", // en route to rally point
"ETA 5 min", // arrival estimate
"RTB", // returning to base
};
static void qrKeyFor(int idx, char out[8]) {
out[0] = 'q'; out[1] = 'r';
out[2] = (char)('0' + (idx & 0x07));
out[3] = '\0';
}
int touchPrefsGetQuickReply(int idx, char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (idx < 0 || idx >= TOUCH_QUICK_REPLY_COUNT) return 0;
if (!s_begun) touchPrefsBegin();
char key[8];
qrKeyFor(idx, key);
String v = prefsGetStr(key, String(k_qr_defaults[idx]));
int n = (int)v.length();
if (n > out_cap - 1) n = out_cap - 1;
if (n > TOUCH_QUICK_REPLY_MAXLEN - 1) n = TOUCH_QUICK_REPLY_MAXLEN - 1;
memcpy(out, v.c_str(), (size_t)n);
out[n] = '\0';
return n;
}
bool touchPrefsGetDutyMeterShown() {
if (!s_begun) touchPrefsBegin();
return s_cfg.dc_show != 0;
}
bool touchPrefsSetDutyMeterShown(bool show) {
if (!s_begun) touchPrefsBegin();
s_cfg.dc_show = show ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetUseMiles() {
if (!s_begun) touchPrefsBegin();
return s_cfg.use_miles != 0; // default = km
}
bool touchPrefsSetUseMiles(bool use_miles) {
if (!s_begun) touchPrefsBegin();
s_cfg.use_miles = use_miles ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetTilesFromSd() {
if (!s_begun) touchPrefsBegin();
return s_cfg.tiles_from_sd != 0; // default = tile server
}
bool touchPrefsSetTilesFromSd(bool from_sd) {
if (!s_begun) touchPrefsBegin();
s_cfg.tiles_from_sd = from_sd ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetHideNodeName() {
if (!s_begun) touchPrefsBegin();
return s_cfg.hide_node_name != 0; // default = show the name
}
bool touchPrefsSetHideNodeName(bool hide) {
if (!s_begun) touchPrefsBegin();
s_cfg.hide_node_name = hide ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetNewContactToast() {
if (!s_begun) touchPrefsBegin();
return s_cfg.notify_new_contact != 0; // default = show the toast
}
bool touchPrefsSetNewContactToast(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.notify_new_contact = on ? 1 : 0;
return cfgFlush();
}
// Heltec V4 Expansion Kit: show the Sensors tab + the Home env widget. Default
// ON. The UI also requires an ENVIRONMENT sensor to actually be present (checked
// at runtime in buildUiTree), so a bare V4 hides the UI even with this on.
bool touchPrefsGetShowSensorsTab() {
if (!s_begun) touchPrefsBegin();
return s_cfg.show_sensors_tab != 0; // default = show the Sensors tab
}
bool touchPrefsSetShowSensorsTab(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.show_sensors_tab = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapNight() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_night != 0;
}
bool touchPrefsSetMapNight(bool on) {
if (!s_begun) touchPrefsBegin();
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;
}
bool touchPrefsSetMapZoom(uint8_t z) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_zoom = z;
return cfgFlush();
}
bool touchPrefsGetSleepIdle() {
if (!s_begun) touchPrefsBegin();
return s_cfg.sleep_idle != 0;
}
bool touchPrefsSetSleepIdle(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.sleep_idle = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowCoords() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_coords != 0;
}
bool touchPrefsSetMapShowCoords(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_coords = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowTileXYZ() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_tilexyz != 0;
}
bool touchPrefsSetMapShowTileXYZ(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_tilexyz = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowContacts() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_contacts != 0;
}
bool touchPrefsSetMapShowContacts(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_contacts = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowLinks() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_links != 0;
}
bool touchPrefsSetMapShowLinks(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_links = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetMapStyle() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_style;
}
bool touchPrefsSetMapStyle(uint8_t style) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_style = style;
return cfgFlush();
}
bool touchPrefsGetAppGridLarge() {
if (!s_begun) touchPrefsBegin();
return s_cfg.app_grid_large != 0;
}
bool touchPrefsSetAppGridLarge(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.app_grid_large = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetUiScale() {
if (!s_begun) touchPrefsBegin();
#if defined(TLORA_PAGER)
return s_cfg.ui_scale > 3 ? 0 : s_cfg.ui_scale;
#else
return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale;
#endif
}
bool touchPrefsSetUiScale(uint8_t scale) {
if (!s_begun) touchPrefsBegin();
#if defined(TLORA_PAGER)
s_cfg.ui_scale = scale > 3 ? 0 : scale;
#else
s_cfg.ui_scale = scale > 2 ? 0 : scale;
#endif
return cfgFlush();
}
bool touchPrefsGetKbdNav() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kbd_nav != 0;
}
bool touchPrefsSetKbdNav(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.kbd_nav = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetTbNav() {
if (!s_begun) touchPrefsBegin();
return s_cfg.tb_nav != 0;
}
bool touchPrefsSetTbNav(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.tb_nav = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetScopeDirect() {
if (!s_begun) touchPrefsBegin();
return s_cfg.scope_direct != 0;
}
bool touchPrefsSetScopeDirect(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.scope_direct = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetFemLna() {
if (!s_begun) touchPrefsBegin();
return s_cfg.fem_lna != 0;
}
bool touchPrefsSetFemLna(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.fem_lna = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMsgFlash() {
if (!s_begun) touchPrefsBegin();
return s_cfg.msg_flash != 0;
}
bool touchPrefsSetMsgFlash(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.msg_flash = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetAttakyNotifyEnabled() {
if (!s_begun) touchPrefsBegin();
return s_cfg.attaky_notify_enabled != 0;
}
bool touchPrefsSetAttakyNotifyEnabled(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.attaky_notify_enabled = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetAttakyNotifyRoomColor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.attaky_notify_room_color < TOUCH_ATTAKY_NOTIFY_COLOR_COUNT
? s_cfg.attaky_notify_room_color : 0;
}
bool touchPrefsSetAttakyNotifyRoomColor(uint8_t color) {
if (color >= TOUCH_ATTAKY_NOTIFY_COLOR_COUNT) return false;
if (!s_begun) touchPrefsBegin();
s_cfg.attaky_notify_room_color = color;
return cfgFlush();
}
uint8_t touchPrefsGetAttakyNotifyDmColor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.attaky_notify_dm_color < TOUCH_ATTAKY_NOTIFY_COLOR_COUNT
? s_cfg.attaky_notify_dm_color : 1;
}
bool touchPrefsSetAttakyNotifyDmColor(uint8_t color) {
if (color >= TOUCH_ATTAKY_NOTIFY_COLOR_COUNT) return false;
if (!s_begun) touchPrefsBegin();
s_cfg.attaky_notify_dm_color = color;
return cfgFlush();
}
// Console mode is a BOOT mode, so this is read before the UI is built. It fails
// safe by construction: the only value that means console is exactly 1, so a
// corrupt or unreadable pref boots the graphical UI, which is the mode everyone
// can use. Never invert this test.
bool touchPrefsGetConsoleMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.console_mode == 1;
}
bool touchPrefsSetConsoleMode(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.console_mode = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetKbForceLegacy() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kb_force_legacy != 0;
}
bool touchPrefsSetKbForceLegacy(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_force_legacy = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetBootWifiTime() {
if (!s_begun) touchPrefsBegin();
return s_cfg.boot_wifi_time != 0;
}
bool touchPrefsSetBootWifiTime(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.boot_wifi_time = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetBootWifiTimeOpen() {
if (!s_begun) touchPrefsBegin();
return s_cfg.boot_wifi_open != 0;
}
bool touchPrefsSetBootWifiTimeOpen(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.boot_wifi_open = on ? 1 : 0;
return cfgFlush();
}
uint16_t touchPrefsGetGpsFuzzM() {
if (!s_begun) touchPrefsBegin();
return s_cfg.gps_fuzz_m;
}
bool touchPrefsSetGpsFuzzM(uint16_t m) {
if (!s_begun) touchPrefsBegin();
s_cfg.gps_fuzz_m = m;
return cfgFlush();
}
// v59: whether a telemetry position ANSWER carries the true fix. The advert is a
// broadcast and stays displaced regardless; this only affects the encrypted reply
// sent to a contact you already granted the permission to.
bool touchPrefsGetTelemLocExact() {
if (!s_begun) touchPrefsBegin();
return s_cfg.telem_loc_exact != 0;
}
bool touchPrefsSetTelemLocExact(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.telem_loc_exact = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetLoudAlerts() {
if (!s_begun) touchPrefsBegin();
return s_cfg.loud_alerts != 0;
}
bool touchPrefsSetLoudAlerts(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.loud_alerts = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetConsoleMonitor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.console_monitor != 0;
}
bool touchPrefsSetConsoleMonitor(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.console_monitor = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetBootAdvert() {
if (!s_begun) touchPrefsBegin();
return s_cfg.boot_advert != 0;
}
bool touchPrefsSetBootAdvert(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.boot_advert = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetRxQueue() {
if (!s_begun) touchPrefsBegin();
return s_cfg.rx_queue != 0;
}
uint32_t touchPrefsGetAppHide() {
if (!s_begun) touchPrefsBegin();
return s_cfg.app_hide;
}
bool touchPrefsSetAppHide(uint32_t mask) {
if (!s_begun) touchPrefsBegin();
if (s_cfg.app_hide == mask) return true;
s_cfg.app_hide = mask;
return cfgFlush();
}
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;
return cfgFlush();
}
bool touchPrefsGetCompactChat() {
if (!s_begun) touchPrefsBegin();
return s_cfg.compact_chat != 0;
}
bool touchPrefsSetCompactChat(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.compact_chat = 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() {
if (!s_begun) touchPrefsBegin();
return s_cfg.clock_floor;
}
bool touchPrefsSetClockFloor(uint32_t epoch) {
if (!s_begun) touchPrefsBegin();
if (epoch <= s_cfg.clock_floor) return true; // never regress the persisted floor
s_cfg.clock_floor = epoch;
return cfgFlush();
}
// Periodic self-advert intervals (0 = off). Validation mirrors MeshCore: flood in hours (cap 168);
// local zero-hop in minutes, 0 or 60-240 (MeshCore's MIN_LOCAL_ADVERT_INTERVAL is 60).
uint16_t touchPrefsGetFloodAdvHrs() {
if (!s_begun) touchPrefsBegin();
return s_cfg.flood_adv_hrs;
}
bool touchPrefsSetFloodAdvHrs(uint16_t hrs) {
if (!s_begun) touchPrefsBegin();
if (hrs > 168) hrs = 168;
s_cfg.flood_adv_hrs = (uint8_t)hrs;
return cfgFlush();
}
uint16_t touchPrefsGetLocalAdvMin() {
if (!s_begun) touchPrefsBegin();
return s_cfg.local_adv_min;
}
bool touchPrefsSetLocalAdvMin(uint16_t mins) {
if (!s_begun) touchPrefsBegin();
if (mins != 0) { if (mins < 60) mins = 60; if (mins > 240) mins = 240; }
s_cfg.local_adv_min = mins;
return cfgFlush();
}
bool touchPrefsGetBetaUpdates() {
if (!s_begun) touchPrefsBegin();
return s_cfg.beta_updates != 0;
}
bool touchPrefsSetBetaUpdates(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.beta_updates = on ? 1 : 0;
return cfgFlush();
}
// Beta test reports (v64). The ping is the only part that leaves the device
// without the user pressing Send, so it is opt-in and stored separately from the
// report itself.
bool touchPrefsGetReportPing() {
if (!s_begun) touchPrefsBegin();
return s_cfg.report_ping != 0;
}
bool touchPrefsSetReportPing(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.report_ping = on ? 1 : 0;
return cfgFlush();
}
uint16_t touchPrefsGetReportedBeta() {
if (!s_begun) touchPrefsBegin();
return s_cfg.report_done_n;
}
bool touchPrefsSetReportedBeta(uint16_t n) {
if (!s_begun) touchPrefsBegin();
s_cfg.report_done_n = n;
return cfgFlush();
}
uint8_t touchPrefsGetNavKey(int tab) {
if (!s_begun) touchPrefsBegin();
if (tab < 0 || tab >= 5) return 0;
return s_cfg.nav_keys[tab];
}
bool touchPrefsSetNavKey(int tab, uint8_t ch) {
if (!s_begun) touchPrefsBegin();
if (tab < 0 || tab >= 5) return false;
s_cfg.nav_keys[tab] = ch;
return cfgFlush();
}
bool touchPrefsGetMapZoomButtons() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_zoom_buttons != 0;
}
bool touchPrefsSetMapZoomButtons(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_zoom_buttons = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetNavDirKey(int idx) { // idx 0-5 = move/select/back, 6-7 = scroll up/down
if (!s_begun) touchPrefsBegin();
if (idx < 0 || idx >= 8) return 0;
return (idx < 6) ? s_cfg.nav_dir_keys[idx] : s_cfg.nav_scroll_keys[idx - 6];
}
bool touchPrefsSetNavDirKey(int idx, uint8_t ch) {
if (!s_begun) touchPrefsBegin();
if (idx < 0 || idx >= 8) return false;
if (idx < 6) s_cfg.nav_dir_keys[idx] = ch;
else s_cfg.nav_scroll_keys[idx - 6] = ch;
return cfgFlush();
}
bool touchPrefsGetHomeIsDrawer() {
if (!s_begun) touchPrefsBegin();
return s_cfg.home_is_drawer != 0;
}
bool touchPrefsSetHomeIsDrawer(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.home_is_drawer = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetHomeKeyKeepsDrawer() {
if (!s_begun) touchPrefsBegin();
return s_cfg.home_key_keeps_drawer != 0;
}
bool touchPrefsSetHomeKeyKeepsDrawer(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.home_key_keeps_drawer = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetBleKbdMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.ble_kbd_mode != 0;
}
bool touchPrefsSetBleKbdMode(bool keyboard) {
if (!s_begun) touchPrefsBegin();
s_cfg.ble_kbd_mode = keyboard ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetBleKbdLayout() {
if (!s_begun) touchPrefsBegin();
return s_cfg.ble_kbd_layout;
}
bool touchPrefsSetBleKbdLayout(uint8_t layout) {
if (!s_begun) touchPrefsBegin();
s_cfg.ble_kbd_layout = layout;
return cfgFlush();
}
uint8_t touchPrefsGetBleKbdBackKey() {
if (!s_begun) touchPrefsBegin();
return s_cfg.ble_kbd_back;
}
bool touchPrefsSetBleKbdBackKey(uint8_t usage) {
if (!s_begun) touchPrefsBegin();
s_cfg.ble_kbd_back = usage;
return cfgFlush();
}
bool touchPrefsGetBleKbdPeer(uint8_t addr[6], uint8_t* addr_type, char* name, size_t name_cap) {
if (!s_begun) touchPrefsBegin();
static const uint8_t kNone[6] = {0, 0, 0, 0, 0, 0};
if (memcmp(s_cfg.ble_kbd_addr, kNone, 6) == 0) return false;
if (addr) memcpy(addr, s_cfg.ble_kbd_addr, 6);
if (addr_type) *addr_type = s_cfg.ble_kbd_addr_type;
if (name && name_cap) {
size_t n = strnlen(s_cfg.ble_kbd_name, sizeof s_cfg.ble_kbd_name);
if (n >= name_cap) n = name_cap - 1;
memcpy(name, s_cfg.ble_kbd_name, n);
name[n] = '\0';
}
return true;
}
bool touchPrefsSetBleKbdPeer(const uint8_t addr[6], uint8_t addr_type, const char* name) {
if (!s_begun) touchPrefsBegin();
memset(s_cfg.ble_kbd_addr, 0, sizeof s_cfg.ble_kbd_addr);
memset(s_cfg.ble_kbd_name, 0, sizeof s_cfg.ble_kbd_name);
s_cfg.ble_kbd_addr_type = 0;
if (addr) {
memcpy(s_cfg.ble_kbd_addr, addr, 6);
s_cfg.ble_kbd_addr_type = addr_type;
if (name) strncpy(s_cfg.ble_kbd_name, name, sizeof s_cfg.ble_kbd_name - 1);
}
return cfgFlush();
}
// Store ALL device data (identity, prefs, contacts, channels) on the SD card
// under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE
// 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* 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)) {
nvs_val = p.getBool(KEY_USE_SD_STORAGE, false);
p.end();
}
if (nvs_val) return true;
// 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 found && file_val;
}
bool touchPrefsGetUseSdStorage() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getBool(KEY_USE_SD_STORAGE, false); // default = SPIFFS
}
bool touchPrefsSetUseSdStorage(bool use_sd) {
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putBool(KEY_USE_SD_STORAGE, use_sd);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
// Mirror to raw NVS for the boot read (PR #123). Best effort ONLY: on
// Launcher installs NVS is unusable and the file write above is the
// authoritative copy — a failed mirror must not fail the setter there.
Preferences nvs;
if (nvs.begin(TOUCH_NS, false)) {
nvs.putBool(KEY_USE_SD_STORAGE, use_sd);
nvs.end();
}
// 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
// the active translation language. Packed into the "cfg" blob.
uint8_t touchPrefsGetUiLang() {
if (!s_begun) touchPrefsBegin();
return s_cfg.ui_lang; // default = English
}
bool touchPrefsSetUiLang(uint8_t lang) {
if (!s_begun) touchPrefsBegin();
s_cfg.ui_lang = lang;
return cfgFlush();
}
void touchPrefsGetLangFile(char* out, size_t cap) {
if (!out || !cap) return;
if (!s_begun) touchPrefsBegin();
size_t n = strnlen(s_cfg.lang_file, sizeof s_cfg.lang_file);
if (n >= cap) n = cap - 1;
memcpy(out, s_cfg.lang_file, n);
out[n] = 0;
}
bool touchPrefsSetLangFile(const char* code) {
if (!s_begun) touchPrefsBegin();
memset(s_cfg.lang_file, 0, sizeof s_cfg.lang_file);
if (code) strncpy(s_cfg.lang_file, code, sizeof s_cfg.lang_file - 1);
return cfgFlush();
}
uint8_t touchPrefsGetUiRotation() {
if (!s_begun) touchPrefsBegin();
const uint8_t r = s_cfg.ui_rotation;
return (s_cfg_loaded && r <= 3) ? r : DEFAULT_UI_ROTATION;
}
bool touchPrefsSetUiRotation(uint8_t rot) {
if (rot > 3) rot = 0;
if (!s_begun) touchPrefsBegin();
const uint8_t previous = s_cfg.ui_rotation;
s_cfg.ui_rotation = rot;
if (cfgFlush()) return true;
s_cfg.ui_rotation = previous;
return false;
}
uint16_t touchPrefsGetBattFullMv() {
if (!s_begun) touchPrefsBegin();
return s_cfg.batt_full_mv; // 0 = not calibrated -> default 4200
}
bool touchPrefsSetBattFullMv(uint16_t mv) {
if (!s_begun) touchPrefsBegin();
s_cfg.batt_full_mv = mv;
return cfgFlush();
}
// Wi-Fi profile slots ----------------------------------------------------
//
// NVS keys: "wsl_<idx>_l" (label), "wsl_<idx>_s" (ssid), "wsl_<idx>_p" (pwd).
// 3 slots × 3 strings = 9 small entries; well under the 12 KB NVS default.
static void wifiSlotKey(int idx, char kind, char out[12]) {
// wsl<idx><kind> → "wsl0l", "wsl1s", "wsl2p", ...
int p = 0;
out[p++] = 'w'; out[p++] = 's'; out[p++] = 'l';
out[p++] = (char)('0' + (idx & 0x07));
out[p++] = kind;
out[p] = '\0';
}
bool touchPrefsGetWifiSlot(int idx, char* label, int label_cap,
char* ssid, int ssid_cap,
char* pwd, int pwd_cap) {
if (idx < 0 || idx >= TOUCH_WIFI_SLOT_COUNT) return false;
if (label && label_cap > 0) label[0] = '\0';
if (ssid && ssid_cap > 0) ssid[0] = '\0';
if (pwd && pwd_cap > 0) pwd[0] = '\0';
if (!s_begun) touchPrefsBegin();
char k[12];
if (label && label_cap > 0) {
wifiSlotKey(idx, 'l', k);
String v = prefsGetStr(k, "");
int n = (int)v.length();
if (n > label_cap - 1) n = label_cap - 1;
if (n > 0) memcpy(label, v.c_str(), (size_t)n);
label[n] = '\0';
}
if (ssid && ssid_cap > 0) {
wifiSlotKey(idx, 's', k);
String v = prefsGetStr(k, "");
int n = (int)v.length();
if (n > ssid_cap - 1) n = ssid_cap - 1;
if (n > 0) memcpy(ssid, v.c_str(), (size_t)n);
ssid[n] = '\0';
}
if (pwd && pwd_cap > 0) {
wifiSlotKey(idx, 'p', k);
String v = prefsGetStr(k, "");
int n = (int)v.length();
if (n > pwd_cap - 1) n = pwd_cap - 1;
if (n > 0) memcpy(pwd, v.c_str(), (size_t)n);
pwd[n] = '\0';
}
return true;
}
bool touchPrefsSetWifiSlot(int idx, const char* label,
const char* ssid, const char* pwd) {
if (idx < 0 || idx >= TOUCH_WIFI_SLOT_COUNT) return false;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
char k[12];
wifiSlotKey(idx, 'l', k); s_prefs.putString(k, label ? label : "");
wifiSlotKey(idx, 's', k); s_prefs.putString(k, ssid ? ssid : "");
wifiSlotKey(idx, 'p', k); s_prefs.putString(k, pwd ? pwd : "");
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return true;
}
// ---- Saved "known networks" store (reworked iPhone/Android-style Wi-Fi) -----
// Keys per idx: "wn<idx>s" ssid, "wn<idx>p" pwd, "wn<idx>f" flags uchar
// (bit0 used, bit1 auto-join), "wn<idx>r" rank uint32. "wnctr" = global recency.
static void wifiNetKey(int idx, char kind, char out[8]) {
int p = 0;
out[p++] = 'w'; out[p++] = 'n';
out[p++] = (char)('0' + (idx & 0x07));
out[p++] = kind;
out[p] = '\0';
}
bool touchPrefsGetWifiNet(int idx, TouchWifiNet& out) {
memset(&out, 0, sizeof(out));
if (idx < 0 || idx >= TOUCH_WIFI_NET_COUNT) return false;
if (!s_begun) touchPrefsBegin();
char k[8];
wifiNetKey(idx, 'f', k);
const uint8_t flags = s_prefs.isKey(k) ? s_prefs.getUChar(k, 0) : 0;
out.used = (flags & 0x01) != 0;
out.auto_join = (flags & 0x02) != 0;
if (!out.used) return true;
wifiNetKey(idx, 's', k); { String v = prefsGetStr(k, ""); strlcpy(out.ssid, v.c_str(), sizeof(out.ssid)); }
wifiNetKey(idx, 'p', k); { String v = prefsGetStr(k, ""); strlcpy(out.pwd, v.c_str(), sizeof(out.pwd)); }
wifiNetKey(idx, 'r', k); out.rank = s_prefs.isKey(k) ? s_prefs.getUInt(k, 0) : 0;
return true;
}
int touchPrefsFindWifiNet(const char* ssid) {
if (!ssid || !ssid[0]) return -1;
TouchWifiNet n;
for (int i = 0; i < TOUCH_WIFI_NET_COUNT; ++i)
if (touchPrefsGetWifiNet(i, n) && n.used && strcmp(n.ssid, ssid) == 0) return i;
return -1;
}
int touchPrefsSaveWifiNet(const char* ssid, const char* pwd, bool auto_join) {
if (!ssid || !ssid[0]) return -1;
if (!s_begun) touchPrefsBegin();
TouchWifiNet n;
int idx = touchPrefsFindWifiNet(ssid); // update existing by ssid
if (idx < 0) { // else first free slot
for (int i = 0; i < TOUCH_WIFI_NET_COUNT && idx < 0; ++i)
if (touchPrefsGetWifiNet(i, n) && !n.used) idx = i;
}
if (idx < 0) { // else evict the least-recent
uint32_t lo = UINT32_MAX; int loi = 0;
for (int i = 0; i < TOUCH_WIFI_NET_COUNT; ++i) {
touchPrefsGetWifiNet(i, n);
if (n.rank <= lo) { lo = n.rank; loi = i; }
}
idx = loi;
}
// Preserve the existing passphrase if the caller passed an empty one (e.g. a
// metadata-only re-save).
char use_pwd[65];
if (pwd && pwd[0]) strlcpy(use_pwd, pwd, sizeof(use_pwd));
else { TouchWifiNet ex; use_pwd[0] = '\0'; if (touchPrefsGetWifiNet(idx, ex) && ex.used) strlcpy(use_pwd, ex.pwd, sizeof(use_pwd)); }
const uint32_t ctr = (s_prefs.isKey("wnctr") ? s_prefs.getUInt("wnctr", 0) : 0) + 1;
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return -1; }
char k[8];
wifiNetKey(idx, 's', k); s_prefs.putString(k, ssid);
wifiNetKey(idx, 'p', k); s_prefs.putString(k, use_pwd);
wifiNetKey(idx, 'f', k); s_prefs.putUChar(k, (uint8_t)(0x01 | (auto_join ? 0x02 : 0)));
wifiNetKey(idx, 'r', k); s_prefs.putUInt(k, ctr);
s_prefs.putUInt("wnctr", ctr);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return idx;
}
bool touchPrefsForgetWifiNet(int idx) {
if (idx < 0 || idx >= TOUCH_WIFI_NET_COUNT) return false;
TouchWifiNet n;
const bool had = touchPrefsGetWifiNet(idx, n) && n.used;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return false; }
char k[8];
wifiNetKey(idx, 's', k); s_prefs.remove(k);
wifiNetKey(idx, 'p', k); s_prefs.remove(k);
wifiNetKey(idx, 'f', k); s_prefs.remove(k); // clears the used bit
wifiNetKey(idx, 'r', k); s_prefs.remove(k);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
// If this was the network the device is using/targeting, also drop the ACTIVE
// credentials so main.cpp stops trying to (re)connect to it (the reconnect loop
// is gated on wifiConfigHasRuntime(), which clearing makes false).
if (had && n.ssid[0]) {
char active[WIFI_CONFIG_SSID_MAX] = {0};
wifiConfigGetSsid(active, sizeof active);
if (strcmp(active, n.ssid) == 0) {
wifiConfigClear();
wifiConfigRequestApply();
}
}
return true;
}
bool touchPrefsSetWifiNetAutoJoin(int idx, bool on) {
TouchWifiNet n;
if (!touchPrefsGetWifiNet(idx, n) || !n.used) return false;
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return false; }
char k[8];
wifiNetKey(idx, 'f', k); s_prefs.putUChar(k, (uint8_t)(0x01 | (on ? 0x02 : 0)));
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return true;
}
bool touchPrefsConnectWifiNet(int idx) {
TouchWifiNet n;
if (!touchPrefsGetWifiNet(idx, n) || !n.used || !n.ssid[0]) return false;
if (!wifiConfigSetSsid(n.ssid)) return false;
if (!wifiConfigSetPwd(n.pwd)) return false;
wifiConfigSetRadioEnabled(true);
wifiConfigRequestApply();
touchPrefsSaveWifiNet(n.ssid, n.pwd, n.auto_join); // re-save bumps recency
return true;
}
// Favorites blob (raw bytes: N * 6-byte pub_key prefixes, packed) ----------
static const char* KEY_FAV = "fav";
static int favReadAll(uint8_t out[TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES]) {
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_FAV)) return 0; // absent on a fresh device — skip the [E] NOT_FOUND log
size_t n = s_prefs.getBytes(KEY_FAV, out, TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES);
if (n == 0 || n > (size_t)(TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES)) return 0;
// Round down to a whole number of entries — guards against NVS returning
// a half-written blob from a power-cut mid-write.
return (int)(n / TOUCH_FAVORITE_KEY_BYTES);
}
static bool favWriteAll(const uint8_t* buf, int count) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok;
if (count <= 0) {
s_prefs.remove(KEY_FAV);
ok = true;
} else {
ok = s_prefs.putBytes(KEY_FAV, buf, (size_t)(count * TOUCH_FAVORITE_KEY_BYTES)) > 0;
}
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
bool touchPrefsIsFavorite(const uint8_t* pub_key6) {
if (!pub_key6) return false;
uint8_t buf[TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES];
int n = favReadAll(buf);
for (int i = 0; i < n; ++i) {
if (memcmp(&buf[i * TOUCH_FAVORITE_KEY_BYTES], pub_key6, TOUCH_FAVORITE_KEY_BYTES) == 0) return true;
}
return false;
}
int touchPrefsCopyFavorites(uint8_t* out_buf) {
if (!out_buf) return 0;
return favReadAll(out_buf);
}
bool touchPrefsFavoritesSnapshotContains(const uint8_t* snapshot, int count,
const uint8_t* pub_key6) {
if (!snapshot || !pub_key6 || count <= 0) return false;
for (int i = 0; i < count; ++i) {
if (memcmp(&snapshot[i * TOUCH_FAVORITE_KEY_BYTES], pub_key6,
TOUCH_FAVORITE_KEY_BYTES) == 0) return true;
}
return false;
}
bool touchPrefsSetFavorite(const uint8_t* pub_key6, bool fav) {
if (!pub_key6) return false;
uint8_t buf[TOUCH_FAVORITES_MAX * TOUCH_FAVORITE_KEY_BYTES];
int n = favReadAll(buf);
int found = -1;
for (int i = 0; i < n; ++i) {
if (memcmp(&buf[i * TOUCH_FAVORITE_KEY_BYTES], pub_key6, TOUCH_FAVORITE_KEY_BYTES) == 0) {
found = i; break;
}
}
if (fav) {
if (found >= 0) return true;
if (n >= TOUCH_FAVORITES_MAX) return false; // cap reached, silently refuse
memcpy(&buf[n * TOUCH_FAVORITE_KEY_BYTES], pub_key6, TOUCH_FAVORITE_KEY_BYTES);
++n;
favWriteAll(buf, n);
return true;
} else {
if (found < 0) return false;
// Shift remaining entries down to keep the blob packed.
for (int i = found; i < n - 1; ++i) {
memcpy(&buf[i * TOUCH_FAVORITE_KEY_BYTES],
&buf[(i + 1) * TOUCH_FAVORITE_KEY_BYTES],
TOUCH_FAVORITE_KEY_BYTES);
}
--n;
favWriteAll(buf, n);
return false;
}
}
// Ignored / blocked senders -------------------------------------------------
//
// Same scheme as favorites: a single NVS blob "ign" of up to TOUCH_IGNORED_MAX
// 6-byte pubkey prefixes. Incoming messages from a stored prefix are dropped
// (no chat entry, no notification). Managed from the chat "Blocked users" sheet.
static const char* KEY_IGN = "ign";
static int ignReadAll(uint8_t out[TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES]) {
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_IGN)) return 0; // absent on a fresh device — skip the [E] NOT_FOUND log
size_t n = s_prefs.getBytes(KEY_IGN, out, TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES);
if (n == 0 || n > (size_t)(TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES)) return 0;
return (int)(n / TOUCH_IGNORE_KEY_BYTES);
}
static bool ignWriteAll(const uint8_t* buf, int count) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok;
if (count <= 0) { s_prefs.remove(KEY_IGN); ok = true; }
else ok = s_prefs.putBytes(KEY_IGN, buf, (size_t)(count * TOUCH_IGNORE_KEY_BYTES)) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
bool touchPrefsIsIgnored(const uint8_t* pub_key6) {
if (!pub_key6) return false;
uint8_t buf[TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES];
int n = ignReadAll(buf);
for (int i = 0; i < n; ++i)
if (memcmp(&buf[i * TOUCH_IGNORE_KEY_BYTES], pub_key6, TOUCH_IGNORE_KEY_BYTES) == 0) return true;
return false;
}
int touchPrefsCopyIgnored(uint8_t* out_buf) {
if (!out_buf) return 0;
return ignReadAll(out_buf);
}
bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored) {
if (!pub_key6) return false;
uint8_t buf[TOUCH_IGNORED_MAX * TOUCH_IGNORE_KEY_BYTES];
int n = ignReadAll(buf);
int found = -1;
for (int i = 0; i < n; ++i)
if (memcmp(&buf[i * TOUCH_IGNORE_KEY_BYTES], pub_key6, TOUCH_IGNORE_KEY_BYTES) == 0) { found = i; break; }
if (ignored) {
if (found >= 0) return true;
if (n >= TOUCH_IGNORED_MAX) return false; // cap reached, silently refuse
memcpy(&buf[n * TOUCH_IGNORE_KEY_BYTES], pub_key6, TOUCH_IGNORE_KEY_BYTES);
++n; ignWriteAll(buf, n); return true;
} else {
if (found < 0) return false;
for (int i = found; i < n - 1; ++i)
memcpy(&buf[i * TOUCH_IGNORE_KEY_BYTES], &buf[(i + 1) * TOUCH_IGNORE_KEY_BYTES], TOUCH_IGNORE_KEY_BYTES);
--n; ignWriteAll(buf, n); return false;
}
}
// Ignored / blocked sender NAMES (channel/room senders that aren't contacts) ---
// One NVS blob "ign_nm2" of up to TOUCH_IGNORED_NAMES_MAX fixed-width,
// NUL-padded TOUCH_IGNORED_NAME_LEN slots. Same read/replace/write scheme as
// the 6-byte prefix list above.
// The blob carries no header — the entry count is derived by dividing its length by the
// slot width — so widening the slot re-slots every stored entry: slot 1 would start inside
// the old name 0, every row after the first would render as a mangled suffix and would stop
// matching, and the first write back would make that permanent. A new key sidesteps it: the
// old blob is read once with the OLD stride, re-slotted, written here, and removed. A key
// rename is unambiguous by construction, unlike sniffing the blob length (28 and 32 share
// multiples at 224 and 448 bytes, both inside the 16-entry cap).
static const char* KEY_IGN_NAMES = "ign_nm2";
static const char* KEY_IGN_NAMES_V1 = "ign_nm";
constexpr int TOUCH_IGN_NAME_LEN_V1 = 28;
// One-shot: fold a pre-widening "ign_nm" blob into the current key. No-op once migrated
// (and on a fresh device, where neither key exists). Latched for the boot because the
// caller runs on the RX path for EVERY incoming message — two NVS key lookups per message
// is not a price worth paying for a check that can only change once.
static bool s_ign_names_migrated = false;
static void ignNamesMigrateV1() {
if (s_ign_names_migrated) return;
if (!s_begun) touchPrefsBegin();
if (s_prefs.isKey(KEY_IGN_NAMES) || !s_prefs.isKey(KEY_IGN_NAMES_V1)) {
s_ign_names_migrated = true; // nothing to fold in, now or on any later call
return;
}
char old_buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGN_NAME_LEN_V1];
size_t n = s_prefs.getBytes(KEY_IGN_NAMES_V1, old_buf, sizeof(old_buf));
const int count = (n == 0 || n > sizeof(old_buf)) ? 0 : (int)(n / TOUCH_IGN_NAME_LEN_V1);
char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN];
memset(buf, 0, sizeof(buf));
for (int i = 0; i < count; ++i) {
// Old slots hold at most 27 chars, so they always fit the wider one.
strncpy(&buf[i * TOUCH_IGNORED_NAME_LEN], &old_buf[i * TOUCH_IGN_NAME_LEN_V1],
TOUCH_IGN_NAME_LEN_V1 - 1);
}
s_prefs.end();
bool ok = false;
if (s_prefs.begin(TOUCH_NS, false)) {
ok = (count == 0) ||
s_prefs.putBytes(KEY_IGN_NAMES, buf, (size_t)(count * TOUCH_IGNORED_NAME_LEN)) > 0;
if (ok) s_prefs.remove(KEY_IGN_NAMES_V1); // only once the new key actually holds the list
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
// Latch on SUCCESS only. Latching a failed fold would leave the list reading empty for
// the rest of the boot — every block silently stops firing — and the next Block tap
// would then write a fresh ign_nm2 that orphans every old entry for good. Retrying
// costs the two key lookups per message the latch exists to avoid, but only while the
// store is already refusing writes.
s_ign_names_migrated = ok;
}
static int ignNamesReadAll(char out[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]) {
if (!s_begun) touchPrefsBegin();
ignNamesMigrateV1();
if (!s_prefs.isKey(KEY_IGN_NAMES)) return 0; // absent on a fresh device — skip [E] NOT_FOUND
size_t n = s_prefs.getBytes(KEY_IGN_NAMES, out, TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN);
if (n == 0 || n > (size_t)(TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN)) return 0;
return (int)(n / TOUCH_IGNORED_NAME_LEN);
}
static bool ignNamesWriteAll(const char* buf, int count) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok;
if (count <= 0) { s_prefs.remove(KEY_IGN_NAMES); ok = true; }
else ok = s_prefs.putBytes(KEY_IGN_NAMES, buf, (size_t)(count * TOUCH_IGNORED_NAME_LEN)) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
bool touchPrefsIsNameIgnored(const char* name) {
if (!name || !name[0]) return false;
char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN];
int n = ignNamesReadAll(buf);
for (int i = 0; i < n; ++i)
if (strncmp(&buf[i * TOUCH_IGNORED_NAME_LEN], name, TOUCH_IGNORED_NAME_LEN) == 0) return true;
return false;
}
int touchPrefsCopyIgnoredNames(char* out_buf) {
if (!out_buf) return 0;
return ignNamesReadAll(out_buf);
}
bool touchPrefsSetNameIgnored(const char* name, bool ignored) {
if (!name || !name[0]) return false;
char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN];
int n = ignNamesReadAll(buf);
int found = -1;
for (int i = 0; i < n; ++i)
if (strncmp(&buf[i * TOUCH_IGNORED_NAME_LEN], name, TOUCH_IGNORED_NAME_LEN) == 0) { found = i; break; }
if (ignored) {
if (found >= 0) return true;
if (n >= TOUCH_IGNORED_NAMES_MAX) return false; // cap reached, silently refuse
char* slot = &buf[n * TOUCH_IGNORED_NAME_LEN];
memset(slot, 0, TOUCH_IGNORED_NAME_LEN);
strncpy(slot, name, TOUCH_IGNORED_NAME_LEN - 1);
++n; ignNamesWriteAll(buf, n); return true;
} else {
if (found < 0) return false;
for (int i = found; i < n - 1; ++i)
memcpy(&buf[i * TOUCH_IGNORED_NAME_LEN], &buf[(i + 1) * TOUCH_IGNORED_NAME_LEN], TOUCH_IGNORED_NAME_LEN);
--n; ignNamesWriteAll(buf, n); return false;
}
}
// Notification-sound prefs (individual NVS keys — integer getters don't emit the
// [E] NOT_FOUND log that getString/getBytes do on a fresh device) -----------
static void prefsPutUChar(const char* key, uint8_t v) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return; }
s_prefs.putUChar(key, v);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// Same re-open dance as prefsPutUChar above, for the one 16-bit setting.
static void prefsPutUShort(const char* key, uint16_t v) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return; }
s_prefs.putUShort(key, v);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
}
bool touchPrefsGetSoundMessages() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("snd_msg", 1) != 0;
}
void touchPrefsSetSoundMessages(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_msg", on ? 1 : 0); }
bool touchPrefsGetSoundMentions() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("snd_men", 1) != 0;
}
void touchPrefsSetSoundMentions(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_men", on ? 1 : 0); }
bool touchPrefsGetSoundDirect() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("snd_dm", 1) != 0;
}
void touchPrefsSetSoundDirect(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_dm", on ? 1 : 0); }
// Drop incoming text messages whose body is a single character. Mesh spam is
// overwhelmingly 1-byte payloads (cheapest possible airtime per message), and a
// 1-character message is never something a person meant to send. Off by default:
// it is a filter on other people's traffic, so it should be a deliberate choice.
bool touchPrefsGetIgnoreTinyMsgs() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("ign_tiny", 0) != 0;
}
void touchPrefsSetIgnoreTinyMsgs(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("ign_tiny", on ? 1 : 0); }
uint8_t touchPrefsGetP4KbLight(uint8_t def) {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("p4kb_light", def);
}
void touchPrefsSetP4KbLight(uint8_t step) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("p4kb_light", step); }
// Most contact dots to draw on the map at once. 0 = no limit (draw every positioned
// contact in view, up to the firmware's own ceiling), which is the default: a map that
// quietly stops plotting is worse than a slow one. Lower it on a board that struggles.
uint16_t touchPrefsGetMapMarkerCap() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUShort("map_cap", 0);
}
void touchPrefsSetMapMarkerCap(uint16_t n) { if (!s_begun) touchPrefsBegin(); prefsPutUShort("map_cap", n); }
bool touchPrefsGetDiscoveredAutoEvict() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("dsc_evict", 1) != 0;
}
void touchPrefsSetDiscoveredAutoEvict(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("dsc_evict", on ? 1 : 0); }
uint8_t touchPrefsGetDiscoveredMaxHops() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getUChar("dsc_hops", 0); // 0 = off
}
void touchPrefsSetDiscoveredMaxHops(uint8_t hops) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("dsc_hops", hops); }
bool touchPrefsGetEnterSends() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("ent_send", 1) != 0; }
void touchPrefsSetEnterSends(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("ent_send", on ? 1 : 0); }
bool touchPrefsGetClock12h() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("clk_12h", 0) != 0; }
void touchPrefsSetClock12h(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("clk_12h", on ? 1 : 0); }
bool touchPrefsGetNavMenubarKeys() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("nav_mbk", 0) != 0; }
void touchPrefsSetNavMenubarKeys(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("nav_mbk", on ? 1 : 0); }
bool touchPrefsGetScrollReverse() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("tb_rev", 0) != 0; }
void touchPrefsSetScrollReverse(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("tb_rev", on ? 1 : 0); }
bool touchPrefsGetEdgeScroll() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("tb_edgesc", 0) != 0; }
void touchPrefsSetEdgeScroll(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("tb_edgesc", on ? 1 : 0); }
bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("lock_off", 0) != 0; }
void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); }
bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; }
void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); }
bool touchPrefsGetGlanceEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_en", 1) != 0; }
void touchPrefsSetGlanceEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_en", on ? 1 : 0); }
#if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged
bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON
void touchPrefsSetMsgLed(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("msg_led", on ? 1 : 0); }
#endif
// Generic blob (used to persist the discovered-nodes list across reboots).
size_t touchPrefsGetBlob(const char* key, uint8_t* out, size_t maxlen) {
if (!s_begun) touchPrefsBegin();
if (!key || !out || !s_prefs.isKey(key)) return 0;
size_t n = s_prefs.getBytes(key, out, maxlen);
return (n > maxlen) ? 0 : n;
}
bool touchPrefsSetBlob(const char* key, const uint8_t* data, size_t len) {
if (!key) return false;
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return false; }
bool ok;
if (!data || len == 0) { s_prefs.remove(key); ok = true; }
else ok = s_prefs.putBytes(key, data, len) > 0;
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
uint8_t touchPrefsGetSoundVolume() {
if (!s_begun) touchPrefsBegin();
#if defined(TLORA_PAGER)
// The pager's ES8311 codec + NS4150B amp run noticeably louder at a given
// percentage than the T-Deck's I2S amp/Tanmatsu's ES8156 — 70% clips into
// uncomfortable territory, so this board gets a quieter first-boot default.
uint8_t v = s_prefs.getUChar("snd_vol", 50);
#else
uint8_t v = s_prefs.getUChar("snd_vol", 70);
#endif
return v > 100 ? 100 : v;
}
void touchPrefsSetSoundVolume(uint8_t vol) { if (vol > 100) vol = 100; if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_vol", vol); }
bool touchPrefsGetDndEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("dnd_en", 0) != 0; }
void touchPrefsSetDndEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_en", on ? 1 : 0); }
uint8_t touchPrefsGetDndStartSlot() {
if (!s_begun) touchPrefsBegin();
uint8_t s = s_prefs.getUChar("dnd_ss", 44);
return s > 47 ? 47 : s;
}
void touchPrefsSetDndStartSlot(uint8_t slot) { if (slot > 47) slot = 47; if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_ss", slot); }
uint8_t touchPrefsGetDndEndSlot() {
if (!s_begun) touchPrefsBegin();
uint8_t s = s_prefs.getUChar("dnd_es", 12);
return s > 47 ? 47 : s;
}
void touchPrefsSetDndEndSlot(uint8_t slot) { if (slot > 47) slot = 47; if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_es", slot); }
uint8_t touchPrefsGetKbdBacklight() {
if (!s_begun) touchPrefsBegin();
uint8_t v = s_prefs.getUChar("kbd_bl", 100);
return v > 100 ? 100 : v;
}
void touchPrefsSetKbdBacklight(uint8_t pct) { if (pct > 100) pct = 100; if (!s_begun) touchPrefsBegin(); prefsPutUChar("kbd_bl", pct); }
// Per-channel mute (name-keyed NVS blob "chm" + tiny RAM cache) --------------
static const char* KEY_CHM = "chm";
static const int CHM_ENTRY = TOUCH_CHMUTE_NAME + 1; // 32-byte name + 1 flag byte
// PSRAM-first (internal fallback), zero-initialized — keeps these keyed tables
// off the scarce internal SRAM (same pattern as UITask's psAlloc).
static void* tpPsAlloc(size_t n) {
void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM);
if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT);
if (p) memset(p, 0, n);
return p;
}
static uint8_t* s_chm = (uint8_t*)tpPsAlloc(TOUCH_CHMUTE_MAX * CHM_ENTRY);
static int s_chm_n = -1; // -1 = not loaded yet
static void chmLoad() {
if (s_chm_n >= 0) return;
s_chm_n = 0;
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_CHM)) return;
size_t n = s_prefs.getBytes(KEY_CHM, s_chm, (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY));
if (n == 0 || n > (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY)) { s_chm_n = 0; return; }
s_chm_n = (int)(n / CHM_ENTRY);
}
static int chmFind(const char* name) {
chmLoad();
for (int i = 0; i < s_chm_n; ++i)
if (strncmp((const char*)&s_chm[i * CHM_ENTRY], name, TOUCH_CHMUTE_NAME) == 0) return i;
return -1;
}
uint8_t touchPrefsGetChannelMute(const char* name) {
if (!name || !name[0]) return 0;
int i = chmFind(name);
return i < 0 ? 0 : s_chm[i * CHM_ENTRY + TOUCH_CHMUTE_NAME];
}
void touchPrefsSetChannelMute(const char* name, uint8_t flags) {
if (!name || !name[0]) return;
chmLoad();
int i = chmFind(name);
if (flags == 0) {
if (i < 0) return;
for (int j = i; j + 1 < s_chm_n; ++j) memcpy(&s_chm[j * CHM_ENTRY], &s_chm[(j + 1) * CHM_ENTRY], CHM_ENTRY);
--s_chm_n;
} else {
if (i < 0) {
if (s_chm_n >= TOUCH_CHMUTE_MAX) return; // cap reached
i = s_chm_n++;
memset(&s_chm[i * CHM_ENTRY], 0, CHM_ENTRY);
strncpy((char*)&s_chm[i * CHM_ENTRY], name, TOUCH_CHMUTE_NAME - 1);
}
s_chm[i * CHM_ENTRY + TOUCH_CHMUTE_NAME] = flags;
}
s_prefs.end();
if (s_prefs.begin(TOUCH_NS, false)) {
if (s_chm_n == 0) s_prefs.remove(KEY_CHM);
else s_prefs.putBytes(KEY_CHM, s_chm, (size_t)(s_chm_n * CHM_ENTRY));
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// ---- Per-channel avatar emoji (chat-list avatar override) ----
// Same keyed-blob pattern as the mute table above: 32-byte name + 16-byte UTF-8
// glyph (16 covers ZWJ sequences). No entry = auto (the two-letter avatar).
// 24 entries x 48 B = 1152 B, safely under the SdNvsPrefs 2048-byte blob cap.
static const int CHE_NAME = TOUCH_CHMUTE_NAME;
static const int CHE_GLYPH = 16;
static const int CHE_ENTRY = CHE_NAME + CHE_GLYPH;
static const int CHE_MAX = 24;
static const char* KEY_CHE = "chemoji";
static uint8_t* s_che = (uint8_t*)tpPsAlloc(CHE_MAX * CHE_ENTRY);
static int s_che_n = -1; // -1 = not loaded yet
static void cheLoad() {
if (s_che_n >= 0) return;
s_che_n = 0;
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_CHE)) return;
size_t n = s_prefs.getBytes(KEY_CHE, s_che, (size_t)(CHE_MAX * CHE_ENTRY));
if (n == 0 || n > (size_t)(CHE_MAX * CHE_ENTRY)) { s_che_n = 0; return; }
s_che_n = (int)(n / CHE_ENTRY);
}
static int cheFind(const char* name) {
cheLoad();
for (int i = 0; i < s_che_n; ++i)
if (strncmp((const char*)&s_che[i * CHE_ENTRY], name, CHE_NAME) == 0) return i;
return -1;
}
bool touchPrefsGetChannelEmoji(const char* name, char* out, size_t cap) {
if (out && cap) out[0] = '\0';
if (!name || !name[0] || !out || cap == 0) return false;
int i = cheFind(name);
if (i < 0) return false;
const char* g = (const char*)&s_che[i * CHE_ENTRY + CHE_NAME];
size_t n = strnlen(g, CHE_GLYPH);
if (n >= cap) n = cap - 1;
memcpy(out, g, n);
out[n] = '\0';
return out[0] != '\0';
}
void touchPrefsSetChannelEmoji(const char* name, const char* utf8) {
if (!name || !name[0]) return;
cheLoad();
int i = cheFind(name);
if (!utf8 || !utf8[0]) { // clear -> back to auto letters
if (i < 0) return;
for (int j = i; j + 1 < s_che_n; ++j) memcpy(&s_che[j * CHE_ENTRY], &s_che[(j + 1) * CHE_ENTRY], CHE_ENTRY);
--s_che_n;
} else {
if (i < 0) {
if (s_che_n >= CHE_MAX) return; // cap reached
i = s_che_n++;
memset(&s_che[i * CHE_ENTRY], 0, CHE_ENTRY);
strncpy((char*)&s_che[i * CHE_ENTRY], name, CHE_NAME - 1);
}
memset(&s_che[i * CHE_ENTRY + CHE_NAME], 0, CHE_GLYPH);
strncpy((char*)&s_che[i * CHE_ENTRY + CHE_NAME], utf8, CHE_GLYPH - 1);
}
s_prefs.end();
if (s_prefs.begin(TOUCH_NS, false)) {
if (s_che_n == 0) s_prefs.remove(KEY_CHE);
else s_prefs.putBytes(KEY_CHE, s_che, (size_t)(s_che_n * CHE_ENTRY));
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// Remembered repeater admin passwords --------------------------------------
//
// Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records,
// each record = [6-byte pubkey prefix][16-byte null-terminated password].
// Empty/cleared records are removed (the blob is repacked) so reading the
// blob length tells you exactly how many entries exist.
static const char* KEY_RPW = "rpw";
constexpr int RPW_REC_BYTES = TOUCH_REPEATER_PW_KEY_LEN + TOUCH_REPEATER_PW_LEN; // 6 + 16 = 22
static int rpwReadAll(uint8_t out[TOUCH_REPEATER_PW_MAX * RPW_REC_BYTES]) {
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_RPW)) return 0; // absent on a fresh device — skip the [E] NOT_FOUND log
size_t n = s_prefs.getBytes(KEY_RPW, out, TOUCH_REPEATER_PW_MAX * RPW_REC_BYTES);
if (n == 0 || n > (size_t)(TOUCH_REPEATER_PW_MAX * RPW_REC_BYTES)) return 0;
return (int)(n / RPW_REC_BYTES);
}
static bool rpwWriteAll(const uint8_t* buf, int count) {
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok;
if (count <= 0) {
s_prefs.remove(KEY_RPW);
ok = true;
} else {
ok = s_prefs.putBytes(KEY_RPW, buf, (size_t)(count * RPW_REC_BYTES)) > 0;
}
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
int touchPrefsGetRepeaterPassword(const uint8_t* pub_key6, char* out, int out_cap) {
if (!out || out_cap <= 0) return 0;
out[0] = '\0';
if (!pub_key6) return 0;
uint8_t buf[TOUCH_REPEATER_PW_MAX * RPW_REC_BYTES];
int n = rpwReadAll(buf);
for (int i = 0; i < n; ++i) {
const uint8_t* rec = &buf[i * RPW_REC_BYTES];
if (memcmp(rec, pub_key6, TOUCH_REPEATER_PW_KEY_LEN) == 0) {
const char* pw = (const char*)(rec + TOUCH_REPEATER_PW_KEY_LEN);
int plen = 0;
while (plen < TOUCH_REPEATER_PW_LEN - 1 && pw[plen] != '\0') ++plen;
if (plen > out_cap - 1) plen = out_cap - 1;
memcpy(out, pw, (size_t)plen);
out[plen] = '\0';
return plen;
}
}
return 0;
}
bool touchPrefsSetRepeaterPassword(const uint8_t* pub_key6, const char* password) {
if (!pub_key6) return false;
uint8_t buf[TOUCH_REPEATER_PW_MAX * RPW_REC_BYTES];
int n = rpwReadAll(buf);
int found = -1;
for (int i = 0; i < n; ++i) {
if (memcmp(&buf[i * RPW_REC_BYTES], pub_key6, TOUCH_REPEATER_PW_KEY_LEN) == 0) {
found = i; break;
}
}
// Treat null/empty password as a remove request — saves NVS bytes and
// avoids confusing "remembered but empty" cases.
bool remove = !password || password[0] == '\0';
if (remove) {
if (found < 0) return true;
for (int i = found; i < n - 1; ++i) {
memcpy(&buf[i * RPW_REC_BYTES], &buf[(i + 1) * RPW_REC_BYTES], RPW_REC_BYTES);
}
--n;
return rpwWriteAll(buf, n);
}
// Add or overwrite. Cap reached → silently refuse.
int slot = found;
if (slot < 0) {
if (n >= TOUCH_REPEATER_PW_MAX) return false;
slot = n++;
}
uint8_t* rec = &buf[slot * RPW_REC_BYTES];
memcpy(rec, pub_key6, TOUCH_REPEATER_PW_KEY_LEN);
// Pad password slot with zeros, then copy up to PW_LEN-1 chars.
memset(rec + TOUCH_REPEATER_PW_KEY_LEN, 0, TOUCH_REPEATER_PW_LEN);
int plen = (int)strlen(password);
if (plen > TOUCH_REPEATER_PW_LEN - 1) plen = TOUCH_REPEATER_PW_LEN - 1;
memcpy(rec + TOUCH_REPEATER_PW_KEY_LEN, password, (size_t)plen);
return rpwWriteAll(buf, n);
}
bool touchPrefsActivateWifiSlot(int idx) {
char label[TOUCH_WIFI_LABEL_MAX];
char ssid[WIFI_CONFIG_SSID_MAX];
char pwd[WIFI_CONFIG_PWD_MAX];
if (!touchPrefsGetWifiSlot(idx, label, sizeof(label),
ssid, sizeof(ssid), pwd, sizeof(pwd))) {
return false;
}
if (ssid[0] == '\0') return false; // refuse to activate an empty slot
if (!wifiConfigSetSsid(ssid)) return false;
if (!wifiConfigSetPwd(pwd)) return false;
wifiConfigSetRadioEnabled(true);
wifiConfigRequestApply();
return true;
}
bool touchPrefsSetQuickReply(int idx, const char* text) {
if (idx < 0 || idx >= TOUCH_QUICK_REPLY_COUNT) return false;
if (!s_begun) touchPrefsBegin();
// Open RW. Truncate to TOUCH_QUICK_REPLY_MAXLEN-1 to bound NVS usage.
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
char key[8];
qrKeyFor(idx, key);
char buf[TOUCH_QUICK_REPLY_MAXLEN];
buf[0] = '\0';
if (text) {
int n = (int)strlen(text);
if (n > TOUCH_QUICK_REPLY_MAXLEN - 1) n = TOUCH_QUICK_REPLY_MAXLEN - 1;
memcpy(buf, text, (size_t)n);
buf[n] = '\0';
}
bool ok = s_prefs.putString(key, buf) > 0 || (buf[0] == '\0');
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
static const char* KEY_SETUP_DONE = "setup_ok";
bool touchPrefsGetSetupDone() {
if (!s_begun) touchPrefsBegin();
return s_prefs.getBool(KEY_SETUP_DONE, false);
}
bool touchPrefsSetSetupDone(bool done) {
if (!s_begun) touchPrefsBegin();
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) return false;
bool ok = s_prefs.putBool(KEY_SETUP_DONE, done);
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
uint32_t touchPrefsGetGpsBaud(uint32_t fallback) {
if (!s_begun) touchPrefsBegin();
// 0 = never set -> caller's compile-time default. A real configured baud is
// always non-zero, so this preserves the old "absent key -> fallback" result.
return s_cfg.gps_baud != 0 ? s_cfg.gps_baud : fallback;
}
bool touchPrefsSetGpsBaud(uint32_t baud) {
if (!s_begun) touchPrefsBegin();
s_cfg.gps_baud = baud;
return cfgFlush();
}
// ---------------------------------------------------------------------------
// Settings backup: screen and app preferences
// ---------------------------------------------------------------------------
// An explicit allow-list rather than a namespace dump: SdNvsPrefs cannot list its
// keys in both backends, the file backend does not keep value types, and import must
// never write a key this build does not own (or with the wrong type — NVS refuses a
// uchar read of a string key). Left out on purpose: "use_sd" (storage choice of this
// unit), "setup_ok" (the wizard), "rgn_scope" (exported with the node settings, where
// import also derives the flood-scope key), and the discovered-nodes cache.
struct BkKey { char name[16]; char type; };
static int backupKeys(BkKey* out, int cap) {
int n = 0;
auto add = [&](const char* k, char t) {
if (n < cap) { strncpy(out[n].name, k, sizeof(out[n].name) - 1); out[n].name[15] = '\0'; out[n].type = t; n++; }
};
add(KEY_CFG, 'b');
static const char* const k_u8[] = {
"snd_msg", "snd_men", "snd_dm", "snd_vol", "ign_tiny", "dsc_evict", "dsc_hops",
"ent_send", "clk_12h", "nav_mbk", "tb_rev", "tb_edgesc", "lock_off", "glance_lck",
"glance_en", "msg_led", "dnd_en", "dnd_ss", "dnd_es", "kbd_bl",
};
for (const char* k : k_u8) add(k, 'c');
add("map_cap", 's');
add(KEY_TILE_SRV, 't');
add(KEY_LOCK_WALL, 't');
for (int slot : {TOUCH_SND_MSG, TOUCH_SND_DM, TOUCH_SND_MEN}) add(soundFileKey(slot), 't');
char k[16];
for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) { qrKeyFor(i, k); add(k, 't'); }
for (int i = 0; i < 64; ++i) { chanScopeKey(i, k); add(k, 't'); }
add(KEY_FAV, 'b'); add(KEY_IGN, 'b'); add(KEY_IGN_NAMES, 'b');
add(KEY_CHM, 'b'); add(KEY_CHE, 'b'); add(KEY_RPW, 'b');
add("radio_preset", 'b'); // Radio & mesh: the community preset last picked (UITask)
for (int i = 0; i < TOUCH_WIFI_NET_COUNT; ++i) {
wifiNetKey(i, 's', k); add(k, 't');
wifiNetKey(i, 'p', k); add(k, 't');
wifiNetKey(i, 'f', k); add(k, 'c');
wifiNetKey(i, 'r', k); add(k, 'u');
}
add("wnctr", 'u');
for (int i = 0; i < TOUCH_WIFI_SLOT_COUNT; ++i) {
wifiSlotKey(i, 'l', k); add(k, 't');
wifiSlotKey(i, 's', k); add(k, 't');
wifiSlotKey(i, 'p', k); add(k, 't');
}
return n;
}
static constexpr int kBackupKeyCap = 160;
static constexpr size_t kBackupBlobMax = 2048;
static void bkPrintJsonStr(Print& out, const char* s) {
out.write('"');
for (; s && *s; ++s) {
const char c = *s;
if (c == '"' || c == '\\') { out.write('\\'); out.write((uint8_t)c); }
else if ((uint8_t)c < 0x20) { char b[8]; snprintf(b, sizeof b, "\\u%04x", (unsigned)(uint8_t)c); out.print(b); }
else out.write((uint8_t)c);
}
out.write('"');
}
void touchPrefsBackupExport(Print& out) {
if (!s_begun) touchPrefsBegin();
BkKey* keys = new BkKey[kBackupKeyCap];
uint8_t* blob = (uint8_t*)malloc(kBackupBlobMax);
if (!keys || !blob) { delete[] keys; free(blob); out.print("[]"); return; }
const int n = backupKeys(keys, kBackupKeyCap);
bool first = true;
out.print("[");
static const char* HX = "0123456789abcdef";
for (int i = 0; i < n; ++i) {
const char* key = keys[i].name;
if (!s_prefs.isKey(key)) continue; // never set on this unit: its default applies on restore too
out.print(first ? "\n [" : ",\n [");
first = false;
bkPrintJsonStr(out, key);
out.print(", \"");
out.write((uint8_t)keys[i].type);
out.print("\", ");
char num[16];
switch (keys[i].type) {
case 'c': snprintf(num, sizeof num, "\"%u\"", (unsigned)s_prefs.getUChar(key, 0)); out.print(num); break;
case 's': snprintf(num, sizeof num, "\"%u\"", (unsigned)s_prefs.getUShort(key, 0)); out.print(num); break;
case 'u': snprintf(num, sizeof num, "\"%lu\"", (unsigned long)s_prefs.getUInt(key, 0)); out.print(num); break;
case 't': bkPrintJsonStr(out, s_prefs.getString(key, String("")).c_str()); break;
default: {
const size_t len = s_prefs.getBytes(key, blob, kBackupBlobMax);
out.write('"');
for (size_t j = 0; j < len && len <= kBackupBlobMax; ++j) { out.write(HX[blob[j] >> 4]); out.write(HX[blob[j] & 0xF]); }
out.write('"');
}
}
out.print("]");
}
out.print(first ? "]" : "\n ]");
delete[] keys;
free(blob);
}
static int bkHexNib(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// Merge a backed-up "cfg" blob into this unit's current settings. Fields present in
// the backup win; newer fields the backup predates keep their current values
// (overlayStored copies only the stored prefix). Then the per-unit fields are put
// back: they describe THIS hardware or a boot mode, and carrying them across could
// break GPS, the battery gauge or the send clock, or boot another device straight
// into remote/console mode.
static bool bkRestoreCfg(const uint8_t* blob, size_t len) {
if (!s_cfg_loaded) cfgLoadOrMigrate();
TouchCfg merged = s_cfg;
uint8_t stored_version = 0;
if (!TouchPrefsSchema::overlayStored(merged, blob, len, &stored_version)) return false;
const TouchCfg& cur = s_cfg;
merged.magic = cur.magic;
merged.ver = cur.ver;
merged.batt_full_mv = cur.batt_full_mv;
merged.gps_baud = cur.gps_baud;
merged.clock_floor = cur.clock_floor;
merged.p4_antenna = cur.p4_antenna;
merged.fem_lna = cur.fem_lna;
merged.remote_mode = cur.remote_mode;
merged.console_mode = cur.console_mode;
merged.kb_force_legacy = cur.kb_force_legacy;
merged.ble_kbd_mode = cur.ble_kbd_mode;
memcpy(merged.ble_kbd_addr, cur.ble_kbd_addr, sizeof(merged.ble_kbd_addr));
merged.ble_kbd_addr_type = cur.ble_kbd_addr_type;
memcpy(merged.ble_kbd_name, cur.ble_kbd_name, sizeof(merged.ble_kbd_name));
merged.report_ping = cur.report_ping;
merged.report_done_n = cur.report_done_n;
s_cfg = merged;
return cfgFlush();
}
bool touchPrefsBackupRestore(const char* key, char type, const char* value) {
if (!key || !value) return false;
if (!s_begun) touchPrefsBegin();
BkKey* keys = new BkKey[kBackupKeyCap];
if (!keys) return false;
const int n = backupKeys(keys, kBackupKeyCap);
bool allowed = false;
for (int i = 0; i < n && !allowed; ++i)
allowed = strcmp(keys[i].name, key) == 0 && keys[i].type == type;
delete[] keys;
if (!allowed) return false;
if (type == 'b') {
const size_t hl = strlen(value);
if (hl % 2 || hl / 2 > kBackupBlobMax) return false;
uint8_t* blob = (uint8_t*)malloc(hl / 2 + 1);
if (!blob) return false;
for (size_t j = 0; j < hl / 2; ++j) {
const int hi = bkHexNib(value[2 * j]), lo = bkHexNib(value[2 * j + 1]);
if (hi < 0 || lo < 0) { free(blob); return false; }
blob[j] = (uint8_t)((hi << 4) | lo);
}
bool ok;
if (strcmp(key, KEY_CFG) == 0) ok = bkRestoreCfg(blob, hl / 2);
else ok = touchPrefsSetBlob(key, blob, hl / 2);
free(blob);
return ok;
}
s_prefs.end();
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = s_prefs.begin(TOUCH_NS, true); return false; }
bool ok = false;
char* end = nullptr;
const unsigned long v = strtoul(value, &end, 10);
const bool num_ok = end && end != value && *end == '\0';
switch (type) {
case 'c': ok = num_ok && v <= 0xFF && s_prefs.putUChar(key, (uint8_t)v) > 0; break;
case 's': ok = num_ok && v <= 0xFFFF && s_prefs.putUShort(key, (uint16_t)v) > 0; break;
case 'u': ok = num_ok && v <= 0xFFFFFFFF && s_prefs.putUInt(key, (uint32_t)v) > 0; break;
case 't': ok = s_prefs.putString(key, value) > 0 || value[0] == '\0'; break;
}
s_prefs.end();
s_begun = s_prefs.begin(TOUCH_NS, true);
return ok;
}
#endif