Compare commits

...
44 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 bb95aed673 touch: beta_6 — RF Monitor sniffer app + chat icons, popup & scroll fixes
- New Monitor app (app drawer): live RF sniffer — recently-heard feed
  (type / RSSI / SNR / hops, colour-coded), link-margin grade, packet rate,
  and an RSSI + noise-floor scope with an on-screen dBm scale; portrait /
  landscape aware. Backed by a recent-RX ring buffer in MyMesh (logRxRaw).
- Chat: DM person icon / channel group icon; block non-contact senders by
  name; keep scroll position when opening a thread; contacts sort popup
  closes on select; long-press popup close-X no longer overlaps buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:37:41 +02:00
Kaj SchittecatandClaude Opus 4.8 e9ece7bf6b touch: tester feedback batch + Launcher/RTC/BLE fixes + timezone picker
Core bump: lib_deps -> meshcomod core-v1.16.3 (BLE advertises the node
name not "NimBLE"; GPS won't push a pre-fix ~1902 date).

Tim's quick-wins: Enter-key-sends toggle, 12-hour clock, lock-when-screen-off,
reverse scrollball.

Launcher / map: fix Wi-Fi tiles never downloading under Launcher (the SD-fallback
cache made tilesFsLowSpace() read the unmounted LittleFS as "full"); plot contact
markers before the tile decode so dots aren't ~2s late.

RTC / time: anchored-time guard re-applies UTC when the clock reads garbage
(GPS clobber); blank pre-2020 timestamps so empty channels don't show 1969/1970;
timezone picker (named zones with correct DST) replacing the CET-only base, with
a v2->v3 config migration mapping prior manual offsets onto a Custom zone.

BLE: persist the random pairing PIN (was re-rolled every boot).

Chat: don't auto-jump to latest on a new message when scrolled up; block-by-name
for non-contact room/channel senders (un-blockable room bots).

Power: drop CPU to 80 MHz when the screen is off; enable Wi-Fi modem-sleep only
once associated (enabling it on the unassociated STA broke the setup-wizard scan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:40:55 +02:00
Dan Vybiral 976bf4022c map: fix server + SD tile rendering, add fetch diagnostics (#11)
Community PR by @Vybo (Dan Vybiral). Fixes Launcher SD-fallback tile caching (tilesFsLowSpace read the unmounted LittleFS partition as full and dropped every download), plus write-verification, read/write cap match, SD PNG-pack support, and on-screen fetch diagnostics.
2026-06-16 22:39:35 +02:00
Kaj SchittecatandClaude Opus 4.8 b85e0a4849 site: centre the BY MESHCOMOD byline on the full lockup (mark + word)
Position the byline relative to .brand (the whole mark+word lockup) instead of just the word, so it centres on the combined logo+title (x=640) rather than under the word alone (x=694). The mark stays aligned with the word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:58:43 +02:00
Kaj SchittecatandClaude Opus 4.8 e7d6c73d16 site: align mark with the WADAMESH word; byline floats beneath
The mark was vertically centring against the word+byline column, so it sat ~13px below the wordmark. Position the byline absolutely under the word so the mark centres on the word itself. ("Free — as it should be" was already centred on the mark+word lockup.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:49:49 +02:00
Kaj SchittecatandClaude Opus 4.8 48ef76fd88 site: Free byline, Telegram + bug buttons, BY MESHCOMOD byline
- BY MESHCOMOD byline under the WADAMESH wordmark.
- Plain "Free — as it should be" line under the open-source badge.
- Two fixed floating buttons: Telegram (top-left -> the meshcomod group) and a bug report (top-right -> the GitHub issues page).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:59:34 +02:00
Kaj SchittecatandClaude Opus 4.8 11bd8365d6 beta_5: official-app region scope + contacts/discovered/sound/map overhaul
Companion / core (ported from meshcomod):
- Region/default scope from the official MeshCore app now works: handle
  CMD_SET/GET_DEFAULT_FLOOD_SCOPE 63/64 (companion-v1.16.0.3, issue #31) —
  previously fell through with no reply ("no_event_received").
- US/Canada radio preset corrected to 910.525 MHz / 62.5 kHz / SF7.
- T-Deck +22 dBm TX fix (SX1262 DIO2-as-RF-switch, issue #6) retained.

Touch UI (since beta_4):
- Contacts: person/antenna + red-blocked icons, block/unblock in the popup,
  table with sort/filter + multi-select delete (favourites protected),
  compact time/distance columns, long-name marquee, instant fav-star toggle.
- Discovered: persists across reboots, holds 48, cogwheel settings
  (auto-delete oldest + auto-delete above N hops), "48!" full badge.
- Sound: +/- volume, master toggle, separate Messages/@mention switches.
- Chats: per-row last-message time, per-channel mute, @mention jump-to-message.
- Map: remembers zoom + pan; compact contact popup (no Block button).
- Auto-add on its own page; full-screen settings; refined tab bar; drawer polish.
- T-Deck keyboard backspace at the caret; quick-replies scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:34:04 +02:00
Kaj SchittecatandClaude Opus 4.8 537148802e fix(t-deck): enable SX1262 DIO2-as-RF-switch — restores full +22 dBm TX (issue #6)
The T-Deck env had SX126X_DIO2_AS_RF_SWITCH=false, so std_init() called
setDio2AsRfSwitch(false). On the T-Deck the SX1262's antenna TX/RX switch is
driven ONLY by DIO2 (no external PA, no TXEN/RXEN pins), so disabling it strands
the +22 dBm PA off the antenna — ~16 dB loss, ~4 mW radiated. That matches the
reported measurement and likely why some sends weren't propagating. Flip to true.

Cross-checked: Meshtastic's T-Deck variant enables it, as does the Heltec V4 here
and every other SX1262 variant in the MeshCore lib — the T-Deck (copied from
upstream MeshCore's identically-buggy lilygo_tdeck variant) was the lone outlier.
LORA_TX_POWER=22, CURRENT_LIMIT=140 and the 1.8 V TCXO were already correct.

Needs an on-air re-measure (no RF meter here); the reporter offered to test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:20:31 +02:00
Kaj Schittecat 7b7ea17031 release-notes: beta_4 2026-06-14 17:23:19 +02:00
Kaj SchittecatandClaude Opus 4.8 1fe92362be splash: add MESHCOMOD product line; power menu: Download-mode button
- Boot splash now shows MESHCOMOD (small, same size as TOUCH BETA) between the
  WADAMESH wordmark and the TOUCH BETA channel line, with its own fade-in.
- Control-center power menu gains a 'Download mode' button: flushes chat, then
  sets RTC_CNTL_FORCE_DOWNLOAD_BOOT + restarts so the device drops into the USB
  serial bootloader for reflashing — no need to hold BOOT + tap RST. Card resized
  to fit the 4th button on both boards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:00:16 +02:00
Kaj SchittecatandClaude Opus 4.8 9b1fd82eb6 map: keep the route-replay HUD clear of the right-edge button column
The HUD pill (hop name + replay button) was full-width top-center, so its replay
button landed under the map's settings gear / zoom column (right edge, x~sw-36).
Anchor it top-LEFT and cap its width to stop before that column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:48:23 +02:00
Kaj SchittecatandClaude Opus 4.8 07353263c0 home: cap profile-name width so it can't overlap the clock + slower marquee
The topbar reorder moved the clock left, and the name's short-name path used
LV_SIZE_CONTENT with no width cap — a name <=11 chars but with wide glyphs (W/M)
overran into the clock on the narrow V4. Always cap the label to a fixed window
(100 px T-Deck / 74 px V4); LONG_SCROLL_CIRCULAR stays static when the name fits
and only marquees when it overflows, so no char-count threshold is needed. Also
slow the marquee (anim_speed 14) for readability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:44:03 +02:00
Kaj SchittecatandClaude Opus 4.8 beb85bd391 setup: first-boot is Wi-Fi-only — Bluetooth off, opt-in afterwards
ble_en defaults ON, so a fresh device co-inits BLE at boot and the first-boot
setup ran with both radios up. On the fresh-flash path only (setupWizardMaybeOpen,
which returns early once setup is done) force BLE off + persist ble_en=0, before
the UI is interactive — so first-boot setup is Wi-Fi-only and the user opts BLE in
later via the control center. Already-set-up devices return early, so their BLE
state is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:26:23 +02:00
Kaj SchittecatandClaude Opus 4.8 2a93779d75 map: stop tile writes before the LittleFS cache fills (fix reboot)
Coredump from a reboot during a route replay: the replay re-centred the map onto
an un-cached area, the fetch task wrote new z16 tiles, and FS::mkdir faulted
inside lfs_alloc — a full/fragmented LittleFS faults rather than returning NOSPC.
The tile cache had no free-space guard or eviction, so heavy map use fills the
4.75 MB partition until the next mkdir reboots the device.

Add tilesFsLowSpace() (cached ~5s, fetch-task-only) and skip tile writes when the
cache has < 320 KB free. Reads keep working so cached areas still display; only
new tiles stop caching once full. The crashing mkdir is never reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:03:07 +02:00
Kaj SchittecatandClaude Opus 4.8 19c856127f map replay: instant popup close + replay button + hop name/id banner
- Tapping 'Replay route on map' now hides the Info popup immediately (was
  lingering over the map transition while the async delete caught up).
- Route-replay HUD: a top banner shows the current hop's repeater name +
  identifier (hash) as each node is revealed, and a replay button (↻) re-runs the
  reveal animation from the first hop. HUD lives on lv_layer_top while the route
  is shown and is torn down when leaving the map.
- RouteNode now carries the resolved name + hex id (routeFillId); 'You' for the
  ME endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:51:13 +02:00
Kaj SchittecatandClaude Opus 4.8 8562384f50 perf: stop the 4Hz refresh tick from re-laying-out unchanged labels
Audit of the per-tick UI refresh (runs 4x/s on the UI thread) found ~8 labels
re-written every tick unconditionally — lv_label_set_text always invalidates +
re-lays-out (it never compares), so byte-identical text was redrawn 4x/s on Home
and the control center. Add setLabelIfChanged() (compares against the label's
current text, so it's safe across home/tab rebuilds) and route the status/home/CC
labels through it: CC sysinfo + GPS, home state/IP, RAM/PSRAM, unread, signal
chip, traffic legend, settings footer.

Also coalesce the Wi-Fi tile-arrived re-render: the dirty flag is set per
delivered tile, so a download burst drove back-to-back full-grid tile read+decode
passes on the UI thread — cap to ~2.5x/s (flag stays set; next tick catches up).

Structural levers (DMA flush, double buffer, bigger draw buffer) were left alone:
the audit confirmed they were already tried and reverted for Wi-Fi/BLE RAM, so
they are NOT no-risk. CPU is already 240MHz, tick is esp_timer, touch is 125Hz
async — all already optimal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:34:32 +02:00
Kaj SchittecatandClaude Opus 4.8 a10abe1487 fix theme persistence; control-center back-to-dropdown + themed slider/chips
- Theme reverting on reboot: the boot wordmark reads a pref (UI rotation) BEFORE
  SdNvsPrefs::useFile() switches the backend to files, so the settings blob got
  cached from legacy NVS and file-saved values (accent, brightness, language…)
  were ignored every boot. Add touchPrefsReload(), called right after useFile(),
  to re-read from the file backend. (main.cpp now quote-includes TouchPrefsStore.h
  so it sees the new decl, not the monorepo lib's stale copy.)
- Control center: a settings page opened via a chip long-press now returns to the
  dropdown (not Home) when you tap the bar to go Back — tracked with
  s_settings_from_cc.
- Control center now follows the theme accent: brightness slider indicator/knob
  and the toggle chips use COLOR_ACCENT (chips: solid on / faint off) instead of
  a fixed green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:16:05 +02:00
Kaj SchittecatandClaude Opus 4.8 5f6398b845 home: tappable unread row w/ mail icon; control-center chips long-press to settings
- Home: split the unread count onto its own row with an envelope icon; tapping it
  jumps to the Chats inbox. Memory (RAM/PSRAM) drops to the line below.
- Control center: long-pressing a chip opens that feature's full settings page
  (Wi-Fi, Bluetooth, GPS->Radio & Mesh, Theme->Display, Keyboard, Lock, Sound).
  A short tap still toggles; lv_indev_wait_release swallows the release click so
  a long-press doesn't also flip the radio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:55:51 +02:00
Kaj SchittecatandClaude Opus 4.8 f25c9921af msg info: list every route hop, name-only, drop the confusing trailing hash
The route list appended each resolved hop's raw hash hex after its name. With
long repeater names the line wrapped, so a hop's trailing hash glued onto the
next hop's number (hop 2 hash '31' + hop 3 '3.' rendered as '313.'). Now show
just the name when the hash resolves to a known repeater; the hash is a fallback
only for unknown hops. Also drop the 6-hop '+N more' cap and grow the body
buffer so the whole path lists — the popup already scrolls. Same name-only
cleanup applied to the outgoing 'Repeats heard' list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:35:26 +02:00
Kaj SchittecatandClaude Opus 4.8 90f8975f45 topbar: close the gap left by the hidden % while charging
When charging the battery shows the bolt only (no % text), but the % label still
reserved its slot, leaving a %-wide gap between the signal bars and the bolt.
Slide the signal/Wi-Fi/Bluetooth/clock cluster right by the reclaimed width on
the charging transition so it stays snug against the bolt; slide back on unplug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:16:34 +02:00
Kaj SchittecatandClaude Opus 4.8 fe7a3ed66d topbar: lightning-only when charging + reorder battery/signal/wifi/bt/time
- Charging now shows just the lightning-bolt glyph (batteryGlyphForMv already
  returns it); the 'CHG' text is dropped.
- Right-to-left order is now battery%, signal bars, Wi-Fi, Bluetooth, time.
  Wi-Fi and Bluetooth are split into independent glyphs (were one combined
  label) so each sits in its own slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:09:24 +02:00
Kaj SchittecatandClaude Opus 4.8 d07945c2c1 topbar: clear chat name + cog when leaving the inbox by any route
Starting a route replay inside a channel jumps to the map via goToTab(), which
hides the chat overlay but didn't clear s_chat_title — so the channel name + its
settings cog lingered in the status bar afterwards (e.g. on Home). Clear the chat
title in onTabChanged's leaving-inbox path, so a tab tap, swipe, or replay jump
all drop it (was only cleared by the explicit chat-close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:17:48 +02:00
Kaj SchittecatandClaude Opus 4.8 c8fbc5571f home: drop the list icon from the graph's 'tap for details' cue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:49 +02:00
Kaj SchittecatandClaude Opus 4.8 79f47dd576 home: move graph tap-cue to top-right + acquire signal fast after boot
- The 'tap for details' cue moves to the graph's top-right corner (was bottom).
- Signal now acquires quickly: the discover probe is a FLOOD advert (repeaters
  re-broadcast it -> we measure the echo's SNR), fired ~4 s after boot and re-
  evaluated every 60 s but only actually sent when we have no recent signal
  (no RX, or older than 100 s). Zero-hop adverts produced nothing to measure
  since they aren't re-broadcast -> the icon sat on 'Sig --'. A chatty mesh that
  already feeds us traffic keeps it quiet; a quiet one refreshes via the echo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:03:32 +02:00
Kaj SchittecatandClaude Opus 4.8 7c145df7dd home: in-graph signal chip + a clear 'tap for details' cue
Overlay two chips inside the RX/TX graph box: the live signal ('Sig -3 dB',
top-left) and an accent-coloured 'list  tap for details' cue (bottom-right) so
it's obvious the graph is tappable. Both ride a faint chip to stay readable over
the lines and are non-clickable, so taps fall through to the chart's handler. The
legend above goes back to plain TX/RX traffic counts (signal now lives in the box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:57:18 +02:00
Kaj SchittecatandClaude Opus 4.8 75fe3b9e5b home: signal in the RX/TX graph legend + a tap-for-detail popup
The graph legend now leads with the live signal ('Sig -3dB  TX n  RX n  (tap)').
Tapping the graph (or the legend) opens a Signal & traffic popup: SNR, RSSI,
bars 0-4, how long ago we last heard the mesh, the flood/direct TX+RX totals
since boot, and the auto-discover interval. logRxRaw now also records RSSI for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:50:47 +02:00
Kaj SchittecatandClaude Opus 4.8 dee2037310 topbar: mesh signal-strength bars driven by a periodic discover probe
Add a 4-bar signal indicator to the status row, left of the connection icon.
MyMesh::logRxRaw now records the SNR (+ timestamp) of every received packet;
updateGlobalStatusBar maps that to 0-4 lit bars and dims them when nothing's been
heard for 5 min. To keep it fresh when idle, UITask::loop sends a light zero-hop
'discover' advert every 60 s (neighbours only, not flooded -> minimal airtime) so
nearby repeaters/nodes are prompted and their SNR shows up. Home-name cap shrinks
on the narrow V4 to leave room for the bars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:40:45 +02:00
Kaj SchittecatandClaude Opus 4.8 6a1770cac4 topbar: show the profile name on the home tab (marquee if long)
The home tab's left status-zone showed the brand 'WADAMESH'; show the user's
profile name instead. Capped to a ~11-char-wide window; longer names
marquee-scroll (LV_LABEL_LONG_SCROLL_CIRCULAR) so they never push into the status
icons. Reconfigured only on a name change (re-setting the scroll mode each tick
would restart the marquee) and reset to content-width when the left zone is
reused by another tab. Empty name falls back to 'WADAMESH'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:32:22 +02:00
Kaj SchittecatandClaude Opus 4.8 86c3c122f7 about: stop the per-second sysinfo refresh from stuttering the scroll
The About sheet re-set its ~800-char System-info label every second; re-wrapping
+ repainting that label mid-scroll collided with the scroll redraw and made it
jittery. Skip the refresh while any pointer is actively scrolling/throwing
(lv_indev_get_scroll_dir != NONE) and re-check at 120 ms so live uptime/heap
resumes the moment the user lets go. (General scroll smoothness is a separate
render-path matter — single-buffered, blocking SPI flush.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:13:10 +02:00
Kaj SchittecatandClaude Opus 4.8 072253355b prefs: FIX bootloop — keep SdNvsPrefs layout identical to the lib's stale header
My previous commit added an fs::FS* _fs member (and grew _path) to SdNvsPrefs.h.
But the monorepo lib ships a STALE copy of this header that UITask.cpp picks up
via an angle include, so that translation unit saw the OLD layout while the .cpp
saw the new one — an ODR/layout mismatch. The inserted member shifted _sd's
offset, so the stale-header destructor (buildUiTree, on a local SdNvsPrefs) freed
the _sd vector at the wrong offset -> free() of a garbage pointer -> assert ->
panic -> bootloop. Confirmed by coredump (heap_caps_free 'outside heap areas',
~SdNvsPrefs -> ~vector<Kv>, header at .pio/libdeps/.../MeshCore/.../SdNvsPrefs.h).

Fix: keep the DATA LAYOUT byte-identical to the stale header (no added/reordered
members) so mixed headers can't corrupt anything. The file backend now uses
file-static state (s_file_fs/dir/mode) + free helpers instead of an _fs member;
_path stays [40]; only the layout-neutral static useFile() is added (fs::FS
forward-declared, not included). All file-mode behaviour (read-through NVS
migration, file-only writes) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:05:52 +02:00
Kaj SchittecatandClaude Opus 4.8 3624540002 prefs: store touch settings in files (SD/SPIFFS), off NVS
Make the touch app's own prefs filesystem-backed instead of NVS, so it no longer
contributes to the tiny 20 KB NVS partition whose exhaustion (esp. under
Launcher) triggered boot loops. main.cpp calls SdNvsPrefs::useFile() once after
the storage decision: SD (/meshcomod) when a card is the active data store, else
SPIFFS (/prefs); V4 always SPIFFS (no SD). The radio stack + Launcher still use
NVS (not ours to remove).

Backward compatible / in-place update:
- Read-through migration: in file mode each getter falls back to the old NVS
  value (read-only), so existing settings + Wi-Fi creds still load and move to
  the file on their next save — nothing is lost, no re-entry needed. Gated by a
  one-time 'does NVS have our data' probe so fresh devices don't log-spam.
- New writes only ever touch the file; NVS is never written in file mode.
- On-disk .kv format unchanged, so existing /meshcomod/<ns>.kv files keep working.
- Legacy NVS-or-SD path kept intact for before useFile() runs (early boot read)
  and as a fallback, so the device stays byte-compatible if it's never reached.
- factory-reset (clear) also wipes the legacy NVS copy so it's permanent.

main.cpp includes SdNvsPrefs.h quoted to pick wadamesh's src/ copy over the
monorepo lib's stale one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:52:32 +02:00
Kaj SchittecatandClaude Opus 4.8 2b224f4d8c about: show NVS partition usage in System info
Add an 'NVS (settings store)' line to the About sub-tab's live System info via
nvs_get_stats() on the whole 'nvs' partition: used/total entries + percent, free
entries, namespace count. NVS is shared by Wi-Fi creds, the Launcher and our
settings, and filling it is what triggers the boot loop — so surfacing how full
it is makes that diagnosable at a glance (refreshes ~1 Hz like the rest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:16:37 +02:00
Kaj SchittecatandClaude Opus 4.8 dd919190e7 map: don't poison the tile dedup ring on a dropped/failed fetch
Two fixes for 'downloading forever, nothing happens':
- queueTileForFetch marked a tile 'seen' BEFORE the queue send, so a tile dropped
  by a full queue (or one whose fetch later failed) was never retried. Split the
  ring into check (tileFetchSeen) + mark (tileFetchMarkSeen) and only mark after a
  successful xQueueSend.
- Clear the dedup ring on map-tab open so a level that failed to fetch once gets
  another chance on re-open (the fetch task still skips on-disk tiles).

(The underlying cause was the tiles.wadamesh.com proxy 404ing .jpg; fixed
server-side. These make the client resilient to any future fetch failure.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:01:44 +02:00
Kaj SchittecatandClaude Opus 4.8 5efaefbf7f deploy: add tiles.wadamesh.com PNG->JPEG transcode service
The firmware fetches map tiles as .jpg, but the tiles.wadamesh.com proxy was a
plain PNG passthrough (no .jpg handler) -> every Wi-Fi tile fetch 404'd. Add the
transcode service (tile-transcode.py on 127.0.0.1:5005, fetches OSM PNG and
re-encodes JPEG with Pillow) + its systemd unit, and update the nginx vhost to
proxy /{z}/{x}/{y}.jpg and /elev to it while keeping the legacy .png passthrough.
Same design as the meshcomod tile proxy. HTTP-only by design (device can't TLS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:54:45 +02:00
Kaj SchittecatandClaude Opus 4.8 6b56ffb091 map: fix task-watchdog reboot from the zoomed-out tile prefetch
Coredump (loopTask stuck in tileCacheExists via queueZoomPackForCenter ->
renderMapTiles) showed the prefetch doing ~58 synchronous LittleFS stat calls
plus a usedBytes() traversal ON THE UI THREAD every render. Zooming out into an
un-cached area fires renders back-to-back as tiles stream in, so this ran in a
tight loop and starved core-0 IDLE past the 5 s task watchdog.

The prefetch now does zero filesystem work on the UI thread: it just enqueues
the pyramid tiles (queueTileForFetch is in-memory: dedup ring + queue send) and
the background fetch task already skips anything already on disk. Dropped the
usedBytes() free-space guard (a full partition fails the write gracefully).
Throttled to re-enqueue only when the center crosses a new z9 tile (~50 km).
Also yield once per dequeued item in the fetch task so a burst of on-disk skips
(now handled there) can't starve IDLE either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:22:05 +02:00
Kaj SchittecatandClaude Opus 4.8 2f95304e7b ui: Info popup — buttons above the route, repeaters tinted by bubble colour
- Move the Replay/Trace action row to just below the title (above the route
  info) instead of pinned at the bottom; the metadata scrolls beneath it.
- Colour each repeater's identifier in the Route / Repeats-heard lists with the
  same signature colour it carries as a chat bubble (nodeSigColorHex reuses
  usernameBubbleColors' FNV-1a hue), via LVGL recolor markers on the body label.
  Nameless hops get a stable tint from their hash. Long-press-copy stays plain —
  copyLabelLongPressCb now strips recolor tokens. Body buffer grown to fit them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:08:55 +02:00
Kaj SchittecatandClaude Opus 4.8 73fd690149 ui: make the message Info popup body scrollable
Wrap the metadata body in a vertical scroll container with the title and the
action row pinned, and cap the card to the screen height. A long route (many
named hops) now scrolls inside the card instead of overflowing or pushing the
buttons off-screen — also fixes the pre-existing top/bottom clipping of the
oversized card on the short 320x240 T-Deck. Thin visible scrollbar; long-press
to copy still works (drag scrolls, hold copies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:56:24 +02:00
Kaj SchittecatandClaude Opus 4.8 af27ebd0a4 map: replay a message's repeater route on the map (Info popup button)
The message Info popup gains a 'Replay' button that draws the repeaters the
message travelled through onto the map and animates the path one hop at a time.

- Received floods: a chain from the first known repeater through to ME.
- Our own sent floods: a star from ME to each repeater that echoed it back.

Repeater hops are resolved to their advertised GPS via a new read-only
MyMesh::uiHopPos(); only repeaters that have shared a position are plotted, and
the button only appears when >=2 nodes are mappable. Tapping it switches to the
map, fits center+zoom to the route, and reveals the amber path/nodes on a timer.
The overlay rides the pan layer (pans/zooms with the map), is freed by
freeMapMarkers each render, and is cleared on leaving the map tab.
onMapTabActivated skips its self-recenter while a replay fit is pending.

The Info popup's bottom row now holds Replay and/or Trace (side-by-side when
both apply). Pairs naturally with the wider offline tile cache, so a
country-scale route has tiles to draw on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:50:48 +02:00
Kaj SchittecatandClaude Opus 4.8 1994f6daba map: prefetch a zoomed-out pyramid over Wi-Fi (province -> country -> borders)
The Wi-Fi tile prefetch cached only two single tiles (z12 + z16), both close
range, so zooming out to see a province/country had nothing cached and dead-ended
offline. Cache a small grid around the center at the zoomed-out levels instead:
3x3 at z7 (~590 km, country + all borders), z8 (~295 km, whole country) and z9
(~145 km, a province), plus the existing z12/z16 detail tiles. Goes through the
same rate-capped background fetch path, runs after the visible tiles (so the
current view still downloads first), self-skips anything already cached, and a
512 KB free-space guard keeps it from thrashing a near-full partition. Bumped the
fetch queue (32->64) and dedup ring (16->48) so a full pyramid pass fits at once.
Now zoom-out is reachable down to country+border scale even offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:25:06 +02:00
Kaj SchittecatandClaude Opus 4.8 47e98f186b fix: don't truncate repeater names in message Info (issue #30)
The name buffer was nm[21]; contact names are up to 32 bytes and emoji cost 4
bytes each, so names like the screenshot's emoji repeater got cut mid-name. Size
the buffer to the full 32-byte name (nm[33]) in both the received-route and
sent-repeater renderers, and widen the body[] stop-margin to match the longer
lines. Label is LV_LABEL_LONG_WRAP so long names wrap rather than clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:11:08 +02:00
Kaj SchittecatandClaude Opus 4.8 9e07a5768a feat: name the repeaters behind 'Repeats heard' on sent messages (issue #30)
Sent messages have no inbound route, so #30's repeater-names had nothing to show
there. Now when we hear an echo of our own flood, capture the re-flooding
repeater (the echo's last path hop) into the echo ring (deduped, bounded to 3),
and list those repeaters by name under 'Repeats heard' in the message Info.
Zero added risk: fixed-size store (no growth), hard buffer guards in the
renderer, read-only contact lookup. Covers #30 in both directions now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:57:21 +02:00
Kaj SchittecatandClaude Opus 4.8 1134e49c79 feat: repeater names in the message Info route (issue #30)
The inbound-flood route in the message Info popup showed bare hop hashes
(24E0>6401>...). Resolve each hop to its repeater contact name when known, shown
as a numbered 'N. Name  HASH' list (falls back to the bare hash for unknown
repeaters). Implemented with zero added risk: a read-only, null-safe, bounded
name lookup (MyMesh::uiHopName) + a hard buffer guard and a hop cap in the
renderer, so it can never overflow body[] or grow past the card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:34:43 +02:00
Kaj SchittecatandClaude Opus 4.8 3104b6eaf6 site: add 512px PNG app icon (for app catalogs / Launcher)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:20:56 +02:00
Kaj SchittecatandClaude Opus 4.8 5410290c17 fix(wdt): stop the heavy-flash guard from arming the core-1 watchdog (reboot)
A user on beta_2 hit a task-watchdog reboot (task_wdt: IDLE1, loopTask on CPU1).
Decoding the backtrace showed the network task idle, so the UI thread stalled on
a local op. Root cause: in the Arduino S3 build only core 0's idle task is
WDT-subscribed (CHECK_IDLE_TASK_CPU1 is off). The heavy-flash guard called
disableCore1WDT() (which fails) then enableCore1WDT(), and the latter
*newly subscribes* IDLE1 via esp_task_wdt_add — arming a core-1 watchdog that
didn't exist before. The next multi-second SPIFFS-GC burst on the UI thread
(history save) then starves IDLE1 and reboots. So the guard CAUSED the very
reboot it existed to prevent. Fix: only touch core 0's WDT; leave core 1 at the
Arduino default (unwatched), so a slow write briefly stalls the UI instead of
rebooting. Built + flashed to the T-Deck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:34:08 +02:00
20 changed files with 5497 additions and 560 deletions

No files matched your search

+69 -13
View File
@@ -2,14 +2,29 @@
#
# The firmware fetches plain HTTP (on-device HTTPS isn't workable: mbedTLS wants
# ~30 KB heap for a handshake and only ~5 KB is free after Wi-Fi associates), so
# this vhost is HTTP-only by design. Cloudflare sits in front and caches the
# .png tiles at the edge; this origin keeps a second 14-day disk cache.
# this vhost is HTTP-only by design. Cloudflare sits in front and caches results
# at the edge; this origin keeps a second 14-day disk cache.
#
# Deploy: copy to /etc/nginx/sites-available/, symlink into sites-enabled/, then
# nginx -t && systemctl reload nginx
# Cloudflare: tiles.wadamesh.com proxied (orange cloud), SSL/TLS mode "Flexible"
# (CF<->origin HTTP), and a Cache Rule: Eligible for cache + Edge TTL ~14d for
# path "/*". Do NOT enable "Always Use HTTPS" on this hostname.
# The device also can only decode JPEG cheaply (SJPG/TJpgDec -> RGB565 in
# stripes); PNG decode (lodepng) needs a 256 KB ARGB8888 buffer per tile. OSM
# only serves PNG, so the device requests `.jpg` and a local transcode service
# (tile-transcode.py on 127.0.0.1:5005) fetches the OSM PNG and re-encodes it as
# JPEG. The legacy `.png` passthrough (used by the website / older callers) is
# kept alongside it.
#
# device --HTTP--> CF --HTTP--> nginx (this) --> 127.0.0.1:5005 --HTTPS--> OSM
# PNG -> JPEG (Pillow)
#
# Deploy:
# sudo systemctl enable --now wadamesh-tile-transcode # the :5005 service
# sudo cp deploy/nginx/tiles.wadamesh.com.conf /etc/nginx/sites-available/
# sudo ln -sf /etc/nginx/sites-available/tiles.wadamesh.com.conf /etc/nginx/sites-enabled/
# sudo mkdir -p /var/cache/nginx/wadamesh-tiles && sudo chown www-data: /var/cache/nginx/wadamesh-tiles
# sudo nginx -t && sudo systemctl reload nginx
# Cloudflare: tiles.wadamesh.com proxied (orange cloud), SSL/TLS "Flexible",
# Cache Rule: Eligible for cache + Edge TTL ~14d for "/*". Do NOT enable
# "Always Use HTTPS" on this hostname. Purge the CF cache after first deploy
# so any negatively-cached `.jpg` 404s clear.
proxy_cache_path /var/cache/nginx/wadamesh-tiles levels=1:2
keys_zone=wadamesh_tiles:10m max_size=4g inactive=14d use_temp_path=off;
@@ -19,20 +34,55 @@ server {
listen [::]:80;
server_name tiles.wadamesh.com;
# Re-resolve the upstream at request time. A literal hostname in proxy_pass
# fails with "no resolver defined" on a fresh box; a variable + resolver works.
# Re-resolve the OSM upstream (for the .png passthrough) at request time. A
# literal hostname in proxy_pass fails with "no resolver defined" on a fresh
# box; a variable + resolver works.
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# The location regex is DOUBLE-QUOTED on purpose: nginx's lexer otherwise
# treats { and } (quantifiers) as block delimiters and refuses to start.
# Primary path the firmware uses: /{z}/{x}/{y}.jpg -> local transcode service
# (fetches the OSM PNG, re-encodes as JPEG). The regex is DOUBLE-QUOTED on
# purpose: nginx's lexer otherwise treats { and } (quantifiers) as block
# delimiters and refuses to start.
location ~ "^/(?<z>[0-9]{1,2})/(?<x>[0-9]{1,7})/(?<y>[0-9]{1,7})\.jpg$" {
proxy_pass http://127.0.0.1:5005/$z/$x/$y.jpg;
proxy_set_header Host "tiles.wadamesh.com";
proxy_cache wadamesh_tiles;
proxy_cache_key "jpg:$z/$x/$y";
proxy_cache_valid 200 14d;
proxy_cache_valid 404 1d;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, max-age=2592000" always;
proxy_buffering on;
proxy_read_timeout 20s;
proxy_connect_timeout 5s;
}
# Elevation profile for the line-of-sight analyzer (same local service).
location = /elev {
proxy_pass http://127.0.0.1:5005;
proxy_set_header Host "tiles.wadamesh.com";
proxy_cache wadamesh_tiles;
proxy_cache_key "elev:$args";
proxy_cache_valid 200 30d;
proxy_cache_valid 400 404 1h;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
proxy_buffering on;
proxy_read_timeout 20s;
proxy_connect_timeout 5s;
}
# Legacy direct PNG passthrough -> OSM (kept for the website / older callers).
location ~ "^/(\d{1,2})/(\d{1,7})/(\d{1,7})\.png$" {
set $osm_upstream "tile.openstreetmap.org";
proxy_pass https://$osm_upstream/$1/$2/$3.png;
proxy_ssl_server_name on;
proxy_set_header Host tile.openstreetmap.org;
# OSM tile usage policy requires a valid, identifying User-Agent.
proxy_set_header User-Agent "wadamesh-tile-proxy/1.0 (+https://wadamesh.com)";
proxy_set_header Referer "https://wadamesh.com/";
@@ -45,5 +95,11 @@ server {
expires 14d;
}
# Tiny landing so a human hitting the bare hostname isn't met with 404.
location = / {
default_type text/plain;
return 200 "wadamesh tile proxy\n\nUsage: GET /{z}/{x}/{y}.jpg (HTTP only)\nUpstream: tile.openstreetmap.org (PNG transcoded to JPEG)\n";
}
location / { return 404; }
}
+24 -3
View File
@@ -37,14 +37,25 @@
.wrap{max-width:1040px; margin:0 auto; padding:0 22px}
header{padding:74px 0 30px; text-align:center}
.kicker{display:inline-block; margin-bottom:24px; padding:6px 13px; border:1px solid var(--line);
.kicker{display:inline-block; margin-bottom:10px; padding:6px 13px; border:1px solid var(--line);
border-radius:100px; color:var(--mut); font-size:.78rem; letter-spacing:.05em; text-transform:uppercase;
font-family:'JetBrains Mono',monospace; background:#fff; transition:border-color .15s, color .15s}
.kicker:hover{border-color:var(--teal); color:var(--ink)}
.brand{display:flex; align-items:center; justify-content:center; gap:clamp(12px,2.6vw,20px); flex-wrap:wrap}
.freeline{margin:0 auto 24px; color:var(--mut); font-size:.92rem}
.fab{position:fixed; top:16px; z-index:45; width:44px; height:44px; border-radius:50%; display:grid; place-items:center;
background:#fff; border:1px solid var(--line); color:#46525c; box-shadow:0 10px 26px -14px rgba(21,24,30,.45);
transition:border-color .15s, color .15s, transform .15s, box-shadow .15s}
.fab:hover{border-color:var(--teal); color:var(--teal-ink); transform:translateY(-2px); box-shadow:0 14px 30px -14px rgba(21,182,166,.5)}
.fab svg{width:21px; height:21px}
.fab-tg{left:16px}
.fab-bug{right:16px}
.brand{display:flex; align-items:center; justify-content:center; gap:clamp(12px,2.6vw,20px); flex-wrap:wrap; position:relative}
.brand .mark{width:clamp(60px,13vw,88px); height:auto; flex:none}
.brand .word{font-weight:700; font-size:clamp(2rem,7.5vw,3.35rem); letter-spacing:3px; color:var(--ink); line-height:1}
.brand .word .t{color:var(--teal)}
/* byline floats beneath the whole lockup, centred on mark+word combined (mark still centres on the word) */
.byline{position:absolute; top:100%; left:0; width:100%; margin-top:7px; text-align:center;
font-family:'JetBrains Mono',monospace; font-size:clamp(.62rem,1.7vw,.8rem); letter-spacing:.2em; text-transform:uppercase; color:var(--mut); font-weight:600}
/* animated mesh mark — repeaters broadcast pings; signal packets ride the trails */
.brand .mark{overflow:visible}
.wm-flow{display:none}
@@ -84,7 +95,7 @@
.h2{font-size:clamp(1.55rem,4vw,2.15rem); font-weight:750; letter-spacing:-.5px; text-align:center}
.p{color:var(--mut); text-align:center; max-width:560px; margin:10px auto 0}
/* lift hero + section text off the animated net so it stays readable */
.brand .word, .lede, .sub, .cue, .h2, .p{
.brand .word, .byline, .freeline, .lede, .sub, .cue, .h2, .p{
text-shadow: 0 1px 3px #fff, 0 0 14px #fff, 0 0 14px #fff, 0 0 26px #fff, 0 0 26px #fff, 0 0 42px #fff;
}
@@ -164,9 +175,18 @@
<body>
<div id="vanta-bg"></div>
<!-- floating quick links -->
<a class="fab fab-tg" href="https://t.me/+fHuhG67-RFJlNmFk" target="_blank" rel="noopener" aria-label="Join us on Telegram" title="Join us on Telegram">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M9.04 15.47 8.7 19.9c.5 0 .72-.22.98-.47l2.36-2.22 4.89 3.53c.9.5 1.53.24 1.77-.83l3.2-14.5c.3-1.33-.5-1.85-1.36-1.53L1.9 9.46c-1.3.5-1.28 1.22-.22 1.55l5.05 1.57L18.4 5.9c.55-.36 1.05-.16.64.2L9.04 15.47z"/></svg>
</a>
<a class="fab fab-bug" href="https://github.com/ALLFATHER-BV/wadamesh/issues" target="_blank" rel="noopener" aria-label="Report a bug on GitHub" title="Report a bug">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>
</a>
<header>
<div class="wrap">
<a class="kicker" href="https://github.com/ALLFATHER-BV/wadamesh" target="_blank" rel="noopener">open source · GPL-3.0</a>
<p class="freeline">Free — as it should be</p>
<!-- wadamesh brand: mark + wordmark, flex-centered -->
<div class="brand">
<svg class="mark" viewBox="0 0 180 120" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="wadamesh">
@@ -198,6 +218,7 @@
<g class="wm-nodes" fill="#15B6A6"><circle cx="10" cy="60" r="7"/><circle cx="90" cy="60" r="7"/><circle cx="170" cy="60" r="7"/><circle cx="50" cy="12" r="7"/><circle cx="130" cy="12" r="7"/><circle cx="50" cy="108" r="7"/><circle cx="130" cy="108" r="7"/></g>
</svg>
<span class="word mono">WADA<span class="t">MESH</span></span>
<span class="byline">by meshcomod</span>
</div>
<p class="lede">A real touchscreen UI for your mesh radio.</p>
<p class="sub">MeshCore MESHCOMOD firmware for the <b>LilyGo&nbsp;T-Deck</b> and <b>Heltec&nbsp;V4</b> — map, chat, channels and contacts on the device itself, <b>and</b> a companion radio for the <b>MeshCore app</b> at the same time.</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
wadamesh tile transcode service.
Why this exists
---------------
The touch firmware fetches map tiles over plain HTTP (on-device HTTPS is not
workable — mbedTLS needs ~30 KB of internal heap for a handshake and only
~5 KB survives Wi-Fi association). It also can only decode JPEG cheaply: the
device's SJPG/TJpgDec path decodes straight to RGB565 in small stripes, while
PNG decode (lodepng) needs a full 256 KB ARGB8888 buffer per tile and bogged
the UI down / rendered as noise.
OpenStreetMap only serves PNG. So this service sits between nginx and OSM:
it fetches the PNG from OSM (HTTPS, with the policy-required identifying
User-Agent) and re-encodes it as JPEG, which is what the device asks for.
nginx (see tiles.wadamesh.com.conf) reverse-proxies tiles.wadamesh.com
to this service on 127.0.0.1:5005 and caches the JPEG result on disk, so OSM
is only hit on a cache miss and transcoding only happens once per tile.
Run
---
pip install flask pillow requests
python3 tile-transcode.py # listens on 127.0.0.1:5005
Production: use the systemd unit (wadamesh-tile-transcode.service).
"""
import io
import sys
import time
try:
import requests
from flask import Flask, Response, abort, request
from PIL import Image
except ImportError:
sys.exit("ERROR: pip install flask pillow requests")
OSM_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# OSM tile policy: identify yourself with a contactable UA. This is what OSM
# sees — the device never talks to OSM directly.
OSM_UA = "wadamesh-tile-proxy/1.0 (+https://wadamesh.com)"
JPEG_QUALITY = 80 # slippy tiles compress well; 80 ≈ visually lossless
MAX_TILE_BYTES = 256 * 1024
REQUEST_TIMEOUT = 12 # seconds for the OSM fetch
# Elevation backend for the line-of-sight analyzer. opentopodata's public
# SRTM 30 m dataset: max 100 locations/request, ~1 req/sec, 1000/day. Fine
# for a personal device. Self-host opentopodata if you outgrow the cap.
ELEV_URL = "https://api.opentopodata.org/v1/srtm30m"
ELEV_MAX_POINTS = 100
app = Flask(__name__)
_session = requests.Session()
_session.headers.update({"User-Agent": OSM_UA, "Accept": "image/png,image/*"})
@app.get("/<int:z>/<int:x>/<int:y>.jpg")
def tile(z: int, x: int, y: int):
# Range-check before touching OSM so we can't be turned into an open proxy.
if not (0 <= z <= 19):
abort(404)
n = 1 << z
if not (0 <= x < n and 0 <= y < n):
abort(404)
try:
r = _session.get(OSM_URL.format(z=z, x=x, y=y), timeout=REQUEST_TIMEOUT,
stream=True)
except requests.RequestException:
abort(502)
if r.status_code != 200:
# Pass OSM's status through (404 = empty/sea tile — nginx caches it).
abort(r.status_code if r.status_code in (404, 429) else 502)
raw = r.raw.read(MAX_TILE_BYTES + 1, decode_content=True)
if len(raw) > MAX_TILE_BYTES:
abort(502)
try:
img = Image.open(io.BytesIO(raw)).convert("RGB")
out = io.BytesIO()
img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True)
except Exception:
abort(502)
return Response(out.getvalue(), mimetype="image/jpeg",
headers={"Cache-Control": "public, max-age=2592000"})
@app.get("/elev")
def elev():
"""Elevation profile for the line-of-sight analyzer.
Request: GET /elev?locations=lat,lon|lat,lon|... (up to 100 points)
Response: compact CSV of integer metres, one per point, in order:
"12,15,40,38,..." ('null' for any point with no data).
The device computes the great-circle sample points itself; this just
proxies them to the SRTM backend and strips the JSON down to a tiny
body the firmware can parse without a JSON library.
"""
locations = request.args.get("locations", "").strip()
if not locations:
abort(400)
pts = locations.split("|")
if len(pts) < 2 or len(pts) > ELEV_MAX_POINTS:
abort(400)
# Validate each "lat,lon" before forwarding so we can't be used to hit
# arbitrary query strings upstream.
for p in pts:
try:
lat_s, lon_s = p.split(",")
lat, lon = float(lat_s), float(lon_s)
except ValueError:
abort(400)
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
abort(400)
# opentopodata rate-limits to ~1 req/sec and occasionally 5xx's. Retry a
# couple of times with a >1 s gap so a transient limit/blip doesn't bubble
# up to the device as a failure.
# 2 attempts max so the proxy's worst case (~2 × (timeout + 1.2 s) ≈ 26 s)
# stays under the device's read timeout (30 s) — otherwise the device
# gives up mid-retry and the work is wasted.
r = None
for attempt in range(2):
if attempt:
time.sleep(1.2)
try:
r = _session.get(ELEV_URL, params={"locations": locations},
timeout=REQUEST_TIMEOUT)
except requests.RequestException:
r = None
continue
if r.status_code == 200:
break
if r.status_code in (429, 500, 502, 503, 504):
continue # transient — retry
break # other 4xx — don't bother retrying
if r is None:
abort(502)
if r.status_code != 200:
abort(502 if r.status_code >= 500 or r.status_code == 429 else 400)
try:
data = r.json()
except ValueError:
abort(502)
if data.get("status") != "OK":
abort(502)
out = []
for res in data.get("results", []):
e = res.get("elevation")
out.append(str(int(round(e))) if e is not None else "null")
if not out:
abort(502)
return Response(",".join(out), mimetype="text/plain",
headers={"Cache-Control": "public, max-age=2592000"})
@app.get("/healthz")
def healthz():
return "ok\n", 200
if __name__ == "__main__":
# threaded=True so concurrent device fetches don't serialize behind one
# another. Bind to localhost only — nginx is the public face.
app.run(host="127.0.0.1", port=5005, threaded=True)
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=wadamesh tile transcode service (OSM PNG -> JPEG for touch firmware)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/wadamesh-tiles
# Runs from a venv so it doesn't depend on the system Python's package set.
# Falls back to /usr/bin/python3 if the venv isn't present (see deploy notes).
ExecStart=/opt/wadamesh-tiles/venv/bin/python /opt/wadamesh-tiles/tile-transcode.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+8 -3
View File
@@ -166,7 +166,7 @@ lib_deps =
; MeshCore core = the meshcomod monorepo built as a lib, pinned by tag.
; Touch-app files (TouchPrefsStore, WifiRuntimeStore, transports, ...) are
; vendored in this app's src/ and dropped from the lib via -DMC_VENDORED_TOUCH_APP.
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.2
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.3
SPI
Wire
jgromes/RadioLib @ ^7.6.0
@@ -246,7 +246,12 @@ build_flags =
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH=false
; The T-Deck's SX1262 antenna (TX/RX) switch is driven by DIO2 — this MUST be
; true. With it false, setDio2AsRfSwitch(false) leaves the switch uncontrolled,
; so on TX the +22 dBm PA is never routed to the antenna (~16 dB loss -> ~4 mW).
; That was issue #6. (Meshtastic's T-Deck + the Heltec V4 here + every other
; MeshCore SX1262 variant use true; the T-Deck was the lone outlier.)
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
@@ -324,7 +329,7 @@ build_src_filter =
+<../variants/lilygo_tdeck/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.2
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.3
SPI
Wire
jgromes/RadioLib @ ^7.6.0
+14
View File
@@ -0,0 +1,14 @@
Replay a message's route on the map — it animates hop by hop, showing each repeater's name and ID on screen, with a button to replay it
"Repeats heard" and the route trace now name the repeaters, and long names are no longer cut off
Message Info popup scrolls, lists every hop, and colours each repeater to match its chat bubble
New mesh signal-strength bars in the top bar, plus a live signal readout in the home graph (tap for details)
Home top bar shows your profile name (scrolls if long); status icons reordered to battery, signal, Wi-Fi, Bluetooth, time
Charging shows just a lightning bolt; the home "unread" line is now tappable and opens Chats
Control center: long-press a tile to jump to its settings page; the slider and tiles follow your theme colour
Theme colour no longer reverts to the default after a reboot
Touch settings now live in files (SD/SPIFFS) instead of NVS — avoids the shared-storage "full" issue
First-boot setup is Wi-Fi only; turn Bluetooth on afterwards from the control center
Map prefetches a zoomed-out view (province to country to borders) for faster panning
Fixed several reboots: map zoom-out, a full tile cache, and flash-heavy moments
Smoother scrolling across the UI; the boot splash now shows MESHCOMOD
Power menu adds a Download-mode button to reflash over USB without holding BOOT
+41
View File
@@ -0,0 +1,41 @@
# beta_5 — what's new (one user-facing note per non-blank, non-# line)
# --- Contacts ---
Contacts now show a person icon for people and an antenna for repeaters, instead of a generic envelope and lightning bolt
Block or unblock anyone straight from their popup — blocked contacts get a red person icon and their messages are hidden
The contacts list is a proper table now: dedicated "last heard" and distance columns, a Sort & Filter button, and multi-select delete with an are-you-sure prompt and a progress bar (favourites are protected — unfavourite them first)
Time and distance are smaller and tucked to the right so names get more room; a name too long for two lines scrolls instead of spilling onto a third
Un-favouriting a contact removes its star right away
# --- Discovered nodes ---
The Discovered list now survives reboots (it used to empty every boot) and holds up to 48 nodes
New Discovered settings (cogwheel): auto-delete the oldest node when the list is full, and auto-delete anything heard via more than a set number of hops — keeps the list fresh and nearby
The Discovered count badge shows "48!" when the list is full
Adding a node from Discovered now appears in your Contacts immediately
A purge-all button sits right next to Close in the Discovered list
# --- Region / radio ---
Setting the region / default scope from the official MeshCore app now works — it used to time out ("no_event_received"); public-channel floods are now region-tagged (issue #31)
The US/Canada radio preset is corrected to the proper 910.525 MHz, 62.5 kHz, SF7 (was 915 MHz / 250 kHz / SF11)
T-Deck transmit power is restored to the full +22 dBm — the SX1262 antenna switch was mis-driven, leaking most of the power (~4 mW) (issue #6)
Auto-add settings moved to their own dedicated page (out of Radio), and are reachable from the Contacts menu too
# --- Chats & sound ---
Each chat and channel row now shows the time of its most recent message on the right
Mute messages and/or @-mentions per channel, from that channel's settings
The @-mentions screen has taller rows (fits two lines) and tapping a mention jumps to that exact message
Volume is now plus/minus buttons instead of a hard-to-drag slider; a master Sound switch silences everything, with independent Messages and @-mention switches
@-mentions can play their own distinct chime, with separate on/off and volume
# --- Map ---
The map remembers your zoom and pan when you leave and come back, instead of snapping back to your location every time
Tapping a contact on the map opens a compact popup (no Block button there, to keep it small)
# --- Keyboard & input ---
T-Deck keyboard: backspace deletes at the cursor instead of always chopping the last character, and editing respects where you place the caret
Quick replies page now scrolls so you can reach and edit every slot
# --- UI polish ---
Refined bottom tab bar — flat icons that take your theme colour with a soft glow under the active tab — plus an unread-count badge on the Chats icon
App drawer: colourful app icons, the command tile relabelled "Cmdr", the Apps button in a contrasting accent, and three-per-row on the Heltec V4
Full-screen settings pages with a clear X to close, a subtle new-message chip, and a full-page Signal view with a live readout and manual refresh
+34
View File
@@ -0,0 +1,34 @@
# beta_6 — what's new (one user-facing note per non-blank, non-# line)
# --- New: RF Monitor app ---
New Monitor app on the app drawer: a live RF sniffer for your node — see exactly what it is pulling out of the air in real time
A "Recently heard" feed lists recent received frames by type (text, advert, ack, route-trace…) with RSSI, SNR and hop count, colour-coded by type
A link-margin readout grades how much headroom your last packet had (green / amber / red), alongside a live packet rate and a noise-floor + peak-hold RSSI scope with an on-screen dBm scale
The Monitor lays out cleanly in both portrait and landscape, so it is readable on the Heltec V4 and the T-Deck
# --- Chat & contacts ---
Direct-message chats now show a person icon and channels a group icon, instead of the old loop / envelope
You can block a non-contact sender by name — handy for silencing an automated room or repeater bot you could not block before
Opening a chat or channel no longer jumps you to the newest message; it keeps your scroll position
The contacts Sort & Filter popup now closes after you pick a sort option
The chat/channel long-press popup is fixed: the close X no longer overlaps the buttons while you scroll it
# --- Time & timezone ---
New time-zone picker with named zones (no longer CET-only) — set yours in Settings → System
Fixed the clock getting stuck at 1902 and channels showing 1969–1970 dates: GPS no longer clobbers the clock with an invalid date, and the time now survives a reboot
# --- Launcher & Wi-Fi ---
Map tiles now download when running under Launcher (the tile cache was wrongly being reported as full)
GPS now acquires a fix when running under Launcher
The setup-wizard Wi-Fi scan no longer comes up empty ("no networks found") — Wi-Fi modem sleep is deferred until after you are connected
# --- Bluetooth ---
Bluetooth now advertises under your node / profile name instead of "NimBLE"
The Bluetooth PIN no longer resets on its own — it is saved and reused across reboots
# --- Power & performance ---
Turning the screen off now downclocks the CPU and enables Wi-Fi modem sleep to save power
On the map, your position dot is drawn before the tiles so it no longer lags behind when you pan
# --- Community ---
Merged map-server and SD-tile rendering fixes contributed by Vybo (PR #11)
+64 -4
View File
@@ -1356,6 +1356,25 @@ static inline bool isMsgFloodType(uint8_t t) {
}
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
// Live signal for the top-bar icon: SNR of whatever we just heard, with a
// timestamp so the UI can dim the icon when the mesh has gone quiet.
_ui_sig_snr_q4 = (int8_t)(snr * 4.0f);
_ui_sig_rssi = (int8_t)rssi;
_ui_sig_ms = millis();
// Recent-RX ring for the Monitor app (see UiRxRec): parse the minimal header
// (type / route / hops) and push newest-first. Header byte layout is
// [version:2][payload_type:4][route_type:2]
// with 4 transport-code bytes following iff route is a TRANSPORT_* one; the
// next byte's low 6 bits are the path length (hop count).
if (len > 0) {
const uint8_t rt = raw[0] & 0x03;
const bool xp = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT);
const int ps = 1 + (xp ? 4 : 0);
const uint8_t hops = (ps < len) ? (uint8_t)(raw[ps] & 0x3F) : 0;
uiRxLogPush(_ui_sig_ms, (int8_t)rssi, _ui_sig_snr_q4,
(uint8_t)((raw[0] >> 2) & 0x0F), rt, hops,
(uint8_t)(len > 255 ? 255 : len));
}
#if defined(DISPLAY_CLASS)
// Diagnostic: log EVERY received frame so we can prove what reaches the
// radio. Header byte layout is
@@ -1429,9 +1448,15 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
rt == ROUTE_TYPE_TRANSPORT_DIRECT); // header iff route is a TRANSPORT_* one
const int ps = 1 + (has_xp ? 4 : 0);
if (ps < len) {
const uint8_t pb = raw[ps];
const int poff = ps + 1 + (pb & 0x3F) * (((pb >> 6) & 0x03) + 1);
if (poff < len) uiCountEcho(fnv1a32(raw + poff, len - poff));
const uint8_t pb = raw[ps];
const uint8_t cnt_p = pb & 0x3F;
const uint8_t hsz_p = (uint8_t)(((pb >> 6) & 0x03) + 1);
const int poff = ps + 1 + cnt_p * hsz_p;
if (poff < len) {
// last path hop = the repeater whose re-flood our radio just heard
const uint8_t* lasthop = (cnt_p >= 1) ? (raw + poff - hsz_p) : nullptr;
uiCountEcho(fnv1a32(raw + poff, len - poff), lasthop, lasthop ? hsz_p : 0);
}
}
}
#endif
@@ -2467,7 +2492,12 @@ void MyMesh::begin(bool has_display) {
#ifdef DISPLAY_CLASS
if (has_display && BLE_PIN_CODE == 123456) {
StdRNG rng;
_active_ble_pin = rng.nextInt(100000, 999999); // random pin each session
_active_ble_pin = rng.nextInt(100000, 999999); // random pin, generated ONCE
// Persist it so it stays the SAME across reboots — it used to be re-rolled
// every boot (ble_pin stayed 0), so a paired phone's saved PIN stopped
// matching and pairing broke (user report: "BT pin resets sometimes").
_prefs.ble_pin = _active_ble_pin;
savePrefs();
} else {
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin
}
@@ -3694,6 +3724,36 @@ void MyMesh::handleCmdFrame(size_t len) {
} else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE && len >= 2 && cmd_frame[1] == 1) { // ver 12+ (1.16 send_unscoped; fork keeps its cmd name/number 54)
send_unscoped = true;
writeOKFrame();
} else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) {
// MeshCore 1.16: persistent default region scope (companion-v1.16.0.3 / issue #31).
// Payload [63, name(31), key(16)]; an empty payload ([63]) clears it. Without this
// handler the official MeshCore app's "default scope" setting got no reply
// ("no_event_received"). The flood path already applies _prefs.default_scope_key.
if (len >= 1 + 31 + 16) {
int n = strlen((char *)&cmd_frame[1]);
if (n > 0 && n < 31) {
strcpy(_prefs.default_scope_name, (char *)&cmd_frame[1]);
memcpy(_prefs.default_scope_key, &cmd_frame[1 + 31], 16);
savePrefs();
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else {
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // null = unscoped
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
savePrefs();
writeOKFrame();
}
} else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) {
out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE;
if (strlen(_prefs.default_scope_name) > 0) {
memcpy(&out_frame[1], _prefs.default_scope_name, 31);
memcpy(&out_frame[1 + 31], _prefs.default_scope_key, 16);
_serial->writeFrame(out_frame, 1 + 31 + 16);
} else {
_serial->writeFrame(out_frame, 1); // no name/key = null
}
} else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) {
auto resp = createControlData(&cmd_frame[1], len - 1);
if (resp) {
+117 -5
View File
@@ -382,6 +382,10 @@ public:
static const int UI_ECHO_SLOTS = 12;
uint32_t _echo_fp[UI_ECHO_SLOTS] = {0};
uint8_t _echo_rep[UI_ECHO_SLOTS] = {0};
static const int ECHO_MAX_HOPS = 3; // repeaters remembered per echoed send
uint8_t _echo_hop[UI_ECHO_SLOTS][ECHO_MAX_HOPS][4] = {}; // last-hop hash of each distinct echo
uint8_t _echo_hop_sz[UI_ECHO_SLOTS] = {0}; // hash size used for those hops
uint8_t _echo_hop_n[UI_ECHO_SLOTS] = {0}; // count, 0..ECHO_MAX_HOPS
uint8_t _echo_idx = 0;
uint32_t _last_sent_fp = 0;
uint8_t _last_rx_path[32] = {0};
@@ -390,6 +394,51 @@ public:
bool _last_rx_has_scope = false; // false if the packet carried no transport codes
volatile bool _echo_dirty = false; // a repeat was counted -> UI should refresh
// ---- Live signal strength (top-bar icon) ----
// Updated on EVERY received packet in logRxRaw(): the SNR (×4) + millis() at RX.
// The UI maps it to bars and dims them when nothing's been heard for a while.
int8_t _ui_sig_snr_q4 = -128; // SNR_dB * 4 of the last RX (-128 = nothing yet)
int8_t _ui_sig_rssi = 0; // RSSI dBm of the last RX
uint32_t _ui_sig_ms = 0; // millis() at that RX (0 = nothing heard yet)
int8_t uiSignalSnrQ4() const { return _ui_sig_snr_q4; }
int8_t uiSignalRssi() const { return _ui_sig_rssi; }
uint32_t uiSignalMs() const { return _ui_sig_ms; }
// ---- Recent-RX ring (RF Monitor app) ----
// One record per received frame, captured in logRxRaw(): payload type / route
// / hop count / length + signal, so the Monitor page can show a live "what am
// I hearing" feed without a core hook. Newest-first via uiRxLogGet (i=0 = most
// recent). Board-independent — populated on every board, not just touch.
struct UiRxRec {
uint32_t ms; // millis() at RX
int8_t rssi; // dBm
int8_t snr_q4; // SNR_dB * 4
uint8_t ptype; // payload type (raw[0]>>2)&0x0F
uint8_t route; // route type raw[0]&0x03
uint8_t hops; // path length carried (0 = heard direct from origin)
uint8_t len; // frame length (clamped to 255)
};
static const int UI_RXLOG_MAX = 16;
UiRxRec _ui_rxlog[UI_RXLOG_MAX];
uint8_t _ui_rxlog_head = 0; // next write slot
uint8_t _ui_rxlog_cnt = 0; // valid entries (<= UI_RXLOG_MAX)
uint8_t uiRxLogCount() const { return _ui_rxlog_cnt; }
bool uiRxLogGet(uint8_t i, UiRxRec& out) const {
if (i >= _ui_rxlog_cnt) return false;
uint8_t idx = (uint8_t)((_ui_rxlog_head + UI_RXLOG_MAX - 1 - i) % UI_RXLOG_MAX);
out = _ui_rxlog[idx];
return true;
}
// Record a reception into the ring (called from logRxRaw).
void uiRxLogPush(uint32_t ms, int8_t rssi, int8_t snr_q4,
uint8_t ptype, uint8_t route, uint8_t hops, uint8_t len) {
UiRxRec& r = _ui_rxlog[_ui_rxlog_head];
r.ms = ms; r.rssi = rssi; r.snr_q4 = snr_q4;
r.ptype = ptype; r.route = route; r.hops = hops; r.len = len;
_ui_rxlog_head = (uint8_t)((_ui_rxlog_head + 1) % UI_RXLOG_MAX);
if (_ui_rxlog_cnt < UI_RXLOG_MAX) _ui_rxlog_cnt++;
}
/** Fingerprint of the most-recently originated flood TXT payload (0 if none). */
uint32_t uiLastSentFp() const { return _last_sent_fp; }
@@ -401,6 +450,25 @@ public:
return 0;
}
/** How many distinct repeaters we captured echoing this sent fingerprint. */
uint8_t uiRepeatHopCount(uint32_t fp) const {
if (fp == 0) return 0;
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) return _echo_hop_n[i];
return 0;
}
/** Copy the idx-th captured repeater hash for `fp` into out[] (needs >= 4 bytes);
* returns the hash size (0 if none). Bounded, read-only. */
uint8_t uiRepeatHop(uint32_t fp, uint8_t idx, uint8_t* out, uint8_t out_sz) const {
if (fp == 0 || !out) return 0;
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) {
if (idx >= _echo_hop_n[i]) return 0;
uint8_t sz = _echo_hop_sz[i]; if (sz > out_sz) sz = out_sz; if (sz > 4) sz = 4;
memcpy(out, _echo_hop[i][idx], sz);
return sz;
}
return 0;
}
/** Path (repeater hashes) of the most-recently received flood; copies up to
* `max` bytes into buf, returns the count. Read synchronously from the
* newMsg* handler that follows reception. */
@@ -430,20 +498,64 @@ public:
void uiTrackSentFp(uint32_t fp) {
if (fp == 0) return;
_last_sent_fp = fp;
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) { _echo_rep[i] = 0; return; }
_echo_fp[_echo_idx] = fp; _echo_rep[_echo_idx] = 0;
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) { _echo_rep[i] = 0; _echo_hop_n[i] = 0; return; }
_echo_fp[_echo_idx] = fp; _echo_rep[_echo_idx] = 0; _echo_hop_n[_echo_idx] = 0;
_echo_idx = (uint8_t)((_echo_idx + 1) % UI_ECHO_SLOTS);
}
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). */
void uiCountEcho(uint32_t fp) {
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). `hop`
* (optional) is the re-flooding repeater's hash — the echo's last path hop —
* recorded deduped + bounded so the sent-message Info can name the repeaters. */
void uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
for (int i = 0; i < UI_ECHO_SLOTS; i++)
if (_echo_fp[i] == fp) { if (_echo_rep[i] < 255) _echo_rep[i]++; _echo_dirty = true; return; }
if (_echo_fp[i] == fp) {
if (_echo_rep[i] < 255) _echo_rep[i]++;
_echo_dirty = true;
if (hop && hop_sz > 0 && hop_sz <= 4 && _echo_hop_n[i] < ECHO_MAX_HOPS) {
bool seen = false;
for (uint8_t k = 0; k < _echo_hop_n[i]; k++)
if (_echo_hop_sz[i] == hop_sz && memcmp(_echo_hop[i][k], hop, hop_sz) == 0) { seen = true; break; }
if (!seen) {
memcpy(_echo_hop[i][_echo_hop_n[i]], hop, hop_sz);
_echo_hop_sz[i] = hop_sz;
_echo_hop_n[i]++;
}
}
return;
}
}
/** True once if a repeat was counted since the last call — the UI uses this
* to refresh the chat so the bubble's repeat tag updates live. */
bool takeEchoDirty() { bool d = _echo_dirty; _echo_dirty = false; return d; }
/** UI: resolve a path-hop hash prefix to its (repeater) contact name. Bounded
* + null-safe; writes out[] NUL-terminated and returns true ONLY on a named
* match (otherwise the caller shows the bare hash). Read-only contact lookup,
* so it can't corrupt anything even if called mid-RX. */
bool uiHopName(const uint8_t* hash, int prefix_len, char* out, size_t out_sz) {
if (!out || out_sz == 0) return false;
out[0] = '\0';
if (!hash || prefix_len <= 0) return false;
ContactInfo* c = lookupContactByPubKey(hash, prefix_len);
if (!c || c->name[0] == '\0') return false;
strncpy(out, c->name, out_sz - 1);
out[out_sz - 1] = '\0';
return true;
}
/** Resolve a path-hop hash prefix to the repeater's advertised position.
* Returns true + fills lat/lon (degrees) when the contact is known AND has a
* non-zero GPS position; false otherwise. Read-only — safe from the UI. */
bool uiHopPos(const uint8_t* hash, int prefix_len, double* out_lat, double* out_lon) {
if (!hash || prefix_len <= 0) return false;
ContactInfo* c = lookupContactByPubKey(hash, prefix_len);
if (!c) return false;
if (c->gps_lat == 0 && c->gps_lon == 0) return false;
if (out_lat) *out_lat = (double)c->gps_lat / 1.0e6;
if (out_lon) *out_lon = (double)c->gps_lon / 1.0e6;
return true;
}
/** Send a 0-hop trace ping to a single neighbor (typically a repeater).
* Returns the trace tag we chose (non-zero on success) so the UI can
* match the onTracePingResult callback. The trace path is just the
+191 -53
View File
@@ -1,43 +1,86 @@
#include "SdNvsPrefs.h"
#if defined(ESP32)
#include <FS.h>
#include <SD.h>
#include <SPIFFS.h>
#include <string.h>
// Backend is probed ONCE per boot and cached for every namespace/instance: a
// device with healthy NVS keeps using NVS; a Launcher install (NVS full) routes
// everything to /meshcomod/<ns>.kv on the SD card.
static int s_backend = -1; // -1 undecided, 0 = SD, 1 = NVS
// ----------------------------- backend selection -----------------------------
// File mode (set by SdNvsPrefs::useFile at boot): every namespace lives in a flat
// <dir>/<ns>.kv file on the chosen filesystem; NVS is read-only (migration only).
// Legacy mode (before useFile): NVS if it works, else /meshcomod/<ns>.kv on SD.
// All backend state is file-static so the class layout stays identical to the
// monorepo lib's stale header (see SdNvsPrefs.h — mismatching it bootloops).
static fs::FS* s_file_fs = nullptr;
static char s_file_dir[24] = {0};
static bool s_file_mode = false;
static int s_legacy = -1; // legacy probe cache: -1 undecided, 0 SD, 1 NVS
static int s_migrate = -1; // -1 undecided, 0 = no NVS legacy data, 1 = yes
void SdNvsPrefs::useFile(fs::FS* fs, const char* dir) {
if (!fs) return;
s_file_fs = fs;
strncpy(s_file_dir, (dir && dir[0]) ? dir : "/prefs", sizeof(s_file_dir) - 1);
s_file_dir[sizeof(s_file_dir) - 1] = '\0';
s_file_mode = true;
Serial.printf("[PREFS] backend = FILE %s\n", s_file_dir);
}
static bool fileMode() { return s_file_mode && s_file_fs; }
static fs::FS* activeFs() { return fileMode() ? s_file_fs : (fs::FS*)&SD; }
// Does NVS hold settings from an older (NVS) build worth migrating? Probed ONCE
// (RO open of the main namespace) so fresh devices don't spam NOT_FOUND when the
// per-namespace migration fallback opens.
static bool nvsHasLegacyData() {
if (s_migrate < 0) {
Preferences p;
bool ok = p.begin("touch", true); // TOUCH_NS, read-only
if (ok) p.end();
s_migrate = ok ? 1 : 0;
}
return s_migrate == 1;
}
bool SdNvsPrefs::useNvs() {
if (s_backend < 0) {
if (s_legacy < 0) {
Preferences probe;
bool ok = false;
if (probe.begin("mc_kvprobe", false)) { // RW open must succeed...
ok = (probe.putUChar("v", 1) > 0); // ...and a real write must land
probe.end();
}
s_backend = ok ? 1 : 0;
Serial.printf("[PREFS] backend = %s\n", ok ? "NVS" : "SD /meshcomod");
if (probe.begin("mc_kvprobe", false)) { ok = (probe.putUChar("v", 1) > 0); probe.end(); }
s_legacy = ok ? 1 : 0;
Serial.printf("[PREFS] legacy backend = %s\n", ok ? "NVS" : "SD /meshcomod");
}
return s_backend == 1;
return s_legacy == 1;
}
// ----------------------------- begin / end -----------------------------
bool SdNvsPrefs::begin(const char* ns, bool readOnly) {
_read_only = readOnly;
_sd.clear();
_sd_loaded = false;
if (_nvs_open) { _nvs.end(); _nvs_open = false; }
if (fileMode()) {
snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns);
sdLoad();
_sd_loaded = true;
if (nvsHasLegacyData()) _nvs_open = _nvs.begin(ns, true); // RO migration source
return true;
}
if (useNvs()) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; }
snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns);
if (!_sd_loaded) { sdLoad(); _sd_loaded = true; }
return true; // SD store is always "open"
sdLoad();
_sd_loaded = true;
return true;
}
void SdNvsPrefs::end() {
if (useNvs() && _nvs_open) { _nvs.end(); _nvs_open = false; }
// SD mode keeps its RAM mirror across begin/end (flushed on every put).
if (_nvs_open) { _nvs.end(); _nvs_open = false; }
}
// ----------------------------- SD helpers -----------------------------
// ----------------------------- file helpers -----------------------------
SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) {
for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e;
return nullptr;
@@ -57,13 +100,12 @@ void SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) {
void SdNvsPrefs::sdLoad() {
_sd.clear();
File f = SD.open(_path, FILE_READ);
fs::FS* fs = activeFs();
if (!fs) return;
File f = fs->open(_path, FILE_READ);
if (!f) return;
// record = [keylen u8][key bytes][vallen u16 LE][val bytes]
// Sanity caps stop a corrupt/garbage file from triggering a huge allocation or
// an unbounded entry list (internal heap is tight on a Launcher boot). Real
// blobs are <=192 B and there are only a few dozen keys; on any bad record we
// stop parsing and keep whatever loaded cleanly before it.
// record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Sanity caps stop a
// corrupt file from a huge alloc; on any bad record we stop and keep what loaded.
while (f.available() > 0 && _sd.size() < 256) {
int kl = f.read();
if (kl <= 0 || kl > 15) break;
@@ -72,7 +114,7 @@ void SdNvsPrefs::sdLoad() {
int lo = f.read(), hi = f.read();
if (lo < 0 || hi < 0) break;
size_t vl = (size_t)lo | ((size_t)hi << 8);
if (vl > 2048) break; // implausible value length -> corrupt
if (vl > 2048) break;
std::vector<uint8_t> v(vl);
if (vl && f.read(v.data(), vl) != (int)vl) break;
Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0';
@@ -83,9 +125,16 @@ void SdNvsPrefs::sdLoad() {
}
void SdNvsPrefs::sdSave() {
if (_read_only) return; // mirror Preferences: a write needs a RW begin
SD.mkdir("/meshcomod");
File f = SD.open(_path, FILE_WRITE); // "w" — truncate + rewrite the whole file
if (_read_only) return;
fs::FS* fs = activeFs();
if (!fs) return;
// Create the parent dir (SD needs it; SPIFFS is flat and treats the whole path
// as a filename, so mkdir is a harmless no-op there).
char dir[40];
strncpy(dir, _path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0';
char* slash = strrchr(dir, '/');
if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); }
File f = fs->open(_path, FILE_WRITE); // truncate + rewrite the whole file
if (!f) return;
for (auto& e : _sd) {
size_t kl = strnlen(e.key, sizeof(e.key));
@@ -116,44 +165,128 @@ size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) {
}
// ----------------------------- API -----------------------------
// File mode: read file first, fall back to the legacy NVS value (read-only) so
// old settings still load and migrate to the file on the next save. Putters only
// ever touch the file — nothing new is written to NVS.
bool SdNvsPrefs::isKey(const char* key) {
if (useNvs()) return _nvs_open && _nvs.isKey(key);
return sdFind(key) != nullptr;
if (fileMode()) return sdFind(key) != nullptr || (_nvs_open && _nvs.isKey(key));
return useNvs() ? (_nvs_open && _nvs.isKey(key)) : (sdFind(key) != nullptr);
}
bool SdNvsPrefs::remove(const char* key) {
if (useNvs()) return _nvs_open && _nvs.remove(key);
if (!fileMode() && useNvs()) return _nvs_open && _nvs.remove(key);
for (size_t i = 0; i < _sd.size(); i++)
if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) == 0) { _sd.erase(_sd.begin() + i); sdSave(); return true; }
return false;
}
bool SdNvsPrefs::clear() {
if (fileMode()) {
_sd.clear();
sdSave();
// Wipe any legacy NVS copy too, so a factory reset is permanent. Derive the
// namespace from "<dir>/<ns>.kv".
if (nvsHasLegacyData()) {
char ns[16] = {0};
const char* slash = strrchr(_path, '/');
const char* base = slash ? slash + 1 : _path;
size_t n = 0; while (base[n] && base[n] != '.' && n < sizeof(ns) - 1) { ns[n] = base[n]; n++; }
ns[n] = '\0';
if (_nvs_open) { _nvs.end(); _nvs_open = false; }
Preferences p; if (p.begin(ns, false)) { p.clear(); p.end(); }
}
return true;
}
if (useNvs()) return _nvs_open && _nvs.clear();
_sd.clear(); sdSave(); return true;
}
uint8_t SdNvsPrefs::getUChar (const char* k, uint8_t def) { return useNvs() ? _nvs.getUChar(k, def) : (uint8_t)sdGetInt(k, def, 1); }
size_t SdNvsPrefs::putUChar (const char* k, uint8_t v) { return useNvs() ? _nvs.putUChar(k, v) : sdPutInt(k, v, 1); }
int8_t SdNvsPrefs::getChar (const char* k, int8_t def) { return useNvs() ? _nvs.getChar(k, def) : (int8_t)sdGetInt(k, (uint8_t)def, 1); }
size_t SdNvsPrefs::putChar (const char* k, int8_t v) { return useNvs() ? _nvs.putChar(k, v) : sdPutInt(k, (uint8_t)v, 1); }
uint16_t SdNvsPrefs::getUShort(const char* k, uint16_t def) { return useNvs() ? _nvs.getUShort(k, def) : (uint16_t)sdGetInt(k, def, 2); }
size_t SdNvsPrefs::putUShort(const char* k, uint16_t v) { return useNvs() ? _nvs.putUShort(k, v) : sdPutInt(k, v, 2); }
uint32_t SdNvsPrefs::getUInt (const char* k, uint32_t def) { return useNvs() ? _nvs.getUInt(k, def) : (uint32_t)sdGetInt(k, def, 4); }
size_t SdNvsPrefs::putUInt (const char* k, uint32_t v) { return useNvs() ? _nvs.putUInt(k, v) : sdPutInt(k, v, 4); }
bool SdNvsPrefs::getBool (const char* k, bool def) { return useNvs() ? _nvs.getBool(k, def) : (sdGetInt(k, def ? 1 : 0, 1) != 0); }
size_t SdNvsPrefs::putBool (const char* k, bool v) { return useNvs() ? _nvs.putBool(k, v) : sdPutInt(k, v ? 1 : 0, 1); }
uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) {
if (fileMode()) {
if (sdFind(k)) return (uint8_t)sdGetInt(k, def, 1);
if (_nvs_open && _nvs.isKey(k)) return _nvs.getUChar(k, def);
return def;
}
return useNvs() ? _nvs.getUChar(k, def) : (uint8_t)sdGetInt(k, def, 1);
}
size_t SdNvsPrefs::putUChar(const char* k, uint8_t v) {
if (fileMode()) return sdPutInt(k, v, 1);
return useNvs() ? _nvs.putUChar(k, v) : sdPutInt(k, v, 1);
}
int8_t SdNvsPrefs::getChar(const char* k, int8_t def) {
if (fileMode()) {
if (sdFind(k)) return (int8_t)sdGetInt(k, (uint8_t)def, 1);
if (_nvs_open && _nvs.isKey(k)) return _nvs.getChar(k, def);
return def;
}
return useNvs() ? _nvs.getChar(k, def) : (int8_t)sdGetInt(k, (uint8_t)def, 1);
}
size_t SdNvsPrefs::putChar(const char* k, int8_t v) {
if (fileMode()) return sdPutInt(k, (uint8_t)v, 1);
return useNvs() ? _nvs.putChar(k, v) : sdPutInt(k, (uint8_t)v, 1);
}
uint16_t SdNvsPrefs::getUShort(const char* k, uint16_t def) {
if (fileMode()) {
if (sdFind(k)) return (uint16_t)sdGetInt(k, def, 2);
if (_nvs_open && _nvs.isKey(k)) return _nvs.getUShort(k, def);
return def;
}
return useNvs() ? _nvs.getUShort(k, def) : (uint16_t)sdGetInt(k, def, 2);
}
size_t SdNvsPrefs::putUShort(const char* k, uint16_t v) {
if (fileMode()) return sdPutInt(k, v, 2);
return useNvs() ? _nvs.putUShort(k, v) : sdPutInt(k, v, 2);
}
uint32_t SdNvsPrefs::getUInt(const char* k, uint32_t def) {
if (fileMode()) {
if (sdFind(k)) return (uint32_t)sdGetInt(k, def, 4);
if (_nvs_open && _nvs.isKey(k)) return _nvs.getUInt(k, def);
return def;
}
return useNvs() ? _nvs.getUInt(k, def) : (uint32_t)sdGetInt(k, def, 4);
}
size_t SdNvsPrefs::putUInt(const char* k, uint32_t v) {
if (fileMode()) return sdPutInt(k, v, 4);
return useNvs() ? _nvs.putUInt(k, v) : sdPutInt(k, v, 4);
}
bool SdNvsPrefs::getBool(const char* k, bool def) {
if (fileMode()) {
if (sdFind(k)) return sdGetInt(k, def ? 1 : 0, 1) != 0;
if (_nvs_open && _nvs.isKey(k)) return _nvs.getBool(k, def);
return def;
}
return useNvs() ? _nvs.getBool(k, def) : (sdGetInt(k, def ? 1 : 0, 1) != 0);
}
size_t SdNvsPrefs::putBool(const char* k, bool v) {
if (fileMode()) return sdPutInt(k, v ? 1 : 0, 1);
return useNvs() ? _nvs.putBool(k, v) : sdPutInt(k, v ? 1 : 0, 1);
}
String SdNvsPrefs::getString(const char* k, const String& def) {
if (useNvs()) return _nvs.getString(k, def);
Kv* e = sdFind(k);
if (!e) return def;
Kv* e = nullptr;
if (fileMode()) {
e = sdFind(k);
if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getString(k, def) : def;
} else {
if (useNvs()) return _nvs.getString(k, def);
e = sdFind(k);
if (!e) return def;
}
String s; s.reserve(e->val.size());
for (uint8_t c : e->val) s += (char)c;
return s;
}
size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) {
if (useNvs()) return _nvs.getString(k, buf, maxLen);
if (!buf || !maxLen) return 0;
Kv* e = sdFind(k);
Kv* e = nullptr;
if (fileMode()) {
e = sdFind(k);
if (!e) { if (_nvs_open && _nvs.isKey(k)) return _nvs.getString(k, buf, maxLen); buf[0] = '\0'; return 0; }
} else {
if (useNvs()) return _nvs.getString(k, buf, maxLen);
e = sdFind(k);
}
size_t n = e ? e->val.size() : 0;
if (n > maxLen - 1) n = maxLen - 1;
if (e && n) memcpy(buf, e->val.data(), n);
@@ -161,24 +294,29 @@ size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) {
return n;
}
size_t SdNvsPrefs::putString(const char* k, const char* v) {
if (fileMode()) { size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; }
if (useNvs()) return _nvs.putString(k, v);
size_t n = v ? strlen(v) : 0;
sdSet(k, (const uint8_t*)v, n);
return n;
size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n;
}
size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) {
if (useNvs()) return _nvs.getBytes(k, buf, maxLen);
Kv* e = sdFind(k);
if (!e) return 0;
Kv* e = nullptr;
if (fileMode()) {
e = sdFind(k);
if (!e) return (_nvs_open && _nvs.isKey(k)) ? _nvs.getBytes(k, buf, maxLen) : 0;
} else {
if (useNvs()) return _nvs.getBytes(k, buf, maxLen);
e = sdFind(k);
if (!e) return 0;
}
size_t n = e->val.size();
if (buf && maxLen) { size_t c = n > maxLen ? maxLen : n; memcpy(buf, e->val.data(), c); }
return n;
}
size_t SdNvsPrefs::putBytes(const char* k, const void* buf, size_t len) {
if (fileMode()) { sdSet(k, (const uint8_t*)buf, len); return len; }
if (useNvs()) return _nvs.putBytes(k, buf, len);
sdSet(k, (const uint8_t*)buf, len);
return len;
sdSet(k, (const uint8_t*)buf, len); return len;
}
#endif // ESP32
+25 -15
View File
@@ -5,21 +5,31 @@
#include <Preferences.h>
#include <vector>
// Drop-in, Preferences-compatible key/value store that survives Launcher.
namespace fs { class FS; } // forward decl only — keep this header's layout stable
// Drop-in, Preferences-compatible key/value store that survives Launcher and can
// keep the touch app's settings OFF NVS.
//
// The companion "app-only" bin, when installed by Launcher, boots on Launcher's
// partition table: there's no usable SPIFFS and the NVS partition is full
// (`nvs_open failed: NOT_ENOUGH_SPACE`). Every Preferences open then fails, so
// settings/Wi-Fi creds silently never persist (and a flood of [E] logs spams the
// console). The mesh DATA already falls back to the SD card (/meshcomod); this
// does the same for PREFS: it mirrors the subset of the Arduino Preferences API
// that TouchPrefsStore + WifiRuntimeStore use, delegating to NVS when NVS works
// and to a flat /meshcomod/<ns>.kv file on the SD card when it doesn't.
// File mode (set by SdNvsPrefs::useFile() once at boot, after the SD/SPIFFS
// storage decision): every namespace lives in a flat <dir>/<ns>.kv file on the
// chosen filesystem — SD (/meshcomod) when a card is the active data store, else
// SPIFFS (/prefs). Nothing new is written to NVS; NVS is read-only, used only to
// migrate settings written by an older NVS build (they move to the file on their
// next save). Legacy mode (before useFile, e.g. the early boot-rotation read):
// NVS if it works, else /meshcomod/<ns>.kv on SD — the previous behaviour.
//
// The backend (NVS vs SD) is probed ONCE per boot and cached globally, so a
// device with working NVS behaves exactly as before.
// IMPORTANT: the monorepo lib ships a STALE copy of this header that other
// translation units may pick up via an angle include. To avoid an ODR/layout
// mismatch (which caused a free()-of-garbage bootloop), the DATA LAYOUT here must
// stay byte-identical to that stale copy: do NOT add/remove/reorder data members.
// The file backend therefore uses file-static state in the .cpp, not a member.
// Only the layout-neutral static useFile() is new.
//
// On-disk file format is unchanged: [keylen u8][key][vallen u16 LE][val].
class SdNvsPrefs {
public:
static void useFile(fs::FS* fs, const char* dir); // route prefs to <dir>/<ns>.kv
bool begin(const char* ns, bool readOnly = false);
void end();
@@ -47,18 +57,18 @@ public:
size_t putBytes(const char* key, const void* buf, size_t len);
private:
// --- NVS-backed path ---
// --- NVS-backed path --- (LAYOUT MUST MATCH the lib's stale header — see above)
Preferences _nvs;
bool _nvs_open = false;
bool _read_only = false;
// --- SD-backed path ---
// --- file-backed path ---
struct Kv { char key[16]; std::vector<uint8_t> val; };
std::vector<Kv> _sd; // in-RAM mirror of /meshcomod/<ns>.kv
std::vector<Kv> _sd; // in-RAM mirror of <dir>/<ns>.kv
char _path[40] = {0};
bool _sd_loaded = false;
bool useNvs(); // probe once, cache globally
bool useNvs(); // legacy probe (NVS vs SD), cached globally
Kv* sdFind(const char* key);
void sdSet(const char* key, const uint8_t* data, size_t len);
void sdLoad();
+295 -6
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 1;
static const uint8_t TOUCH_CFG_VER = 3; // v2 adds sig_probe_en + sig_poll_min; v3 adds tz_zone
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -46,6 +46,8 @@ 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
struct __attribute__((packed)) TouchCfg {
uint16_t magic; // TOUCH_CFG_MAGIC — rejects a garbage/short read
@@ -68,6 +70,9 @@ struct __attribute__((packed)) TouchCfg {
uint32_t lock_color; // "lk_col" 0xRRGGBB
uint32_t accent; // "accent" 0xRRGGBB
uint32_t gps_baud; // "gps_baud" 0 = unset -> caller fallback
uint8_t sig_probe_en; // signal auto-discover probe on/off (bool) — v2
uint16_t sig_poll_min; // minutes between signal probes, 1..1440 — v2
uint8_t tz_zone; // selected time-zone index (0 = Europe/CET) — v3
};
static TouchCfg s_cfg;
@@ -114,6 +119,9 @@ static void cfgSetDefaults(TouchCfg& c) {
c.lock_color = DEFAULT_LOCK_COLOR;
c.accent = DEFAULT_ACCENT;
c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback
c.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
}
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -148,6 +156,12 @@ static void cfgLoadOrMigrate() {
// newer trailing fields at their default), then version-upgrade if needed.
memcpy(&s_cfg, &tmp, n < sizeof(s_cfg) ? n : sizeof(s_cfg));
if (s_cfg.ver < 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 (s_cfg.ver < 3 && s_cfg.time_offs != 0) s_cfg.tz_zone = 0xFE;
s_cfg.ver = TOUCH_CFG_VER;
s_cfg.magic = TOUCH_CFG_MAGIC;
cfgFlush(); // rewrite with new fields defaulted-in
@@ -229,6 +243,18 @@ void touchPrefsBegin() {
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();
}
// 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)
@@ -249,6 +275,37 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) {
return cfgFlush();
}
// --- Mesh signal auto-discover probe (toggle + poll interval) ---------------
// The interval is entered in whole minutes; clamp >1 min (one flood a minute is
// already aggressive on shared airtime) .. 1 day so a bad/blank entry can't make
// the probe hammer the mesh or effectively never run.
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;
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;
@@ -440,13 +497,70 @@ bool touchPrefsSetTimeOffsetHours(int hours) {
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;
// CET base is UTC+1 (POSIX std offset -1). The displayed local time is
// UTC + (1 + userOffset), so the POSIX std-offset field is -(1 + userOffset).
// CEST (summer) auto-adds 1h via the DST rule. offset 0 == "CET-1CEST,...".
int off = touchPrefsGetTimeOffsetHours();
snprintf(out, out_cap, "CET%dCEST,M3.5.0,M10.5.0/3", -(1 + off));
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() {
@@ -834,6 +948,181 @@ bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored) {
}
}
// Ignored / blocked sender NAMES (channel/room senders that aren't contacts) ---
// One NVS blob "ign_nm" 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.
static const char* KEY_IGN_NAMES = "ign_nm";
static int ignNamesReadAll(char out[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]) {
if (!s_begun) touchPrefsBegin();
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);
}
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 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 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 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); }
// 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();
uint8_t v = s_prefs.getUChar("snd_vol", 70);
return v > 100 ? 100 : v;
}
void touchPrefsSetSoundVolume(uint8_t vol) { if (vol > 100) vol = 100; if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_vol", vol); }
// 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
static uint8_t s_chm[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, sizeof(s_chm));
if (n == 0 || n > sizeof(s_chm)) { 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);
}
// Remembered repeater admin passwords --------------------------------------
//
// Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records,
+81 -3
View File
@@ -7,8 +7,12 @@
#if defined(ESP32)
#include <stdint.h>
#include <stddef.h> // size_t (blob helpers below)
void touchPrefsBegin();
// Force a fresh load of the settings blob. Call after SdNvsPrefs::useFile() at
// boot: an earlier pref read may have cached the blob from the legacy backend.
void touchPrefsReload();
/** Screen timeout in seconds; 0 = never sleep. Default 20. */
uint16_t touchPrefsGetScreenTimeoutSecs();
@@ -152,6 +156,58 @@ bool touchPrefsIsIgnored(const uint8_t* pub_key6);
bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored);
int touchPrefsCopyIgnored(uint8_t* out_buf);
/** Ignored / blocked sender NAMES — for channel/room senders who are NOT saved
* contacts, so the 6-byte pubkey scheme above can't target them (e.g. a bot
* posting inside a joined room: posts carry only a display name, not a pubkey).
* Incoming channel/room messages whose parsed sender name is stored here are
* dropped (no bubble, no notification, no sound). Case-sensitive exact match.
* • touchPrefsIsNameIgnored / touchPrefsSetNameIgnored: membership + add/remove.
* • touchPrefsCopyIgnoredNames: copy every stored name into `out_buf`
* (>= TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN bytes); returns count. */
constexpr int TOUCH_IGNORED_NAMES_MAX = 16;
constexpr int TOUCH_IGNORED_NAME_LEN = 28; // fixed NUL-padded slot (>= MAX_SENDER_NAME+1)
bool touchPrefsIsNameIgnored(const char* name);
bool touchPrefsSetNameIgnored(const char* name, bool ignored);
int touchPrefsCopyIgnoredNames(char* out_buf);
/** Notification-sound prefs (the message chime itself stays on the core
* buzzer_quiet flag). @-mention chime is a SEPARATE enable + a distinct sound,
* so an operator can mute everything but still hear @-mentions. Volume is the
* T-Deck I2S amplitude (0..100); the V4 piezo can't vary volume. */
bool touchPrefsGetSoundMessages(); // message chime on/off (under the master), default true
void touchPrefsSetSoundMessages(bool on);
bool touchPrefsGetDiscoveredAutoEvict(); // ring full -> auto-delete the oldest discovered node (default true)
void touchPrefsSetDiscoveredAutoEvict(bool on);
uint8_t touchPrefsGetDiscoveredMaxHops(); // auto-delete discovered nodes heard via more hops than this (0 = off)
void touchPrefsSetDiscoveredMaxHops(uint8_t hops);
bool touchPrefsGetSoundMentions(); // default true
void touchPrefsSetSoundMentions(bool on);
uint8_t touchPrefsGetSoundVolume(); // 0..100, default 70
void touchPrefsSetSoundVolume(uint8_t vol);
bool touchPrefsGetEnterSends(); // Enter key sends a chat message (default true)
void touchPrefsSetEnterSends(bool on);
bool touchPrefsGetClock12h(); // 12-hour clock (default false = 24h)
void touchPrefsSetClock12h(bool on);
bool touchPrefsGetScrollReverse(); // invert trackball/scrollball direction (default false)
void touchPrefsSetScrollReverse(bool on);
bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; only a deliberate hold wakes (default false)
void touchPrefsSetLockOnScreenOff(bool on);
/** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 =
* mute @-mentions. Suppresses the notification SOUND for that channel (the
* unread badge/divider still work). A small name-keyed NVS blob. */
constexpr int TOUCH_CHMUTE_MAX = 24;
constexpr int TOUCH_CHMUTE_NAME = 32;
constexpr uint8_t TOUCH_CHMUTE_MSG = 0x1;
constexpr uint8_t TOUCH_CHMUTE_MEN = 0x2;
uint8_t touchPrefsGetChannelMute(const char* name);
void touchPrefsSetChannelMute(const char* name, uint8_t flags);
/** Generic NVS blob (key/value). Used to persist the discovered-nodes list so
* it survives a reboot. getBlob returns the byte count copied (0 if absent). */
size_t touchPrefsGetBlob(const char* key, uint8_t* out, size_t maxlen);
bool touchPrefsSetBlob(const char* key, const uint8_t* data, size_t len);
/** Map tile-server base URL. The device fetches missing map tiles by
* HTTP GET against `<base>/<z>/<x>/<y>.png`. Defaults to the meshcomod
* proxy. Plain HTTP only — mbedTLS doesn't fit in the ~5 KB of internal
@@ -218,9 +274,20 @@ bool touchPrefsSetAccentColor(uint32_t rgb);
int touchPrefsGetTimeOffsetHours();
bool touchPrefsSetTimeOffsetHours(int hours);
/** Build the POSIX TZ string for local-time display: the CET/CEST base plus the
* user's hour offset (see touchPrefsGetTimeOffsetHours). Used by both the
* boot-time setenv and the Wi-Fi NTP sync so the offset is honoured everywhere.
/** Time-zone picker. A curated list of named zones, each carrying the correct
* POSIX TZ string WITH that region's DST rules — so non-European users get the
* right time year-round instead of being stuck on the CET base + EU DST dates.
* Default 0 == "Europe (CET/CEST)" (unchanged behaviour). The LAST index is
* "Custom (UTC offset)", which uses touchPrefsGetTimeOffsetHours() as a fixed,
* no-DST offset. touchPrefsBuildLocalTz() resolves the selected zone. */
int touchPrefsTimezoneCount(); // number of pickable entries
const char* touchPrefsTimezoneLabel(int idx); // display label for the picker
uint8_t touchPrefsGetTimezone(); // selected index (default 0)
void touchPrefsSetTimezone(uint8_t idx);
/** Build the POSIX TZ string for local-time display from the selected time zone
* (touchPrefsGetTimezone), or a fixed UTC offset for the "Custom" entry. Used by
* both the boot-time setenv and the Wi-Fi NTP sync so it's honoured everywhere.
* `out` is always null-terminated when out_cap > 0. */
void touchPrefsBuildLocalTz(char* out, int out_cap);
@@ -237,4 +304,15 @@ bool touchPrefsSetSetupDone(bool done);
uint32_t touchPrefsGetGpsBaud(uint32_t fallback);
bool touchPrefsSetGpsBaud(uint32_t baud);
/** Mesh signal auto-discover probe. When ON (default), the firmware periodically
* sends a flood advert so nearby repeaters re-broadcast it and we can measure
* the echo's SNR — what drives the top-bar signal bars and the home-graph
* readout. The poll interval is in whole minutes, clamped 1..1440 (1 min =
* 60 s minimum, no higher than a day). Both are exposed in the home-graph
* "Signal & traffic" popup (tap the graph). */
bool touchPrefsGetSigProbeEnabled();
bool touchPrefsSetSigProbeEnabled(bool on);
uint16_t touchPrefsGetSigPollMins();
bool touchPrefsSetSigPollMins(uint16_t mins);
#endif
+55 -1
View File
@@ -8,7 +8,9 @@
#include <esp_partition.h> // find/erase otadata so the bootloader returns to the recovery
#include <helpers/TouchDiagTrace.h>
#include <helpers/MeshTouchTxTrace.h>
#include <helpers/esp32/TouchPrefsStore.h> // touchPrefsGetUiRotation for the boot wordmark
#include "helpers/esp32/TouchPrefsStore.h" // QUOTED: get wadamesh's copy (touchPrefsReload), not the lib's stale one
#include "helpers/esp32/SdNvsPrefs.h" // route prefs to file storage (SD/SPIFFS), off NVS
// (quoted: use wadamesh's src/ copy, not the lib's stale one)
#include "wadamesh_mark_rgb.h" // anti-aliased mesh-mark (RGB565) for the pre-LVGL boot screen
#endif
@@ -345,6 +347,22 @@ void setup() {
#endif
if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS
Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "SPIFFS");
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
// Route touch settings + Wi-Fi creds to the active filesystem (SD when that's
// the data store, else SPIFFS) instead of NVS. Old NVS values still load and
// migrate on their next save, so this is a transparent in-place upgrade.
#if defined(HAS_TDECK_GT911)
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS,
sd_storage ? "/meshcomod" : "/prefs");
#else
SdNvsPrefs::useFile((fs::FS*)&SPIFFS, "/prefs"); // no SD on this board
#endif
// The boot wordmark already read a pref (UI rotation) BEFORE useFile switched
// the backend, caching the settings blob from legacy NVS. Re-read it now so
// file-saved values (theme accent, brightness, language, …) take effect this
// boot — otherwise a theme change "reverts" on every restart.
touchPrefsReload();
#endif
store.begin();
the_mesh.begin(
#ifdef DISPLAY_CLASS
@@ -383,6 +401,10 @@ void setup() {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false);
WiFi.persistent(false);
// NOTE: do NOT enable modem-sleep here. On a fresh, *unassociated* STA (the
// setup wizard, no creds yet) DTIM modem-sleep naps the radio through the
// scan dwell, so WiFi.scanNetworks() comes back empty ("no networks found").
// It's enabled once we actually associate — see the GOT_IP handler below.
}
#if defined(BLE_PIN_CODE)
/* Always stash the BLE params so the toggle can bring BLE up live later, even
@@ -545,6 +567,12 @@ void loop() {
static bool sntp_pushed = false;
static uint32_t sntp_kick_ms = 0;
if (WiFi.status() == WL_CONNECTED) {
// Now that we're associated, enable DTIM modem-sleep (saves power + gives
// BLE coexistence airtime). Deferred to here on purpose: enabling it on the
// unassociated STA naps the radio through a scan dwell and breaks the setup
// wizard's WiFi.scanNetworks() ("no networks found"). One-shot.
static bool modem_sleep_set = false;
if (!modem_sleep_set) { WiFi.setSleep(true); modem_sleep_set = true; }
if (!sntp_kicked) {
/* Brussels timezone with DST rules baked in (POSIX "CET-1CEST,...").
* On touch builds the base is shifted by the user's manual hour offset
@@ -587,6 +615,32 @@ void loop() {
#endif
the_mesh.loop();
sensors.loop();
#if defined(ESP32)
// GPS time guard (Ricky Leong's "stuck at 1902"): MicroNMEALocationProvider
// sets the mesh RTC from a GPS *position* fix even before the date fields are
// valid (getYear()==0 -> ~1902-10-11), and re-syncs every 30 min — so it
// periodically clobbers a good time. A 1902 clock stamps our adverts as
// decades old and every other node rejects them as stale.
//
// On the T-Deck the mesh RTC, NTP and GPS ALL share the one ESP32 system clock
// (ESP32RTCClock == settimeofday/gettimeofday), so we can't recover by
// "re-reading NTP" — it was already overwritten. Instead keep a millis()-
// anchored copy of the last good time and rebuild from it whenever the clock
// reads garbage, undoing the clobber before the next the_mesh.loop() sends an
// advert. The anchor refreshes every loop while the clock is sane, so the
// rebuilt time is accurate to the second.
{
static uint32_t good_epoch = 0, good_millis = 0;
const uint32_t live = rtc_clock.getCurrentTime();
if (live > 1700000000UL) { // clock sane -> remember it (anchor)
good_epoch = live;
good_millis = millis();
} else if (good_epoch != 0) { // clock went bad -> rebuild from anchor
const uint32_t rebuilt = good_epoch + (uint32_t)((millis() - good_millis) / 1000UL);
rtc_clock.setCurrentTime(rebuilt);
}
}
#endif
rtc_clock.tick();
// (1.16) sleep when there's no pending work — nRF power saving
+4067 -447
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -253,6 +253,7 @@ public:
void showAlert(const char* text, int duration_millis);
int getMsgCount() const { return _msgcount; }
int getUnreadTotal() const;
int getUnreadMentionCount() const; // # of threads with an unread @mention of me
void markThreadRead(int idx); // clear one thread's unread count (persisted)
void markAllThreadsRead(); // clear every thread's unread count
bool threadHasMention(int idx) const; // unread @mention of me in this thread
+55 -7
View File
@@ -1,7 +1,7 @@
/*******************************************************************************
* Size: 16 px
* Bpp: 4
* Opts: --no-compress --bpp 4 --size 16 --font /tmp/fa/fa-solid-900.ttf -r 0xF007 --format lvgl -o /tmp/person_font.c
* Opts: --no-compress --bpp 4 --size 16 --font /tmp/fa/fa-solid-900.ttf -r 0xF007 -r 0xF519 -r 0xF0C0 --format lvgl -o src/ui-touch/person_font.c
******************************************************************************/
#include "lvgl.h"
@@ -32,7 +32,51 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xff, 0xff, 0xff, 0xa0, 0x5f, 0xff, 0xff, 0xff,
0xff, 0xff, 0xf5, 0xbf, 0xff, 0xff, 0xff, 0xff,
0xff, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xfe, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc
0xfe, 0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc,
/* U+F0C0 "" */
0x0, 0x1c, 0xfc, 0x10, 0x0, 0x0, 0x0, 0x7e,
0xe7, 0x0, 0x0, 0xbf, 0xff, 0xb0, 0x0, 0x0,
0x3, 0xff, 0xff, 0x30, 0x0, 0xff, 0xff, 0xf0,
0x0, 0x0, 0x7, 0xff, 0xff, 0x70, 0x0, 0xbf,
0xff, 0xb0, 0x0, 0x0, 0x3, 0xff, 0xff, 0x30,
0x0, 0x1c, 0xfc, 0x10, 0x8e, 0xe8, 0x0, 0x7e,
0xe7, 0x0, 0x0, 0x0, 0x0, 0x8, 0xff, 0xff,
0x80, 0x0, 0x0, 0x0, 0x6, 0xdf, 0xfd, 0xe,
0xff, 0xff, 0xe0, 0xdf, 0xfd, 0x60, 0x6f, 0xff,
0xff, 0xe, 0xff, 0xff, 0xe1, 0xff, 0xff, 0xf6,
0xdf, 0xff, 0xff, 0x58, 0xff, 0xff, 0x85, 0xff,
0xff, 0xfd, 0xdf, 0xff, 0xff, 0xd1, 0x8e, 0xe8,
0x1d, 0xff, 0xff, 0xfd, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x1, 0xae, 0xff, 0xff, 0xea, 0x10, 0x0, 0x0,
0x0, 0x0, 0x1e, 0xff, 0xff, 0xff, 0xff, 0xe1,
0x0, 0x0, 0x0, 0x0, 0xaf, 0xff, 0xff, 0xff,
0xff, 0xfa, 0x0, 0x0, 0x0, 0x0, 0xef, 0xff,
0xff, 0xff, 0xff, 0xfe, 0x0, 0x0, 0x0, 0x0,
0xcf, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0, 0x0,
/* U+F519 "" */
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x3e, 0x50, 0x0, 0x0, 0x0, 0x0, 0x0,
0x5, 0xf3, 0x9f, 0x75, 0xe3, 0x0, 0x0, 0x0,
0x3e, 0x57, 0xf9, 0xdf, 0x2c, 0xf4, 0x4, 0xee,
0x40, 0x4f, 0xc2, 0xfd, 0xff, 0xf, 0xf0, 0xe,
0xff, 0xe0, 0xf, 0xf0, 0xff, 0xff, 0xf, 0xf0,
0xd, 0xff, 0xd0, 0xf, 0xf0, 0xff, 0xdf, 0x2c,
0xf4, 0x4, 0xff, 0x40, 0x4f, 0xc2, 0xfd, 0x9f,
0x75, 0xe3, 0x0, 0xff, 0x0, 0x3e, 0x57, 0xf9,
0x3f, 0x50, 0x0, 0x0, 0xff, 0x0, 0x0, 0x5,
0xe3, 0x0, 0x0, 0x0, 0x0, 0xff, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0xcc, 0x0, 0x0, 0x0,
0x0
};
@@ -42,21 +86,25 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
{.bitmap_index = 0, .adv_w = 224, .box_w = 14, .box_h = 16, .ofs_x = 0, .ofs_y = -2}
{.bitmap_index = 0, .adv_w = 224, .box_w = 14, .box_h = 16, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 112, .adv_w = 320, .box_w = 20, .box_h = 16, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 272, .adv_w = 288, .box_w = 18, .box_h = 17, .ofs_x = 0, .ofs_y = -2}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
static const uint16_t unicode_list_0[] = {
0x0, 0xb9, 0x512
};
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61447, .range_length = 1, .glyph_id_start = 1,
.unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY
.range_start = 61447, .range_length = 1299, .glyph_id_start = 1,
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 3, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
}
};
@@ -104,7 +152,7 @@ lv_font_t person_font = {
#endif
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
.line_height = 16, /*The maximum line height required by the font*/
.line_height = 17, /*The maximum line height required by the font*/
.base_line = 2, /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
.subpx = LV_FONT_SUBPX_NONE,
+165
View File
@@ -0,0 +1,165 @@
/*******************************************************************************
* Size: 14 px
* Bpp: 4
* Opts: --no-compress --bpp 4 --size 14 --font /tmp/fa/fa-solid-900.ttf -r 0xF007 -r 0xF519 -r 0xF0C0 --format lvgl -o src/ui-touch/person_font14.c
******************************************************************************/
#include "lvgl.h"
#ifndef PERSON_FONT14
#define PERSON_FONT14 1
#endif
#if PERSON_FONT14
/*-----------------
* BITMAPS
*----------------*/
/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+F007 "" */
0x0, 0x0, 0x0, 0x10, 0x0, 0x0, 0x0, 0x0,
0x1b, 0xff, 0xd3, 0x0, 0x0, 0x0, 0xc, 0xff,
0xff, 0xe1, 0x0, 0x0, 0x3, 0xff, 0xff, 0xff,
0x70, 0x0, 0x0, 0x5f, 0xff, 0xff, 0xf9, 0x0,
0x0, 0x2, 0xff, 0xff, 0xff, 0x60, 0x0, 0x0,
0x9, 0xff, 0xff, 0xc0, 0x0, 0x0, 0x0, 0x6,
0xcc, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x5, 0xcf, 0xff, 0xfd, 0x70,
0x0, 0x9, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x5,
0xff, 0xff, 0xff, 0xff, 0xff, 0x90, 0xcf, 0xff,
0xff, 0xff, 0xff, 0xff, 0xf, 0xff, 0xff, 0xff,
0xff, 0xff, 0xf3, 0x8b, 0xbb, 0xbb, 0xbb, 0xbb,
0xba, 0x0,
/* U+F0C0 "" */
0x0, 0x2, 0x20, 0x0, 0x0, 0x0, 0x2, 0x20,
0x0, 0x0, 0xaf, 0xf8, 0x0, 0x0, 0x0, 0x9f,
0xf9, 0x0, 0x2, 0xff, 0xff, 0x0, 0x0, 0x1,
0xff, 0xff, 0x10, 0x2, 0xff, 0xff, 0x0, 0x0,
0x1, 0xff, 0xff, 0x10, 0x0, 0x8f, 0xf6, 0x6,
0xb9, 0x20, 0x7f, 0xf7, 0x0, 0x0, 0x0, 0x0,
0x6f, 0xff, 0xe0, 0x0, 0x0, 0x0, 0x9, 0xef,
0xf3, 0xdf, 0xff, 0xf5, 0xaf, 0xfc, 0x40, 0x9f,
0xff, 0xf5, 0xcf, 0xff, 0xf4, 0xdf, 0xff, 0xf1,
0xef, 0xff, 0xfa, 0x3f, 0xff, 0xb3, 0xff, 0xff,
0xf6, 0x68, 0x88, 0x87, 0x12, 0x75, 0x5, 0x88,
0x88, 0x72, 0x0, 0x0, 0x0, 0x46, 0x66, 0x62,
0x0, 0x0, 0x0, 0x0, 0x0, 0x4e, 0xff, 0xff,
0xff, 0xa0, 0x0, 0x0, 0x0, 0x1, 0xef, 0xff,
0xff, 0xff, 0xf8, 0x0, 0x0, 0x0, 0x5, 0xff,
0xff, 0xff, 0xff, 0xfd, 0x0, 0x0, 0x0, 0x5,
0xff, 0xff, 0xff, 0xff, 0xfc, 0x0, 0x0,
/* U+F519 "" */
0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x20,
0x7f, 0x23, 0x10, 0x0, 0x0, 0x2, 0x16, 0xf3,
0xcf, 0x1f, 0xa0, 0x4, 0x30, 0xe, 0xc4, 0xf8,
0xed, 0x4f, 0x70, 0x9f, 0xf5, 0xb, 0xf1, 0xfa,
0xfc, 0x5f, 0x60, 0xcf, 0xf8, 0xa, 0xf1, 0xfb,
0xee, 0x3f, 0x90, 0x5f, 0xe2, 0xd, 0xf2, 0xf9,
0xaf, 0x2a, 0x70, 0xf, 0xc0, 0xa, 0x76, 0xf6,
0x2a, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x3, 0xa0,
0x0, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xf, 0xc0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xf, 0xb0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0xa, 0x60, 0x0, 0x0, 0x0
};
/*---------------------
* GLYPH DESCRIPTION
*--------------------*/
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
{.bitmap_index = 0, .adv_w = 196, .box_w = 13, .box_h = 15, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 98, .adv_w = 280, .box_w = 18, .box_h = 15, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 233, .adv_w = 252, .box_w = 16, .box_h = 15, .ofs_x = 0, .ofs_y = -2}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
static const uint16_t unicode_list_0[] = {
0x0, 0xb9, 0x512
};
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61447, .range_length = 1299, .glyph_id_start = 1,
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 3, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
}
};
/*--------------------
* ALL CUSTOM DATA
*--------------------*/
#if LVGL_VERSION_MAJOR == 8
/*Store all the custom data of the font*/
static lv_font_fmt_txt_glyph_cache_t cache;
#endif
#if LVGL_VERSION_MAJOR >= 8
static const lv_font_fmt_txt_dsc_t font_dsc = {
#else
static lv_font_fmt_txt_dsc_t font_dsc = {
#endif
.glyph_bitmap = glyph_bitmap,
.glyph_dsc = glyph_dsc,
.cmaps = cmaps,
.kern_dsc = NULL,
.kern_scale = 0,
.cmap_num = 1,
.bpp = 4,
.kern_classes = 0,
.bitmap_format = 0,
#if LVGL_VERSION_MAJOR == 8
.cache = &cache
#endif
};
/*-----------------
* PUBLIC FONT
*----------------*/
/*Initialize a public general font descriptor*/
#if LVGL_VERSION_MAJOR >= 8
const lv_font_t person_font14 = {
#else
lv_font_t person_font14 = {
#endif
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
.line_height = 15, /*The maximum line height required by the font*/
.base_line = 2, /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
.subpx = LV_FONT_SUBPX_NONE,
#endif
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
.underline_position = -1,
.underline_thickness = 1,
#endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL,
#endif
.user_data = NULL,
};
#endif /*#if PERSON_FONT14*/