Compare commits

...
92 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 2aa8f59410 touch: beta_8 — Ack reply, map visibility toggles + auto-refresh, RAM trim
- Chat: quick replies insert at the cursor (no longer replace typed text);
  long-press 'Ack' drops a signal-strength + route reply into the composer
  (@mentions the sender on channels); fixed the action-menu overflowing the screen.
- Map: +/- zoom glyph; per-line on-map visibility toggles (coords / tile xyz /
  contacts); microSD-tiles regrouped under a heading; auto-refresh follow button
  with on/off toast; close-X overlap fix when scrolled.
- Perf: ~18.9 KB internal DRAM reclaimed (6 scratch buffers -> on-demand PSRAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:36:46 +02:00
Kaj SchittecatandClaude Opus 4.8 c8b451f14f release: beta_7 notes — credit contributors prominently (Vybo #12, Ruben Laban #15, marcelverdult #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:32:03 +02:00
Kaj SchittecatandClaude Opus 4.8 9e567e1789 touch: beta_7 — app-drawer fit + visible scroll, Snake touch steering & icon
- App drawer: size tiles to fit so the bottom row's labels aren't clipped, and
  show a persistent scrollbar when the grid overflows (discoverable scroll).
- Snake: steer via the hardware swipe detector (works on the V4 cap-touch and
  the T-Deck), stop touch/swipe leaking to the screen behind the overlay, and
  gate the status bar while playing.
- Snake app-drawer icon drawn from shapes — the U+1F40D emoji isn't in the baked
  colour-emoji set, so it rendered as a tofu box.
- Add beta_7 release notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:21:49 +02:00
Kaj Schittecat bbcafa3088 Merge PR #12 (Vybo): status-bar tools, battery history, map night mode, image viewer, Snake
# Conflicts:
#	src/ui-touch/UITask.cpp
2026-06-18 11:04:08 +02:00
Kaj Schittecat b3af35666b Merge PR #15 (tun0): fix inaccurate unread badges + split history persistence
- closeChatPanel marks-read + flags dirty immediately (no stale badge)
- post-load reconciliation clears phantom unread for evicted threads
- split thread-meta (200ms flush) vs message-ring (lazy 2s) persistence
- ring 96 -> 500
2026-06-18 10:58:08 +02:00
Kaj SchittecatandClaude Opus 4.8 eca5281f22 touch: post-beta_6 batch — BLE PIN, wallpaper-from-files, storage & memory fixes
- BLE: user-settable 6-digit pairing code (Settings -> Bluetooth), MyMesh::setBLEPin.
- Lock wallpaper: pick via the file manager (fast, any folder) + 'Set as wallpaper'
  in the image viewer; downscaling JPEG decoder accepts images > 1024 px and keeps
  the held buffer small; clear message for progressive JPEGs.
- Map tiles: validate JPEG magic before caching + re-fetch bad cached tiles
  (fixes black quadrants).
- Channel @reply: quick-reply now appends at the cursor instead of wiping the
  mention.
- Audio: heap pre-flight before i2s_driver_install (fixes the esp_timer NO_MEM
  abort when toggling sound under BLE+Wi-Fi memory pressure).
- App drawer: squircle tiles. Log hygiene: CORE_DEBUG_LEVEL 1->0 (mute benign
  vfs/Preferences spam off the USB-CDC companion stream).
- Core bump to core-v1.16.4 (drops the 4-byte chat-payload trailer, issue #13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:57:50 +02:00
Ruben LabanandClaude Sonnet 4.6 3a70a8ab0f touch: fix legacy history migration when ring size grew
loadLegacyHistoryFromStorage() tried to read MAX_UI_MESSAGES (now 500)
records from a file that only contained 96 (old ring size). It read the
thread metadata fine — populating unread counts — then hit EOF on the
messages and returned false. The reconciliation pass that resets phantom
unread counts never ran, so the first boot after the ring bump showed
high unread counters against empty channels.

Fix: derive the actual slot count from the remaining file size and only
read that many message records, zero-filling the rest. The phantom-badge
reconciliation in loadHistoryFromStorage() then clears any thread whose
messages were already evicted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:45:30 +02:00
Ruben LabanandClaude Sonnet 4.6 2894776a11 touch: split history save into threads+msgs files; bump ring to 500
Thread metadata (~4 KB, contains unread counts) and the message ring
(now ~115 KB at 500 slots) are now persisted to separate files
(/ui_threads_v1.bin and /ui_msgs_v1.bin).

Thread metadata flushes on the original 200 ms delay so unread counts
survive a hard reset. The message ring flushes lazily every 2 s,
batching burst arrivals into a single write instead of hammering SPIFFS
on every message — reducing flash wear and WdtHeavyGuard exposure as
the ring grows.

MAX_UI_MESSAGES 96 → 500 (PSRAM cost: 26 KB → 133 KB, well within
budget). Ring overflow within a session is now extremely unlikely on
LoRa; the cross-session phantom-badge fix (post-load reconciliation)
from the previous commit handles the remaining edge case.

Migration: on first boot the loader falls back to the legacy combined
file (/ui_chat_history_v1.bin), then schedules writes of the new split
files. Old file is left in place and ignored on subsequent boots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:31:33 +02:00
Ruben LabanandClaude Sonnet 4.6 88a9d386f1 touch: reset phantom unread on load when ring has no messages for thread
The display ring is a fixed-size cache independent of the per-thread
unread counter. After a reboot the ring may not contain the messages
that were counted as unread (they were evicted before the save), leaving
a non-zero badge on a thread that opens to an empty chat.

Fix: after loading history, zero the unread counter for any thread whose
messages are absent from the ring. The unread counter and the ring are
independent — the ring is a display buffer only — so opening the thread
via the first fix (markThreadRead on close) handles the mid-session case;
this one-shot check handles the cross-reboot case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:05:36 +02:00
Ruben Laban a6647dcf55 Revert "touch: fix phantom unread badge when ring evicts channel messages"
This reverts commit 28879f55c4.
2026-06-18 09:04:37 +02:00
Ruben LabanandClaude Sonnet 4.6 28879f55c4 touch: fix phantom unread badge when ring evicts channel messages
The 96-slot message ring can overflow when there is sustained traffic
across many threads. When ring wraparound silently overwrites a channel's
messages, thread.unread stays non-zero but getThreadMessageIndexes
returns 0 — producing a badge with a count but an empty chat on open.
The same phantom persists across reboots because unread is saved to
disk independently of the ring contents.

Fix has three parts:

1. counted_as_unread per UIMessage (RAM-only): set true when a message
   is the reason a thread's unread counter was incremented. Cleared when
   the thread is marked read, so future evictions of those slots don't
   double-decrement.

2. Eviction hook in appendMessage: before overwriting a ring slot,
   decrement its thread's unread if the slot was counted_as_unread.
   Keeps the counter in sync as the ring wraps.

3. Post-load reconciliation in loadHistoryFromStorage: after restoring
   thread+message state from disk, any thread whose persisted unread
   count exceeds its actual incoming messages in the ring gets clamped
   (or reset to 0 if the ring has nothing for it). Also reconstructs
   the counted_as_unread flags so evictions after boot are tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 08:34:37 +02:00
Ruben LabanandClaude Sonnet 4.6 d16c833171 touch: fix stale unread badge on per-channel/DM inbox rows
Two related causes:

1. Messages arriving while a chat is open increment unread (appendMessage
   always passes mark_unread=true). closeChatPanel never called
   markThreadRead, so those in-session messages left a phantom badge in
   the inbox after the user closed the chat.

2. Neither threadSelectCb (open) nor closeChatPanel (close) set
   dirty_threads, so the inbox list could lag up to 4 s (the periodic
   refreshThreadsFromMesh tick) before reflecting a cleared count.

Fix: call markThreadRead on the active thread in closeChatPanel and set
dirty_threads so the inbox rebuilds immediately on return.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 08:19:32 +02:00
Dan Vybiral 031cb03bd4 battery chart on V4 (SPIFFS fallback); telemetry log under /meshcomod/telemetry
Battery log/chart now compile on both builds: a small FS selector prefers the SD
card when present (T-Deck, /meshcomod/battery.log) and falls back to internal
SPIFFS (/battery.log) otherwise, so the 24h chart + battery tap work on the
Heltec V4 (no SD slot) too. The 5-min logger and the battery tap-wiring are no
longer gated behind HAS_TDECK_GT911.

Telemetry per-node logs + poll.cfg move from /telemetry to /meshcomod/telemetry
to sit with the other app files. Telemetry logging/window/auto-poll stay
T-Deck-only (V4 keeps the request/reply toast).

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 14:59:30 +02:00
Dan Vybiral 5ec9176906 file browser: fix stale image when viewing several in a row
s_fm_img_dsc is a reused static, so LVGL's image cache (keyed on the src pointer)
returned the previous image's decoded geometry/data — the 'half old, half new'
render. Invalidate the cache entry when building the dsc and on close.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 14:42:50 +02:00
Dan Vybiral ed9cf619ec map: invert status bar + tab bar chrome under night mode
On the map tab the immersive chrome forces black text/icons for legibility over
the light tiles. With night mode on the tiles are dark, so flip the status-bar
text and the bottom tab-bar icons to off-white instead. Only affects the map tab;
re-applied immediately when night mode is toggled.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 14:32:18 +02:00
Dan Vybiral 460b82f9bc file browser: view PNG and BMP images, not just JPEG
The viewer already routed image taps to fmOpenImage but only ran the JPEG
decoder and bailed on PNG with a stale 'not supported' note. Sniff the magic
bytes and route PNG -> decodePngToRgb565 (lodepng, already used for map tiles)
and BMP -> a small 16/24-bpp reader (so screenshots saved by the status-bar hold
are viewable). fmIsImage now also accepts .bmp.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 14:30:36 +02:00
Dan Vybiral 0a4d8d5b2f map: live zoom-level readout above the zoom slider
A 'zoom <level>' chip, centred above the slider, appears with it and updates
live while dragging so the target level is visible before release.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 14:23:21 +02:00
Dan Vybiral e72a1b1bd5 map: real magnifier glyph on the zoom button
Add a one-glyph FontAwesome font (zoom_font.c, U+F002 magnifying-glass, 16 px bpp4,
generated with lv_font_conv from fa-solid-900.ttf) and splice it into the g_font_16
fallback chain alongside person_font. The map zoom button now shows a magnifier
(TOUCH_SYM_ZOOM) instead of the borrowed + glyph.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 13:56:37 +02:00
Dan Vybiral 30fe6fbd6f map/telemetry/battery/snake: visibility, zoom slider, night mode, screenshot
Battery chart: stats line spans the full width so the CPU MHz figure no longer
wraps; the CPU series + chip now use the RF-monitor noise-floor grey (0x4A5256).

Telemetry: the window no longer auto-requests on open — it shows history and a
Request button (async refresh glyph, left of the gear) sends a poll. Legend text
recoloured to the axis-note grey instead of near-black.

Map: the + / - overlay buttons are replaced by a single zoom button that toggles
a full-width zoom slider along the bottom of the map (tap again to hide); the
chosen level is persisted across reboots and wins over the first-open auto-snap.
New 'Night mode (invert)' toggle in Map options inverts tile colours at render
time only (cache untouched), persisted.

Snake: pause/resume button next to the close X.

Status bar: hold for 3 s to capture a full-screen screenshot to
/screenshots/<time>.bmp on the SD card (16-bit BMP — fastest viewable format;
the hold suppresses the control-center tap).

Prefs: cfg v5 adds map_night + map_zoom.
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 13:47:38 +02:00
Dan Vybiral 5e8dd55847 battery chart: make the window scrollable; lay the bottom block out top-down
The card was a fixed 246 px — taller than the screen — and not scrollable, so its
bottom-anchored estimate/stats/points labels were clipped and overlapped (the
narrower text width also let the estimate wrap onto the stats line). Switch the
card to content height with a max-height cap + vertical scrolling, and place the
chart, the three text lines and the CPU/trash buttons with a running y so they
stack cleanly and the window scrolls when it doesn't fit.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 13:19:39 +02:00
Dan Vybiral 26ae08101c telemetry/battery: data-visibility toggles + telemetry settings panel
Battery chart: a CPU chip beside the trash button shows/hides the CPU-MHz
series; the secondary axis, series and bottom-line CPU figure are dropped when
hidden and the chart redraws immediately.

Telemetry window: a gear opens a settings panel spawned on top with checkboxes
for the battery / temperature / humidity series (the chart underneath redraws
live as they toggle), a manual auto-poll interval field (minutes, 0 = off,
replacing the cycling button) and the clear-history button. The main window now
holds only the reading, chart and time-range inputs; its layout was reworked so
the chart clears the wrapped reading line and the time-range fields no longer
overlap their labels.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 13:05:07 +02:00
Dan Vybiral aa89572631 chore: gitignore custom build outputs and local notes
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 13:05:07 +02:00
Dan Vybiral ff5818f7f7 battery chart: stack bottom text clear of the trash button; dim the CPU line
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 12:48:14 +02:00
Dan Vybiral be7fb32f23 battery chart: estimated life left (d/h/m) from the discharge slope, skipping charge
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 12:43:02 +02:00
Dan Vybiral 17981029a7 telemetry: per-node window with battery/temp/humidity chart, auto-poll, clear
- Manual telemetry opens a per-node window immediately (request-in-progress),
  then updates to the reading on reply or "last request failed" on timeout — the
  window shows history either way (issue: see history even on failure).
- Chart from /telemetry/<id>.log: battery V (left) + temperature/humidity (right),
  RF-monitor-style axis ticks + grid, point markers.
- Display window chosen with two fields (now-X h .. now-Y h ago); "Show" reloads.
- Auto-poll per node (toggle + 1/5/15/30/120 min), persisted to /telemetry/poll.cfg;
  due nodes are polled from UITask::loop — emits the async spinner, logs the reply,
  does NOT open the window.
- Clear-history buttons (with confirmation) on both the telemetry and battery windows.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 11:51:04 +02:00
Dan Vybiral 9bc9822111 telemetry: log each reply (battery/temp/humidity) to /telemetry/<id>.log per node
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 11:38:19 +02:00
Dan Vybiral 799a3754d0 battery chart: clamp charging spikes to top; RF-monitor-style axis ticks + grid
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 11:35:06 +02:00
Dan Vybiral c9e3de15d4 status bar: park the async spinner left of the clock so it never overlaps it
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 10:27:34 +02:00
Dan Vybiral be1c72fbb1 status bar: SD LED also covers file-manager reads + cross-volume copies
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 10:21:55 +02:00
Dan Vybiral b478851a21 status bar: async-request spinner (telemetry/admin/ping/trace/range/sightline)
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 10:18:42 +02:00
Dan Vybiral 8ce6baf786 battery chart: blue series, calibrated range, visible points, per-point value labels
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 10:14:08 +02:00
Dan Vybiral b82f4a2363 apps: floor the drawer tile height so a full grid scrolls instead of shrinking
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 10:04:16 +02:00
Dan Vybiral 6e62d35328 contacts: add "Show on map" to the action sheet (centre the map on the node)
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:45 +02:00
Dan Vybiral fe8925f91d apps: Snake review fixes — trackball, New-game gate, full field, tab-bar lock
- trackball now steers Snake (UITask routes motion via SnakeGame::isOpen()/steer()
  and hides the soft cursor on that page).
- a "New game" button gates the start so it doesn't move on open; tap restarts
  after a game over.
- slower tick (220 -> 300 ms).
- playfield fills the unoccupied screen (dynamic cols x rows at kCellPx instead of
  a small fixed square).
- the tab bar is hard-locked while Snake is open so a stray swipe/tap can't slip
  through to the app switcher (e.g. into the map).

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:44 +02:00
Dan Vybiral 7463530f63 ui: review fixes — contacts sort, battery chart, close buttons, clock centring
- contacts: the "?" (unknown last-heard) sink-to-end now mirrors formatAgeBadge's
  exact condition (also future/garbage/>400-day timestamps, not just ==0), using
  the clock captured before the sort. Fixes "no change in behaviour".
- battery chart: add a universal close X (addCloseXBadge); lighter grid + time
  (vertical) division lines; axis notes (V left, MHz right, time on X); point
  dots so a single/sparse sample renders; second ORANGE CPU line on a secondary
  Y axis. battery.log gains a CPU-MHz column (5th field); pre-upgrade 4-col lines
  read as a CPU gap.
- telemetry window: add the same universal close X.
- hide device name: park the clock CENTRED on every screen (not just Home),
  clear of the left-zone title (chat / Files / map credit) and the right icons.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:44 +02:00
Dan Vybiral d7151dbbf8 apps: extract Snake into a self-contained SnakeGame class (own files)
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:44 +02:00
Dan Vybiral dd67336fdb apps: add a Snake game (canvas + swipe to steer)
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:43 +02:00
Dan Vybiral 13b240062a telemetry: show the response in a dismissible window instead of a toast
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:04 +02:00
Dan Vybiral 9ddb08ba93 status bar: tap battery for a 24h history chart, logged to SD every 5 min
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:03 +02:00
Dan Vybiral 373be3e546 settings: option to hide the device name and park the clock left
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:03 +02:00
Dan Vybiral d2e776afe9 status bar: fix charging-shift offsets to match the SD-LED layout
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:03 +02:00
Dan Vybiral a5968afb70 status bar: light the SD LED on file-manager microSD access too
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:03 +02:00
Dan Vybiral 7aeee1f456 map: add a zoom + tile-path line under the OSM attribution
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:02 +02:00
Dan Vybiral ed72f15a81 about: tag this fork's builds with an "unofficial fork build" note
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:02 +02:00
Dan Vybiral 59896b6bdb about: show microSD card size + free space in the storage section
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:02 +02:00
Dan Vybiral 5ff7590168 chat: show hop count on received flood messages (next to the time)
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:01 +02:00
Dan Vybiral 00f2f70800 contacts: sort unknown last-heard ("?") to the end of the list
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-17 09:54:01 +02:00
Dan Vybiral fdaf34575e status bar: microSD read/write activity LED left of Wi-Fi
A small amber dot in the status bar (just left of the Wi-Fi glyph) lights
for ~180 ms after any microSD access: SD-backed tile cache, SD tile packs,
and card mount. markSdIo() stamps a timestamp on the I/O path (one 32-bit
store, safe from either core); UITask::loop toggles the dot edge-triggered,
so an idle device pays a single comparison per loop and never relayouts.
The ble / clock / keyboard-layout labels shift left to free the slot.
2026-06-17 09:54:01 +02:00
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
26 changed files with 8684 additions and 831 deletions

No files matched your search

+6
View File
@@ -28,3 +28,9 @@ scripts/build/wifi_secrets.h
# Local working notes + agent config (contain ops/infra details — never publish) # Local working notes + agent config (contain ops/infra details — never publish)
CLAUDE.md CLAUDE.md
.claude/ .claude/
# Custom timestamped build outputs
/builds/
# Local reconstruction notes (scratch, never publish)
/.notes/
+69 -13
View File
@@ -2,14 +2,29 @@
# #
# The firmware fetches plain HTTP (on-device HTTPS isn't workable: mbedTLS wants # 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 # ~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 # this vhost is HTTP-only by design. Cloudflare sits in front and caches results
# .png tiles at the edge; this origin keeps a second 14-day disk cache. # at the edge; this origin keeps a second 14-day disk cache.
# #
# Deploy: copy to /etc/nginx/sites-available/, symlink into sites-enabled/, then # The device also can only decode JPEG cheaply (SJPG/TJpgDec -> RGB565 in
# nginx -t && systemctl reload nginx # stripes); PNG decode (lodepng) needs a 256 KB ARGB8888 buffer per tile. OSM
# Cloudflare: tiles.wadamesh.com proxied (orange cloud), SSL/TLS mode "Flexible" # only serves PNG, so the device requests `.jpg` and a local transcode service
# (CF<->origin HTTP), and a Cache Rule: Eligible for cache + Edge TTL ~14d for # (tile-transcode.py on 127.0.0.1:5005) fetches the OSM PNG and re-encodes it as
# path "/*". Do NOT enable "Always Use HTTPS" on this hostname. # 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 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; keys_zone=wadamesh_tiles:10m max_size=4g inactive=14d use_temp_path=off;
@@ -19,20 +34,55 @@ server {
listen [::]:80; listen [::]:80;
server_name tiles.wadamesh.com; server_name tiles.wadamesh.com;
# Re-resolve the upstream at request time. A literal hostname in proxy_pass # Re-resolve the OSM upstream (for the .png passthrough) at request time. A
# fails with "no resolver defined" on a fresh box; a variable + resolver works. # 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 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s; resolver_timeout 5s;
# The location regex is DOUBLE-QUOTED on purpose: nginx's lexer otherwise # Primary path the firmware uses: /{z}/{x}/{y}.jpg -> local transcode service
# treats { and } (quantifiers) as block delimiters and refuses to start. # (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$" { location ~ "^/(\d{1,2})/(\d{1,7})/(\d{1,7})\.png$" {
set $osm_upstream "tile.openstreetmap.org"; set $osm_upstream "tile.openstreetmap.org";
proxy_pass https://$osm_upstream/$1/$2/$3.png; proxy_pass https://$osm_upstream/$1/$2/$3.png;
proxy_ssl_server_name on; proxy_ssl_server_name on;
proxy_set_header Host tile.openstreetmap.org; 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 User-Agent "wadamesh-tile-proxy/1.0 (+https://wadamesh.com)";
proxy_set_header Referer "https://wadamesh.com/"; proxy_set_header Referer "https://wadamesh.com/";
@@ -45,5 +95,11 @@ server {
expires 14d; 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; } location / { return 404; }
} }
+24 -3
View File
@@ -37,14 +37,25 @@
.wrap{max-width:1040px; margin:0 auto; padding:0 22px} .wrap{max-width:1040px; margin:0 auto; padding:0 22px}
header{padding:74px 0 30px; text-align:center} 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; 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} font-family:'JetBrains Mono',monospace; background:#fff; transition:border-color .15s, color .15s}
.kicker:hover{border-color:var(--teal); color:var(--ink)} .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 .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{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)} .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 */ /* animated mesh mark — repeaters broadcast pings; signal packets ride the trails */
.brand .mark{overflow:visible} .brand .mark{overflow:visible}
.wm-flow{display:none} .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} .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} .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 */ /* 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; 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> <body>
<div id="vanta-bg"></div> <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> <header>
<div class="wrap"> <div class="wrap">
<a class="kicker" href="https://github.com/ALLFATHER-BV/wadamesh" target="_blank" rel="noopener">open source · GPL-3.0</a> <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 --> <!-- wadamesh brand: mark + wordmark, flex-centered -->
<div class="brand"> <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"> <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> <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> </svg>
<span class="word mono">WADA<span class="t">MESH</span></span> <span class="word mono">WADA<span class="t">MESH</span></span>
<span class="byline">by meshcomod</span>
</div> </div>
<p class="lede">A real touchscreen UI for your mesh radio.</p> <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> <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
+14 -4
View File
@@ -146,6 +146,7 @@ build_flags =
-D DISPLAY_CLASS=ST7789LCDDisplay -D DISPLAY_CLASS=ST7789LCDDisplay
-D ADMIN_PASSWORD='"password"' -D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1 -D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000 -D TCP_PORT=5000
-D WS_PORT=8765 -D WS_PORT=8765
-D BLE_PIN_CODE=123456 -D BLE_PIN_CODE=123456
@@ -166,7 +167,7 @@ lib_deps =
; MeshCore core = the meshcomod monorepo built as a lib, pinned by tag. ; MeshCore core = the meshcomod monorepo built as a lib, pinned by tag.
; Touch-app files (TouchPrefsStore, WifiRuntimeStore, transports, ...) are ; Touch-app files (TouchPrefsStore, WifiRuntimeStore, transports, ...) are
; vendored in this app's src/ and dropped from the lib via -DMC_VENDORED_TOUCH_APP. ; 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.4
SPI SPI
Wire Wire
jgromes/RadioLib @ ^7.6.0 jgromes/RadioLib @ ^7.6.0
@@ -238,7 +239,10 @@ build_flags =
-I variants/lilygo_tdeck -I variants/lilygo_tdeck
-D LILYGO_TDECK -D LILYGO_TDECK
-D BOARD_HAS_PSRAM=1 -D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1 ; 0 = silence ARDUHAL [E] spam (benign vfs_api cache-miss "no permits for
; creation" + Preferences nvs_open NOT_FOUND on first-namespace-access) that
; also pollutes the USB-CDC companion stream. Our own Serial.printf logs stay.
-D CORE_DEBUG_LEVEL=0
-D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0 -D PIN_USER_BTN=0
-D PIN_PERF_POWERON=10 -D PIN_PERF_POWERON=10
@@ -246,7 +250,12 @@ build_flags =
-D RADIO_CLASS=CustomSX1262 -D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper -D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22 -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_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1 -D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f -D SX126X_DIO3_TCXO_VOLTAGE=1.8f
@@ -307,6 +316,7 @@ build_flags =
-D DISPLAY_CLASS=ST7789LCDDisplay -D DISPLAY_CLASS=ST7789LCDDisplay
-D ADMIN_PASSWORD='"password"' -D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1 -D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000 -D TCP_PORT=5000
-D WS_PORT=8765 -D WS_PORT=8765
-D BLE_PIN_CODE=123456 -D BLE_PIN_CODE=123456
@@ -324,7 +334,7 @@ build_src_filter =
+<../variants/lilygo_tdeck/*.cpp> +<../variants/lilygo_tdeck/*.cpp>
lib_deps = lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.2 https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.4
SPI SPI
Wire Wire
jgromes/RadioLib @ ^7.6.0 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)
+48
View File
@@ -0,0 +1,48 @@
# beta_7 — what's new (one user-facing note per non-blank, non-# line)
# --- Community-built ---
Most of beta_7 was contributed by the community — Dan Vybiral (Vybo) and Ruben Laban. Thank you both!
# --- Chat reliability ---
Fixed garbled and duplicated messages — chat text is no longer corrupted in transit, so messages arrive intact and duplicates are filtered out correctly (thanks to marcelverdult for the report, issue #13)
Unread badges are accurate again: the phantom and stuck unread counts on chats and channels are fixed (Ruben Laban, PR #15)
More of your chat history is kept and reloaded across reboots, and it is saved more reliably than before (Ruben Laban, PR #15)
# --- New: Snake game ---
A Snake game is now in the app drawer — steer by swiping the screen or with the T-Deck trackball, with pause/resume and restart after a game over (Vybo, PR #12)
While you play, touch stays inside the game and no longer leaks through to the screen behind it
# --- Battery & telemetry ---
Tap the battery in the status bar for a 24-hour history chart with an estimated time-left readout; battery level is logged every 5 minutes (Vybo, PR #12)
New per-node telemetry window: poll a node's battery, temperature and humidity, watch them on a live chart, and keep a per-node log (Vybo, PR #12)
# --- Map ---
New map night mode — the status bar and tab bar invert to match the dark map instead of glaring (Vybo, PR #12)
A live zoom-level readout sits above the zoom slider, and the zoom button now shows a proper magnifier icon (Vybo, PR #12)
Contacts have a new "Show on map" action that centres the map on that node (Vybo, PR #12)
# --- Status bar & tools ---
A microSD activity LED lights whenever the card is accessed, including file-manager reads and copies (Vybo, PR #12)
An activity spinner shows while a telemetry, admin, ping, trace, range or sightline request is in flight, kept clear of the clock (Vybo, PR #12)
New option to hide the device name and move the clock to the left (Vybo, PR #12)
Press and hold the status bar to save a full-screen screenshot to the SD card (Vybo, PR #12)
# --- Files & images ---
The file browser can preview PNG and BMP images now, not just JPEG, and no longer shows a stale image when you open several in a row (Vybo, PR #12)
Lock-screen wallpaper: open Files, view any JPEG on your SD card, and tap "Set as wallpaper" — no more slow scan or "no image found"
# --- App drawer ---
The app drawer fits all the apps again — the bottom row's labels are no longer cut off
A scrollbar clearly shows when there are more apps than fit, so it is obvious the page scrolls
# --- Bluetooth ---
You can set your own Bluetooth pairing code in Settings now, instead of being stuck with the default
# --- Storage & stability ---
Fixed map tiles failing to cache (with a false "storage full"), and black or missing tiles now re-download instead of staying blank
Replying to a message with @ no longer overwrites text you were already typing
Fixed an out-of-memory crash and made notification audio more reliable
# --- Community ---
Huge thanks to Dan Vybiral (Vybo) — most of beta_7 is his work: the Snake game, battery history, per-node telemetry, map night mode, PNG/BMP image viewer and the whole status-bar toolkit (PR #12)
Thanks to Ruben Laban for the accurate unread-badge and chat-history-persistence fixes (PR #15)
+16
View File
@@ -0,0 +1,16 @@
# beta_8 — what's new (one user-facing note per non-blank, non-# line)
# --- Chat ---
Quick replies now insert at the cursor instead of wiping whatever you'd already typed — tap one mid-message and it's added in place
Long-press any message for a new one-tap "Ack": it drops a ready-to-send reply into the composer with that message's signal strength (SNR/RSSI) and the route it took, so you can instantly confirm someone's test came through — on a channel it @mentions the sender
Fixed the message menu (Ack / Copy / Info / Mention / Block) running off the bottom of the screen — it now fits, and scrolls if it ever needs to
# --- Map ---
The zoom button now shows a clear "+/-" icon
Each on-map readout can be shown or hidden independently in Map settings — the coordinates line, the tile x/y/z line, and the contact markers
Map settings are reorganised: the microSD-tiles switch sits at the top under its own heading, separated from the new show/hide toggles
New auto-refresh button: turn it on and the map re-centres on you automatically whenever your position changes, with an on-screen "Auto-refresh on/off" confirmation
Fixed the close (X) in Map settings overlapping the switches when the panel was scrolled
# --- Performance ---
Freed about 19 KB of internal memory, leaving more headroom for Wi-Fi and trimming the idle-memory growth seen in recent betas
+78 -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) { 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) #if defined(DISPLAY_CLASS)
// Diagnostic: log EVERY received frame so we can prove what reaches the // Diagnostic: log EVERY received frame so we can prove what reaches the
// radio. Header byte layout is // 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 rt == ROUTE_TYPE_TRANSPORT_DIRECT); // header iff route is a TRANSPORT_* one
const int ps = 1 + (has_xp ? 4 : 0); const int ps = 1 + (has_xp ? 4 : 0);
if (ps < len) { if (ps < len) {
const uint8_t pb = raw[ps]; const uint8_t pb = raw[ps];
const int poff = ps + 1 + (pb & 0x3F) * (((pb >> 6) & 0x03) + 1); const uint8_t cnt_p = pb & 0x3F;
if (poff < len) uiCountEcho(fnv1a32(raw + poff, len - poff)); 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 #endif
@@ -2467,7 +2492,12 @@ void MyMesh::begin(bool has_display) {
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (has_display && BLE_PIN_CODE == 123456) { if (has_display && BLE_PIN_CODE == 123456) {
StdRNG rng; 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 { } else {
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin _active_ble_pin = BLE_PIN_CODE; // otherwise static pin
} }
@@ -2523,6 +2553,20 @@ NodePrefs *MyMesh::getNodePrefs() {
uint32_t MyMesh::getBLEPin() { uint32_t MyMesh::getBLEPin() {
return _active_ble_pin; return _active_ble_pin;
} }
// User-chosen pairing code from the touch BLE settings page. Validated to a
// 6-digit value, persisted to _prefs.ble_pin, and applied at the next boot
// (begin() seeds _active_ble_pin from it) — same contract as the companion
// CMD_SET_DEVICE_PIN. Returns false on an out-of-range PIN.
bool MyMesh::setBLEPin(uint32_t pin) {
// Any 6-digit BLE passkey is valid, INCLUDING ones that start with 0 (e.g.
// "012345" == 12345) — the UI validates the 6-digit string and displays it
// zero-padded. Only reject 0 (the "use default/random" sentinel) and values
// that don't fit in 6 digits.
if (pin == 0 || pin > 999999) return false;
_prefs.ble_pin = pin;
savePrefs();
return true;
}
struct FreqRange { struct FreqRange {
uint32_t lower_freq, upper_freq; uint32_t lower_freq, upper_freq;
@@ -3694,6 +3738,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) } 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; send_unscoped = true;
writeOKFrame(); 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) { } else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) {
auto resp = createControlData(&cmd_frame[1], len - 1); auto resp = createControlData(&cmd_frame[1], len - 1);
if (resp) { if (resp) {
+118 -5
View File
@@ -113,6 +113,7 @@ public:
const char *getNodeName(); const char *getNodeName();
NodePrefs *getNodePrefs(); NodePrefs *getNodePrefs();
uint32_t getBLEPin(); uint32_t getBLEPin();
bool setBLEPin(uint32_t pin); // user-chosen 6-digit pairing code (persisted; applies next boot)
// Live device info accessors (used by the touch Settings → Device modal to // Live device info accessors (used by the touch Settings → Device modal to
// mirror the web client's "Device (live)" panel — public key prefix, channel // mirror the web client's "Device (live)" panel — public key prefix, channel
@@ -382,6 +383,10 @@ public:
static const int UI_ECHO_SLOTS = 12; static const int UI_ECHO_SLOTS = 12;
uint32_t _echo_fp[UI_ECHO_SLOTS] = {0}; uint32_t _echo_fp[UI_ECHO_SLOTS] = {0};
uint8_t _echo_rep[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; uint8_t _echo_idx = 0;
uint32_t _last_sent_fp = 0; uint32_t _last_sent_fp = 0;
uint8_t _last_rx_path[32] = {0}; uint8_t _last_rx_path[32] = {0};
@@ -390,6 +395,51 @@ public:
bool _last_rx_has_scope = false; // false if the packet carried no transport codes 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 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). */ /** Fingerprint of the most-recently originated flood TXT payload (0 if none). */
uint32_t uiLastSentFp() const { return _last_sent_fp; } uint32_t uiLastSentFp() const { return _last_sent_fp; }
@@ -401,6 +451,25 @@ public:
return 0; 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 /** Path (repeater hashes) of the most-recently received flood; copies up to
* `max` bytes into buf, returns the count. Read synchronously from the * `max` bytes into buf, returns the count. Read synchronously from the
* newMsg* handler that follows reception. */ * newMsg* handler that follows reception. */
@@ -430,20 +499,64 @@ public:
void uiTrackSentFp(uint32_t fp) { void uiTrackSentFp(uint32_t fp) {
if (fp == 0) return; if (fp == 0) return;
_last_sent_fp = fp; _last_sent_fp = fp;
for (int i = 0; i < UI_ECHO_SLOTS; i++) if (_echo_fp[i] == fp) { _echo_rep[i] = 0; return; } 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_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); _echo_idx = (uint8_t)((_echo_idx + 1) % UI_ECHO_SLOTS);
} }
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). */ /** Count one echo of fingerprint `fp` (called from logRxRaw on a match). `hop`
void uiCountEcho(uint32_t fp) { * (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++) 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 /** 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. */ * to refresh the chat so the bubble's repeat tag updates live. */
bool takeEchoDirty() { bool d = _echo_dirty; _echo_dirty = false; return d; } 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). /** 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 * Returns the trace tag we chose (non-zero on success) so the UI can
* match the onTracePingResult callback. The trace path is just the * match the onTracePingResult callback. The trace path is just the
+191 -53
View File
@@ -1,43 +1,86 @@
#include "SdNvsPrefs.h" #include "SdNvsPrefs.h"
#if defined(ESP32) #if defined(ESP32)
#include <FS.h>
#include <SD.h> #include <SD.h>
#include <SPIFFS.h>
#include <string.h> #include <string.h>
// Backend is probed ONCE per boot and cached for every namespace/instance: a // ----------------------------- backend selection -----------------------------
// device with healthy NVS keeps using NVS; a Launcher install (NVS full) routes // File mode (set by SdNvsPrefs::useFile at boot): every namespace lives in a flat
// everything to /meshcomod/<ns>.kv on the SD card. // <dir>/<ns>.kv file on the chosen filesystem; NVS is read-only (migration only).
static int s_backend = -1; // -1 undecided, 0 = SD, 1 = NVS // 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() { bool SdNvsPrefs::useNvs() {
if (s_backend < 0) { if (s_legacy < 0) {
Preferences probe; Preferences probe;
bool ok = false; bool ok = false;
if (probe.begin("mc_kvprobe", false)) { // RW open must succeed... if (probe.begin("mc_kvprobe", false)) { ok = (probe.putUChar("v", 1) > 0); probe.end(); }
ok = (probe.putUChar("v", 1) > 0); // ...and a real write must land s_legacy = ok ? 1 : 0;
probe.end(); Serial.printf("[PREFS] legacy backend = %s\n", ok ? "NVS" : "SD /meshcomod");
}
s_backend = ok ? 1 : 0;
Serial.printf("[PREFS] backend = %s\n", ok ? "NVS" : "SD /meshcomod");
} }
return s_backend == 1; return s_legacy == 1;
} }
// ----------------------------- begin / end ----------------------------- // ----------------------------- begin / end -----------------------------
bool SdNvsPrefs::begin(const char* ns, bool readOnly) { bool SdNvsPrefs::begin(const char* ns, bool readOnly) {
_read_only = 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; } if (useNvs()) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; }
snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns); snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns);
if (!_sd_loaded) { sdLoad(); _sd_loaded = true; } sdLoad();
return true; // SD store is always "open" _sd_loaded = true;
return true;
} }
void SdNvsPrefs::end() { void SdNvsPrefs::end() {
if (useNvs() && _nvs_open) { _nvs.end(); _nvs_open = false; } if (_nvs_open) { _nvs.end(); _nvs_open = false; }
// SD mode keeps its RAM mirror across begin/end (flushed on every put).
} }
// ----------------------------- SD helpers ----------------------------- // ----------------------------- file helpers -----------------------------
SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) { SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) {
for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e; for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e;
return nullptr; return nullptr;
@@ -57,13 +100,12 @@ void SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) {
void SdNvsPrefs::sdLoad() { void SdNvsPrefs::sdLoad() {
_sd.clear(); _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; if (!f) return;
// record = [keylen u8][key bytes][vallen u16 LE][val bytes] // record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Sanity caps stop a
// Sanity caps stop a corrupt/garbage file from triggering a huge allocation or // corrupt file from a huge alloc; on any bad record we stop and keep what loaded.
// 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.
while (f.available() > 0 && _sd.size() < 256) { while (f.available() > 0 && _sd.size() < 256) {
int kl = f.read(); int kl = f.read();
if (kl <= 0 || kl > 15) break; if (kl <= 0 || kl > 15) break;
@@ -72,7 +114,7 @@ void SdNvsPrefs::sdLoad() {
int lo = f.read(), hi = f.read(); int lo = f.read(), hi = f.read();
if (lo < 0 || hi < 0) break; if (lo < 0 || hi < 0) break;
size_t vl = (size_t)lo | ((size_t)hi << 8); 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); std::vector<uint8_t> v(vl);
if (vl && f.read(v.data(), vl) != (int)vl) break; 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'; 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() { void SdNvsPrefs::sdSave() {
if (_read_only) return; // mirror Preferences: a write needs a RW begin if (_read_only) return;
SD.mkdir("/meshcomod"); fs::FS* fs = activeFs();
File f = SD.open(_path, FILE_WRITE); // "w" — truncate + rewrite the whole file 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; if (!f) return;
for (auto& e : _sd) { for (auto& e : _sd) {
size_t kl = strnlen(e.key, sizeof(e.key)); 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 ----------------------------- // ----------------------------- 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) { bool SdNvsPrefs::isKey(const char* key) {
if (useNvs()) return _nvs_open && _nvs.isKey(key); if (fileMode()) return sdFind(key) != nullptr || (_nvs_open && _nvs.isKey(key));
return sdFind(key) != nullptr; return useNvs() ? (_nvs_open && _nvs.isKey(key)) : (sdFind(key) != nullptr);
} }
bool SdNvsPrefs::remove(const char* key) { 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++) 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; } if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) == 0) { _sd.erase(_sd.begin() + i); sdSave(); return true; }
return false; return false;
} }
bool SdNvsPrefs::clear() { 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(); if (useNvs()) return _nvs_open && _nvs.clear();
_sd.clear(); sdSave(); return true; _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); } uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) {
size_t SdNvsPrefs::putUChar (const char* k, uint8_t v) { return useNvs() ? _nvs.putUChar(k, v) : sdPutInt(k, v, 1); } if (fileMode()) {
int8_t SdNvsPrefs::getChar (const char* k, int8_t def) { return useNvs() ? _nvs.getChar(k, def) : (int8_t)sdGetInt(k, (uint8_t)def, 1); } if (sdFind(k)) return (uint8_t)sdGetInt(k, def, 1);
size_t SdNvsPrefs::putChar (const char* k, int8_t v) { return useNvs() ? _nvs.putChar(k, v) : sdPutInt(k, (uint8_t)v, 1); } if (_nvs_open && _nvs.isKey(k)) return _nvs.getUChar(k, def);
uint16_t SdNvsPrefs::getUShort(const char* k, uint16_t def) { return useNvs() ? _nvs.getUShort(k, def) : (uint16_t)sdGetInt(k, def, 2); } return def;
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); } return useNvs() ? _nvs.getUChar(k, def) : (uint8_t)sdGetInt(k, def, 1);
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::putUChar(const char* k, uint8_t v) {
size_t SdNvsPrefs::putBool (const char* k, bool v) { return useNvs() ? _nvs.putBool(k, v) : sdPutInt(k, v ? 1 : 0, 1); } 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) { String SdNvsPrefs::getString(const char* k, const String& def) {
if (useNvs()) return _nvs.getString(k, def); Kv* e = nullptr;
Kv* e = sdFind(k); if (fileMode()) {
if (!e) return def; 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()); String s; s.reserve(e->val.size());
for (uint8_t c : e->val) s += (char)c; for (uint8_t c : e->val) s += (char)c;
return s; return s;
} }
size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) { 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; 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; size_t n = e ? e->val.size() : 0;
if (n > maxLen - 1) n = maxLen - 1; if (n > maxLen - 1) n = maxLen - 1;
if (e && n) memcpy(buf, e->val.data(), n); 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; return n;
} }
size_t SdNvsPrefs::putString(const char* k, const char* v) { 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); if (useNvs()) return _nvs.putString(k, v);
size_t n = v ? strlen(v) : 0; size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n;
sdSet(k, (const uint8_t*)v, n);
return n;
} }
size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) { size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) {
if (useNvs()) return _nvs.getBytes(k, buf, maxLen); Kv* e = nullptr;
Kv* e = sdFind(k); if (fileMode()) {
if (!e) return 0; 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(); size_t n = e->val.size();
if (buf && maxLen) { size_t c = n > maxLen ? maxLen : n; memcpy(buf, e->val.data(), c); } if (buf && maxLen) { size_t c = n > maxLen ? maxLen : n; memcpy(buf, e->val.data(), c); }
return n; return n;
} }
size_t SdNvsPrefs::putBytes(const char* k, const void* buf, size_t len) { 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); if (useNvs()) return _nvs.putBytes(k, buf, len);
sdSet(k, (const uint8_t*)buf, len); sdSet(k, (const uint8_t*)buf, len); return len;
return len;
} }
#endif // ESP32 #endif // ESP32
+25 -15
View File
@@ -5,21 +5,31 @@
#include <Preferences.h> #include <Preferences.h>
#include <vector> #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 // File mode (set by SdNvsPrefs::useFile() once at boot, after the SD/SPIFFS
// partition table: there's no usable SPIFFS and the NVS partition is full // storage decision): every namespace lives in a flat <dir>/<ns>.kv file on the
// (`nvs_open failed: NOT_ENOUGH_SPACE`). Every Preferences open then fails, so // chosen filesystem — SD (/meshcomod) when a card is the active data store, else
// settings/Wi-Fi creds silently never persist (and a flood of [E] logs spams the // SPIFFS (/prefs). Nothing new is written to NVS; NVS is read-only, used only to
// console). The mesh DATA already falls back to the SD card (/meshcomod); this // migrate settings written by an older NVS build (they move to the file on their
// does the same for PREFS: it mirrors the subset of the Arduino Preferences API // next save). Legacy mode (before useFile, e.g. the early boot-rotation read):
// that TouchPrefsStore + WifiRuntimeStore use, delegating to NVS when NVS works // NVS if it works, else /meshcomod/<ns>.kv on SD — the previous behaviour.
// and to a flat /meshcomod/<ns>.kv file on the SD card when it doesn't.
// //
// The backend (NVS vs SD) is probed ONCE per boot and cached globally, so a // IMPORTANT: the monorepo lib ships a STALE copy of this header that other
// device with working NVS behaves exactly as before. // 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 { class SdNvsPrefs {
public: public:
static void useFile(fs::FS* fs, const char* dir); // route prefs to <dir>/<ns>.kv
bool begin(const char* ns, bool readOnly = false); bool begin(const char* ns, bool readOnly = false);
void end(); void end();
@@ -47,18 +57,18 @@ public:
size_t putBytes(const char* key, const void* buf, size_t len); size_t putBytes(const char* key, const void* buf, size_t len);
private: private:
// --- NVS-backed path --- // --- NVS-backed path --- (LAYOUT MUST MATCH the lib's stale header — see above)
Preferences _nvs; Preferences _nvs;
bool _nvs_open = false; bool _nvs_open = false;
bool _read_only = false; bool _read_only = false;
// --- SD-backed path --- // --- file-backed path ---
struct Kv { char key[16]; std::vector<uint8_t> val; }; 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}; char _path[40] = {0};
bool _sd_loaded = false; bool _sd_loaded = false;
bool useNvs(); // probe once, cache globally bool useNvs(); // legacy probe (NVS vs SD), cached globally
Kv* sdFind(const char* key); Kv* sdFind(const char* key);
void sdSet(const char* key, const uint8_t* data, size_t len); void sdSet(const char* key, const uint8_t* data, size_t len);
void sdLoad(); void sdLoad();
+365 -6
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields. // short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg"; static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg) 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 = 6; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility
// Defaults (kept identical to the historical per-key defaults). // Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20; 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_LOCK_COLOR = 0xE6F2FFu; // soft white
static const uint32_t DEFAULT_ACCENT = 0x15B6A6u; // brand teal static const uint32_t DEFAULT_ACCENT = 0x15B6A6u; // brand teal
static const bool DEFAULT_DC_SHOW = true; 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 { struct __attribute__((packed)) TouchCfg {
uint16_t magic; // TOUCH_CFG_MAGIC — rejects a garbage/short read uint16_t magic; // TOUCH_CFG_MAGIC — rejects a garbage/short read
@@ -68,6 +70,15 @@ struct __attribute__((packed)) TouchCfg {
uint32_t lock_color; // "lk_col" 0xRRGGBB uint32_t lock_color; // "lk_col" 0xRRGGBB
uint32_t accent; // "accent" 0xRRGGBB uint32_t accent; // "accent" 0xRRGGBB
uint32_t gps_baud; // "gps_baud" 0 = unset -> caller fallback 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
uint8_t hide_node_name; // hide the device name in the status bar + park clock left (bool) — v4
uint8_t map_night; // invert map tile colours at render time (bool) — v5
uint8_t map_zoom; // last map zoom level (0 = unset, auto-snap) — v5
uint8_t map_show_coords; // show the coords read-out on the map (bool) — v6
uint8_t map_show_tilexyz; // show the zoom + tile z/x/y line on the map (bool) — v6
uint8_t map_show_contacts;// show contact markers on the map (bool) — v6
}; };
static TouchCfg s_cfg; static TouchCfg s_cfg;
@@ -114,6 +125,15 @@ static void cfgSetDefaults(TouchCfg& c) {
c.lock_color = DEFAULT_LOCK_COLOR; c.lock_color = DEFAULT_LOCK_COLOR;
c.accent = DEFAULT_ACCENT; c.accent = DEFAULT_ACCENT;
c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback 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
c.hide_node_name = 0; // default: show the device name
c.map_night = 0; // default: normal (light) tiles
c.map_zoom = 0; // 0 = unset -> auto-snap on first map open
c.map_show_coords = 1; // default: show coords / tile line / contacts
c.map_show_tilexyz = 1;
c.map_show_contacts = 1;
} }
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO) // Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -148,6 +168,12 @@ static void cfgLoadOrMigrate() {
// newer trailing fields at their default), then version-upgrade if needed. // newer trailing fields at their default), then version-upgrade if needed.
memcpy(&s_cfg, &tmp, n < sizeof(s_cfg) ? n : sizeof(s_cfg)); memcpy(&s_cfg, &tmp, n < sizeof(s_cfg) ? n : sizeof(s_cfg));
if (s_cfg.ver < TOUCH_CFG_VER) { 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.ver = TOUCH_CFG_VER;
s_cfg.magic = TOUCH_CFG_MAGIC; s_cfg.magic = TOUCH_CFG_MAGIC;
cfgFlush(); // rewrite with new fields defaulted-in cfgFlush(); // rewrite with new fields defaulted-in
@@ -229,6 +255,18 @@ void touchPrefsBegin() {
if (s_begun) cfgLoadOrMigrate(); 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" // 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 // 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) // device and on every empty Wi-Fi-slot read. isKey() (getType → raw nvs probes)
@@ -249,6 +287,37 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) {
return cfgFlush(); 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() { uint8_t touchPrefsGetBrightness() {
if (!s_begun) touchPrefsBegin(); if (!s_begun) touchPrefsBegin();
uint8_t v = s_cfg.bright; uint8_t v = s_cfg.bright;
@@ -440,13 +509,70 @@ bool touchPrefsSetTimeOffsetHours(int hours) {
s_cfg.time_offs = (int8_t)hours; s_cfg.time_offs = (int8_t)hours;
return cfgFlush(); 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) { void touchPrefsBuildLocalTz(char* out, int out_cap) {
if (!out || out_cap <= 0) return; if (!out || out_cap <= 0) return;
// CET base is UTC+1 (POSIX std offset -1). The displayed local time is const uint8_t z = touchPrefsGetTimezone();
// UTC + (1 + userOffset), so the POSIX std-offset field is -(1 + userOffset). if (z < (uint8_t)TZ_ZONE_N) {
// CEST (summer) auto-adds 1h via the DST rule. offset 0 == "CET-1CEST,...". snprintf(out, out_cap, "%s", TZ_ZONES[z].posix);
int off = touchPrefsGetTimeOffsetHours(); return;
snprintf(out, out_cap, "CET%dCEST,M3.5.0,M10.5.0/3", -(1 + off)); }
// "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() { uint32_t touchPrefsGetLockTextColor() {
@@ -568,6 +694,64 @@ bool touchPrefsSetTilesFromSd(bool from_sd) {
return cfgFlush(); return cfgFlush();
} }
bool touchPrefsGetHideNodeName() {
if (!s_begun) touchPrefsBegin();
return s_cfg.hide_node_name != 0; // default = show the name
}
bool touchPrefsSetHideNodeName(bool hide) {
if (!s_begun) touchPrefsBegin();
s_cfg.hide_node_name = hide ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapNight() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_night != 0;
}
bool touchPrefsSetMapNight(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_night = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetMapZoom() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_zoom;
}
bool touchPrefsSetMapZoom(uint8_t z) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_zoom = z;
return cfgFlush();
}
bool touchPrefsGetMapShowCoords() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_coords != 0;
}
bool touchPrefsSetMapShowCoords(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_coords = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowTileXYZ() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_tilexyz != 0;
}
bool touchPrefsSetMapShowTileXYZ(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_tilexyz = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowContacts() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_contacts != 0;
}
bool touchPrefsSetMapShowContacts(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_contacts = on ? 1 : 0;
return cfgFlush();
}
// Store ALL device data (identity, prefs, contacts, channels) on the SD card // Store ALL device data (identity, prefs, contacts, channels) on the SD card
// under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE // under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE
// the data loads, so changing it needs a reboot. Key "use_sd" in the "touch" // the data loads, so changing it needs a reboot. Key "use_sd" in the "touch"
@@ -834,6 +1018,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 -------------------------------------- // Remembered repeater admin passwords --------------------------------------
// //
// Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records, // Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records,
+105 -3
View File
@@ -7,8 +7,12 @@
#if defined(ESP32) #if defined(ESP32)
#include <stdint.h> #include <stdint.h>
#include <stddef.h> // size_t (blob helpers below)
void touchPrefsBegin(); 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. */ /** Screen timeout in seconds; 0 = never sleep. Default 20. */
uint16_t touchPrefsGetScreenTimeoutSecs(); uint16_t touchPrefsGetScreenTimeoutSecs();
@@ -76,6 +80,30 @@ bool touchPrefsSetUseMiles(bool use_miles);
bool touchPrefsGetTilesFromSd(); bool touchPrefsGetTilesFromSd();
bool touchPrefsSetTilesFromSd(bool from_sd); bool touchPrefsSetTilesFromSd(bool from_sd);
/** Map night mode: invert tile colours at render time (cache stays untouched).
* Default false. */
bool touchPrefsGetMapNight();
bool touchPrefsSetMapNight(bool on);
/** Last map zoom level, persisted so the map reopens where the user left it.
* 0 = unset (let the auto-snap pick a level for the available tile pack). */
uint8_t touchPrefsGetMapZoom();
bool touchPrefsSetMapZoom(uint8_t z);
/** Per-element visibility of the map's on-screen text/markers. All default true
* (shown), so existing installs are unchanged. */
bool touchPrefsGetMapShowCoords();
bool touchPrefsSetMapShowCoords(bool on);
bool touchPrefsGetMapShowTileXYZ();
bool touchPrefsSetMapShowTileXYZ(bool on);
bool touchPrefsGetMapShowContacts();
bool touchPrefsSetMapShowContacts(bool on);
/** Hide the device/profile name in the status bar and move the clock to the
* left where the name used to be. Default false (name shown). */
bool touchPrefsGetHideNodeName();
bool touchPrefsSetHideNodeName(bool hide);
/** Store all device data (identity/prefs/contacts/channels) on the SD card under /** Store all device data (identity/prefs/contacts/channels) on the SD card under
* /meshcomod instead of internal SPIFFS. T-Deck only; read at boot before data * /meshcomod instead of internal SPIFFS. T-Deck only; read at boot before data
* loads, so changing it requires a reboot. Default false (SPIFFS). */ * loads, so changing it requires a reboot. Default false (SPIFFS). */
@@ -152,6 +180,58 @@ bool touchPrefsIsIgnored(const uint8_t* pub_key6);
bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored); bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored);
int touchPrefsCopyIgnored(uint8_t* out_buf); 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 /** Map tile-server base URL. The device fetches missing map tiles by
* HTTP GET against `<base>/<z>/<x>/<y>.png`. Defaults to the meshcomod * 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 * proxy. Plain HTTP only — mbedTLS doesn't fit in the ~5 KB of internal
@@ -218,9 +298,20 @@ bool touchPrefsSetAccentColor(uint32_t rgb);
int touchPrefsGetTimeOffsetHours(); int touchPrefsGetTimeOffsetHours();
bool touchPrefsSetTimeOffsetHours(int hours); bool touchPrefsSetTimeOffsetHours(int hours);
/** Build the POSIX TZ string for local-time display: the CET/CEST base plus the /** Time-zone picker. A curated list of named zones, each carrying the correct
* user's hour offset (see touchPrefsGetTimeOffsetHours). Used by both the * POSIX TZ string WITH that region's DST rules — so non-European users get the
* boot-time setenv and the Wi-Fi NTP sync so the offset is honoured everywhere. * 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. */ * `out` is always null-terminated when out_cap > 0. */
void touchPrefsBuildLocalTz(char* out, int out_cap); void touchPrefsBuildLocalTz(char* out, int out_cap);
@@ -237,4 +328,15 @@ bool touchPrefsSetSetupDone(bool done);
uint32_t touchPrefsGetGpsBaud(uint32_t fallback); uint32_t touchPrefsGetGpsBaud(uint32_t fallback);
bool touchPrefsSetGpsBaud(uint32_t baud); 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 #endif
+55 -1
View File
@@ -8,7 +8,9 @@
#include <esp_partition.h> // find/erase otadata so the bootloader returns to the recovery #include <esp_partition.h> // find/erase otadata so the bootloader returns to the recovery
#include <helpers/TouchDiagTrace.h> #include <helpers/TouchDiagTrace.h>
#include <helpers/MeshTouchTxTrace.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 #include "wadamesh_mark_rgb.h" // anti-aliased mesh-mark (RGB565) for the pre-LVGL boot screen
#endif #endif
@@ -345,6 +347,22 @@ void setup() {
#endif #endif
if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS
Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "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(); store.begin();
the_mesh.begin( the_mesh.begin(
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
@@ -383,6 +401,10 @@ void setup() {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false); WiFi.setAutoReconnect(false);
WiFi.persistent(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) #if defined(BLE_PIN_CODE)
/* Always stash the BLE params so the toggle can bring BLE up live later, even /* 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 bool sntp_pushed = false;
static uint32_t sntp_kick_ms = 0; static uint32_t sntp_kick_ms = 0;
if (WiFi.status() == WL_CONNECTED) { 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) { if (!sntp_kicked) {
/* Brussels timezone with DST rules baked in (POSIX "CET-1CEST,..."). /* Brussels timezone with DST rules baked in (POSIX "CET-1CEST,...").
* On touch builds the base is shifted by the user's manual hour offset * On touch builds the base is shifted by the user's manual hour offset
@@ -587,6 +615,32 @@ void loop() {
#endif #endif
the_mesh.loop(); the_mesh.loop();
sensors.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(); rtc_clock.tick();
// (1.16) sleep when there's no pending work — nRF power saving // (1.16) sleep when there's no pending work — nRF power saving
+225
View File
@@ -0,0 +1,225 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SnakeGame.h"
#include <Arduino.h> // random()
#include <string.h> // memmove
#include <LvglPsramAlloc.h> // PSRAM-preferred buffer for the canvas
// Mirrors the app's status-bar height; the overlay starts below it so the bar
// stays visible. Kept local so this module needs nothing from UITask.
static constexpr lv_coord_t kTopBar = 22;
static constexpr uint32_t kStepMs = 300; // tick period (a touch slower than before)
// Palette (hex literals so the module is self-contained).
static constexpr uint32_t kColBg = 0x0A0B0C; // playfield background
static constexpr uint32_t kColFood = 0xE0584C; // food
static constexpr uint32_t kColBody = 0x15B6A6; // snake body (brand teal)
static constexpr uint32_t kColText = 0xE6EAEE;
SnakeGame* SnakeGame::s_active = nullptr;
bool SnakeGame::isOpen() { return s_active != nullptr && s_active->root_ != nullptr; }
void SnakeGame::steer(int dx, int dy) {
if (!s_active || !s_active->started_ || s_active->over_) return;
// Trackball deltas -> one cardinal direction (dominant axis).
if (dx == 0 && dy == 0) return;
const int adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy;
if (adx >= ady) s_active->setDir(dx > 0 ? 1 : -1, 0);
else s_active->setDir(0, dy > 0 ? 1 : -1);
}
void SnakeGame::launch() {
if (s_active) return;
SnakeGame* g = new SnakeGame();
s_active = g;
if (!g->open()) { s_active = nullptr; delete g; }
}
void SnakeGame::placeFood() {
const int cells = cols_ * rows_;
for (int tries = 0; tries < 4 * cells + 16; ++tries) {
const uint8_t fx = (uint8_t)random(cols_);
const uint8_t fy = (uint8_t)random(rows_);
bool on = false;
for (int i = 0; i < len_; ++i) if (bx_[i] == fx && by_[i] == fy) { on = true; break; }
if (!on) { fx_ = fx; fy_ = fy; return; }
}
}
void SnakeGame::reset() {
len_ = 3;
const uint8_t cx = (uint8_t)(cols_ / 2), cy = (uint8_t)(rows_ / 2);
for (int i = 0; i < len_; ++i) { bx_[i] = (uint8_t)(cx - i); by_[i] = cy; }
dx_ = 1; dy_ = 0; ndx_ = 1; ndy_ = 0;
over_ = false; score_val_ = 0;
placeFood();
}
void SnakeGame::render() {
if (!canvas_) return;
const int c = kCellPx;
lv_canvas_fill_bg(canvas_, lv_color_hex(kColBg), LV_OPA_COVER);
lv_draw_rect_dsc_t d; lv_draw_rect_dsc_init(&d); d.radius = 2;
d.bg_color = lv_color_hex(kColFood);
lv_canvas_draw_rect(canvas_, fx_ * c + 1, fy_ * c + 1, c - 2, c - 2, &d);
for (int i = 0; i < len_; ++i) {
d.bg_color = lv_color_hex(kColBody);
d.bg_opa = (i == 0) ? LV_OPA_COVER : LV_OPA_80; // head reads brighter
lv_canvas_draw_rect(canvas_, bx_[i] * c + 1, by_[i] * c + 1, c - 2, c - 2, &d);
}
}
void SnakeGame::updateScoreLabel() {
if (!score_) return;
if (over_) lv_label_set_text_fmt(score_, "Game over \xe2\x80\x94 score %d (tap to restart)", score_val_);
else if (paused_) lv_label_set_text_fmt(score_, "Paused \xe2\x80\x94 score %d", score_val_);
else if (started_) lv_label_set_text_fmt(score_, "score %d", score_val_);
else lv_label_set_text(score_, "Snake \xe2\x80\x94 swipe or roll the trackball");
}
void SnakeGame::step() {
if (over_ || !started_ || paused_) return;
if (!(ndx_ == -dx_ && ndy_ == -dy_)) { dx_ = ndx_; dy_ = ndy_; }
const int nx = bx_[0] + dx_, ny = by_[0] + dy_;
const bool eat = (nx == fx_ && ny == fy_);
bool over = (nx < 0 || ny < 0 || nx >= cols_ || ny >= rows_);
const int clen = eat ? len_ : len_ - 1; // the tail vacates unless we grow
for (int i = 0; i < clen && !over; ++i) if (bx_[i] == nx && by_[i] == ny) over = true;
if (over) { over_ = true; updateScoreLabel(); return; }
int newlen = len_ + (eat ? 1 : 0);
if (newlen > cols_ * rows_) newlen = cols_ * rows_;
memmove(&bx_[1], &bx_[0], (size_t)(newlen - 1));
memmove(&by_[1], &by_[0], (size_t)(newlen - 1));
bx_[0] = (uint8_t)nx; by_[0] = (uint8_t)ny;
len_ = newlen;
if (eat) { score_val_++; placeFood(); updateScoreLabel(); }
render();
}
void SnakeGame::setDir(int dx, int dy) { ndx_ = dx; ndy_ = dy; }
void SnakeGame::startGame() {
if (started_) return;
started_ = true;
paused_ = false;
if (start_btn_) { lv_obj_del(start_btn_); start_btn_ = nullptr; }
if (pause_btn_) { // reveal the pause toggle now that we're playing
lv_obj_clear_flag(pause_btn_, LV_OBJ_FLAG_HIDDEN);
if (pause_lbl_) lv_label_set_text(pause_lbl_, LV_SYMBOL_PAUSE);
}
reset();
updateScoreLabel();
render();
if (!timer_) timer_ = lv_timer_create(timerCb, kStepMs, this);
}
void SnakeGame::togglePause() {
if (!started_ || over_) return;
paused_ = !paused_;
if (pause_lbl_) lv_label_set_text(pause_lbl_, paused_ ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
updateScoreLabel();
}
bool SnakeGame::open() {
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
root_ = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(root_);
lv_obj_set_size(root_, sw, sh - kTopBar);
lv_obj_set_pos(root_, 0, kTopBar);
lv_obj_set_style_bg_color(root_, lv_color_hex(0x0E1216), LV_PART_MAIN);
lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(root_, gestureCb, LV_EVENT_GESTURE, this);
lv_obj_add_event_cb(root_, tapCb, LV_EVENT_CLICKED, this);
// Fill the unoccupied screen below a 24-px score/close row. Whole cells only.
const int avail_w = (int)sw;
const int avail_h = (int)(sh - kTopBar) - 24;
cols_ = avail_w / kCellPx; if (cols_ > kMaxCols) cols_ = kMaxCols; if (cols_ < 6) cols_ = 6;
rows_ = avail_h / kCellPx; if (rows_ > kMaxRows) rows_ = kMaxRows; if (rows_ < 6) rows_ = 6;
const int pw = cols_ * kCellPx, ph = rows_ * kCellPx;
buf_ = (lv_color_t*)lvglPsramAlloc((size_t)pw * ph * sizeof(lv_color_t));
if (!buf_) return false;
canvas_ = lv_canvas_create(root_);
lv_canvas_set_buffer(canvas_, buf_, pw, ph, LV_IMG_CF_TRUE_COLOR);
lv_obj_align(canvas_, LV_ALIGN_BOTTOM_MID, 0, 0); // playfield fills the lower area
score_ = lv_label_create(root_);
lv_obj_set_style_text_color(score_, lv_color_hex(kColText), LV_PART_MAIN);
lv_obj_align(score_, LV_ALIGN_TOP_LEFT, 6, 4);
lv_obj_t* x = lv_btn_create(root_);
lv_obj_set_size(x, 30, 24);
lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0);
lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this);
lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl);
// Pause/resume toggle, left of the X. Hidden until the game starts.
pause_btn_ = lv_btn_create(root_);
lv_obj_set_size(pause_btn_, 30, 24);
lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -40, 0);
lv_obj_add_event_cb(pause_btn_, pauseCb, LV_EVENT_CLICKED, this);
pause_lbl_ = lv_label_create(pause_btn_); lv_label_set_text(pause_lbl_, LV_SYMBOL_PAUSE); lv_obj_center(pause_lbl_);
lv_obj_add_flag(pause_btn_, LV_OBJ_FLAG_HIDDEN);
// Paused: draw the initial snake, then wait on a centred "New game" button.
reset();
updateScoreLabel();
render();
start_btn_ = lv_btn_create(root_);
lv_obj_set_size(start_btn_, 150, 44);
lv_obj_align(start_btn_, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(start_btn_, startCb, LV_EVENT_CLICKED, this);
lv_obj_t* sl = lv_label_create(start_btn_);
lv_label_set_text(sl, LV_SYMBOL_PLAY " New game");
lv_obj_center(sl);
return true;
}
void SnakeGame::close() {
if (timer_) { lv_timer_del(timer_); timer_ = nullptr; }
if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes canvas + button children
if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } // free AFTER the canvas is gone
canvas_ = nullptr; score_ = nullptr; start_btn_ = nullptr;
pause_btn_ = nullptr; pause_lbl_ = nullptr;
}
// ---- LVGL C-callback trampolines (user_data = the instance) ----
void SnakeGame::timerCb(lv_timer_t* t) {
auto* self = static_cast<SnakeGame*>(t->user_data);
if (self) self->step();
}
void SnakeGame::gestureCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (!self || !self->started_) return;
switch (lv_indev_get_gesture_dir(lv_indev_get_act())) {
case LV_DIR_TOP: self->setDir(0, -1); break;
case LV_DIR_BOTTOM: self->setDir(0, 1); break;
case LV_DIR_LEFT: self->setDir(-1, 0); break;
case LV_DIR_RIGHT: self->setDir(1, 0); break;
default: break;
}
}
void SnakeGame::tapCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (self && self->started_ && self->over_) { self->reset(); self->updateScoreLabel(); self->render(); }
}
void SnakeGame::startCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (self) self->startGame();
}
void SnakeGame::pauseCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (self) self->togglePause();
}
void SnakeGame::closeCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (!self) return;
self->close();
if (s_active == self) s_active = nullptr;
delete self;
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <lvgl.h>
// Self-contained Snake mini-game launched from the Apps drawer.
//
// Owns a full-screen overlay on lv_layer_top: a canvas playfield filling the
// unoccupied screen, a score line, and a close button. The game waits on a
// "New game" button (doesn't start moving on open). Steer by swipe OR trackball
// (UITask routes the trackball here via isOpen()/steer() and hides the cursor);
// tap to restart after a game over; the X closes it. One instance at a time.
//
// Decoupled from UITask internals — depends only on LVGL and lvglPsramAlloc.
class SnakeGame {
public:
static void launch(); // open (no-op if already open)
static bool isOpen(); // UITask: gate the trackball + tab bar
static void steer(int dx, int dy); // UITask: trackball motion -> direction
private:
// The playfield grid is sized to the screen at open() (cols_ x rows_), capped
// by these maxima so the body arrays are fixed-size.
static constexpr int kCellPx = 14; // px per cell
static constexpr int kMaxCols = 40;
static constexpr int kMaxRows = 30;
static constexpr int kMaxCells = kMaxCols * kMaxRows;
lv_obj_t* root_ = nullptr; // full-screen overlay (owns everything below)
lv_obj_t* canvas_ = nullptr;
lv_color_t* buf_ = nullptr; // canvas pixel buffer (PSRAM)
lv_obj_t* score_ = nullptr;
lv_obj_t* start_btn_ = nullptr; // "New game" gate (deleted once tapped)
lv_obj_t* pause_btn_ = nullptr; // pause/resume toggle (shown once playing)
lv_obj_t* pause_lbl_ = nullptr; // its glyph (play <-> pause)
lv_timer_t* timer_ = nullptr;
int cols_ = 14, rows_ = 14; // grid dimensions (set at open)
bool started_ = false; // false until "New game" is tapped
bool paused_ = false; // pause toggle (timer keeps ticking; step() no-ops)
uint8_t bx_[kMaxCells], by_[kMaxCells]; // snake body, [0] = head
int len_ = 0;
int dx_ = 1, dy_ = 0; // active direction
int ndx_ = 1, ndy_ = 0; // queued direction (applied next step)
uint8_t fx_ = 0, fy_ = 0; // food cell
bool over_ = false;
int score_val_ = 0;
bool open(); // build UI (paused); false on alloc failure
void startGame(); // begin the tick (from the New-game button)
void togglePause(); // pause/resume the running game
void close();
void reset();
void placeFood();
void render();
void step();
void setDir(int dx, int dy);
void updateScoreLabel();
static SnakeGame* s_active; // the one live game (or nullptr)
static void timerCb(lv_timer_t* t);
static void gestureCb(lv_event_t* e);
static void tapCb(lv_event_t* e);
static void closeCb(lv_event_t* e);
static void startCb(lv_event_t* e);
static void pauseCb(lv_event_t* e);
};
+6632 -710
View File
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -29,7 +29,7 @@ enum class TouchUiScreen : uint8_t { Home = 0, ChatInbox = 1, Contacts = 2, Sett
class UITask : public AbstractUITask { class UITask : public AbstractUITask {
public: public:
static const int MAX_UI_MESSAGES = 96; static const int MAX_UI_MESSAGES = 500;
static const int MAX_UI_THREADS = 48; static const int MAX_UI_THREADS = 48;
static const int MAX_THREAD_NAME = 32; static const int MAX_THREAD_NAME = 32;
static const int MAX_SENDER_NAME = 24; static const int MAX_SENDER_NAME = 24;
@@ -160,8 +160,10 @@ private:
TouchUiScreen _touch_screen; TouchUiScreen _touch_screen;
bool _active_dm_contact_set; bool _active_dm_contact_set;
uint8_t _active_dm_contact_pub[32]; uint8_t _active_dm_contact_pub[32];
bool _history_dirty; bool _threads_dirty;
unsigned long _next_history_flush_ms; unsigned long _next_threads_flush_ms;
bool _msgs_dirty;
unsigned long _next_msgs_flush_ms;
void userLedHandler(); void userLedHandler();
@@ -213,10 +215,15 @@ private:
void appendComposerText(const char* text); void appendComposerText(const char* text);
void backspaceComposerChar(); void backspaceComposerChar();
bool sendComposerToActiveThread(); bool sendComposerToActiveThread();
void markHistoryDirty(unsigned long delay_ms = 600); void markThreadsDirty(unsigned long delay_ms = 200);
void markMsgsDirty(unsigned long delay_ms = 2000);
void flushHistoryIfDue(unsigned long now); void flushHistoryIfDue(unsigned long now);
bool loadHistoryFromStorage(); bool loadHistoryFromStorage();
bool saveHistoryToStorage(); bool loadThreadsFromStorage();
bool loadMsgsFromStorage();
bool loadLegacyHistoryFromStorage();
bool saveThreadsToStorage();
bool saveMsgsToStorage();
void setCurrScreen(UIScreen* c); void setCurrScreen(UIScreen* c);
@@ -230,8 +237,10 @@ public:
_next_mesh_thread_refresh = 0; _next_mesh_thread_refresh = 0;
_active_dm_contact_set = false; _active_dm_contact_set = false;
memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub)); memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub));
_history_dirty = false; _threads_dirty = false;
_next_history_flush_ms = 0; _next_threads_flush_ms = 0;
_msgs_dirty = false;
_next_msgs_flush_ms = 0;
_last_input_ms = 0; _last_input_ms = 0;
_screen_timeout_ms = 20000; // overridden by NVS in begin() _screen_timeout_ms = 20000; // overridden by NVS in begin()
_screen_off = false; _screen_off = false;
@@ -253,7 +262,9 @@ public:
void showAlert(const char* text, int duration_millis); void showAlert(const char* text, int duration_millis);
int getMsgCount() const { return _msgcount; } int getMsgCount() const { return _msgcount; }
int getUnreadTotal() const; 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 markThreadRead(int idx); // clear one thread's unread count (persisted)
void markActiveThreadRead(); // clear the currently-open thread's unread (viewing == read)
void markAllThreadsRead(); // clear every thread's unread count void markAllThreadsRead(); // clear every thread's unread count
bool threadHasMention(int idx) const; // unread @mention of me in this thread bool threadHasMention(int idx) const; // unread @mention of me in this thread
int getThreadCount(bool channel_mode, int out_indexes[], int max_out) const; int getThreadCount(bool channel_mode, int out_indexes[], int max_out) const;
+55 -7
View File
@@ -1,7 +1,7 @@
/******************************************************************************* /*******************************************************************************
* Size: 16 px * Size: 16 px
* Bpp: 4 * 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" #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, 0xff, 0xa0, 0x5f, 0xff, 0xff, 0xff,
0xff, 0xff, 0xf5, 0xbf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf5, 0xbf, 0xff, 0xff, 0xff, 0xff,
0xff, 0xfb, 0xff, 0xff, 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[] = { 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 = 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 * CHARACTER MAPPING
*--------------------*/ *--------------------*/
static const uint16_t unicode_list_0[] = {
0x0, 0xb9, 0x512
};
/*Collect the unicode lists and glyph_id offsets*/ /*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] = static const lv_font_fmt_txt_cmap_t cmaps[] =
{ {
{ {
.range_start = 61447, .range_length = 1, .glyph_id_start = 1, .range_start = 61447, .range_length = 1299, .glyph_id_start = 1,
.unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY .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 #endif
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/ .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*/ .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*/ .base_line = 2, /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0) #if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
.subpx = LV_FONT_SUBPX_NONE, .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*/
+128
View File
@@ -0,0 +1,128 @@
/*******************************************************************************
* Size: 16 px
* Bpp: 4
* Opts: --no-compress --bpp 4 --size 16 --font /Users/dan.vybiral/.claude/jobs/dadc5d4b/tmp/fa-solid-900.ttf -r 0xF002 --format lvgl --lv-font-name zoom_font -o src/ui-touch/zoom_font.c
******************************************************************************/
#include "lvgl.h"
#ifndef ZOOM_FONT
#define ZOOM_FONT 1
#endif
#if ZOOM_FONT
/*-----------------
* BITMAPS
*----------------*/
/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+F002 "" */
0x0, 0x3, 0xae, 0xfe, 0xa3, 0x0, 0x0, 0x0,
0x0, 0x9f, 0xff, 0xff, 0xff, 0x90, 0x0, 0x0,
0x9, 0xff, 0x82, 0x2, 0x8f, 0xf9, 0x0, 0x0,
0x3f, 0xf3, 0x0, 0x0, 0x3, 0xff, 0x30, 0x0,
0xaf, 0x80, 0x0, 0x0, 0x0, 0x8f, 0xa0, 0x0,
0xef, 0x20, 0x0, 0x0, 0x0, 0x2f, 0xe0, 0x0,
0xff, 0x0, 0x0, 0x0, 0x0, 0xf, 0xf0, 0x0,
0xef, 0x20, 0x0, 0x0, 0x0, 0x2f, 0xe0, 0x0,
0xaf, 0x80, 0x0, 0x0, 0x0, 0x8f, 0xa0, 0x0,
0x3f, 0xf3, 0x0, 0x0, 0x3, 0xff, 0x40, 0x0,
0x9, 0xff, 0x82, 0x2, 0x8f, 0xfe, 0x10, 0x0,
0x0, 0x9f, 0xff, 0xff, 0xff, 0xef, 0xd1, 0x0,
0x0, 0x3, 0xae, 0xfe, 0xa4, 0x1d, 0xfd, 0x10,
0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0xdf, 0xd1,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1d, 0xfc,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0xcc
};
/*---------------------
* 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 = 256, .box_w = 16, .box_h = 16, .ofs_x = 0, .ofs_y = -2}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61442, .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
}
};
/*--------------------
* 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 zoom_font = {
#else
lv_font_t zoom_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*/
.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 ZOOM_FONT*/