Compare commits

...
53 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 c931df6054 touch: beta_10 — trackball D-pad mode, microSD tile-cache fill, Battery group
- T-Deck: trackball mouse/D-pad navigation toggle (Settings -> Keyboard).
- Map (#20): microSD-tile mode now caches Wi-Fi-fetched tiles onto the SD card so
  the offline library fills in + grows instead of going blank past its edge.
- Power: PR #19 idle power-save (Dan Vybiral / @Vybo) relocated into a new
  'Battery' group under Settings -> System, alongside a button that opens the
  battery / power-history screen.
- TouchCfg v10 (ui_scale, tb_keypad, sleep_idle). Mesh America catalog -> beta_10.

Curated touch overlay onto main (the WIP Tanmatsu ESP32-P4 build system stays on
the tanmatsu-port branch). Co-Authored-By: Dan Vybiral <dan.vybiral@greencode.cz>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:08:13 +02:00
Dan Vybiral 11b31e1cdd touch: idle power-save — loop-throttle + downclock when parked (#19)
Opt-in Idle power-save for the T-Deck: when parked (screen off, on battery, no
companion client, Wi-Fi+BLE off, mesh idle) the main loop is throttled with
vTaskDelay so the FreeRTOS idle task can halt the CPU between ticks, on top of the
existing 80 MHz screen-off downclock — without ever stopping mesh RX. Default OFF
(Settings -> Lock, persisted TouchCfg v8 sleep_idle); status-bar moon indicator +
idle stats. Heltec V4 compiles (gate never passes there). A plain scheduler throttle
rather than esp_light_sleep_start(), which trips INT_WDT in this build.

Co-authored-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-06-21 22:39:59 +02:00
Kaj SchittecatandClaude Opus 4.8 c16b5230a6 release: auto-refresh + push the Mesh America catalog each beta
release.sh now regenerates deploy/meshamerica-catalog.json for the tag and, when
run on main, commits + pushes that one file so apps.meshamerica.com (the flasher
Cascadia Mesh uses) always points at the newest immutable bins instead of going
stale on the previous beta. Idempotent (no-op when already current); non-fatal if
the push fails; also mirrors the catalog into the VPS publish tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:27:28 +02:00
Kaj SchittecatandClaude Opus 4.8 f5362b1cb3 deploy: add Mesh America Configurator provider catalog
Catalog JSON (Mesh America provider schema) + a reusable generator so the two
wadamesh touch boards appear in the Mesh America Device Configurator
(apps.meshamerica.com — the flasher Cascadia Mesh uses). Points at the immutable
per-tag bins on firmware.wadamesh.com (HTTPS + CORS). Served live over
raw.githubusercontent.com.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:20:29 +02:00
Kaj SchittecatandClaude Opus 4.8 7a63ffe86c touch: beta_9 — crash/Wi-Fi reliability, crash-report export, room fixes, app-drawer icon size
- Stability: Wi-Fi auto-reconnect; corrupt chat-history quarantine (fixes reboot-on-
  receive / inaccessible messages); draw-buffer + flush NULL-guards (boot-panic).
- Crash-report export to SD/SPIFFS (Settings -> About).
- Map: reload purges on-disk tiles offline + visible zoom band; corrupt-tile self-heal
  on load; z15 dedup-key overflow fixed.
- Contacts: favourites always listed (#17).
- Chat: room message send-time (#26) + sender labels; QR share uses the official
  meshcore://contact/add format (#16); unread badge capped at 99+.
- App drawer: icon-size setting (Compact/Large) + smoother scrolling.
- ~5 KB internal DRAM reclaimed (backup arrays + msg_idx -> PSRAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:16:04 +02:00
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
23 changed files with 5647 additions and 539 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)
CLAUDE.md
.claude/
# Custom timestamped build outputs
/builds/
# Local reconstruction notes (scratch, never publish)
/.notes/
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Generate the Mesh America Configurator catalog JSON for wadamesh.
Mesh America (apps.meshamerica.com — the flasher Cascadia Mesh uses) merges a
provider "catalog" JSON into its device list. Spec: third-party hosts ONE static
JSON (this file's output) at a stable HTTPS+CORS URL, plus the firmware bins over
HTTPS+CORS; their app reads it live. See provider-api-spec.md from TJ Downes.
We host:
- the bins on firmware.wadamesh.com/releases/TOUCH/<TAG>/ (Cloudflare HTTPS +
`Access-Control-Allow-Origin: *`, immutable per-tag dir — see deploy/nginx/
firmware.wadamesh.com.conf), and
- this catalog at a stable HTTPS+CORS URL (deployed alongside the firmware).
Usage: python3 deploy/gen-meshamerica-catalog.py <TAG> > meshamerica-catalog.json
(TAG defaults to the newest beta_N dir; notes come from release-notes/<TAG>.txt)
Re-run it each release (or wire it into scripts/release.sh) so the catalog always
points at the newest immutable bins — the spec wants firmware URLs treated as
immutable, so each release adds a fresh versioned URL.
"""
import sys, json, os, glob, re
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def newest_tag():
tags = []
for p in glob.glob(os.path.join(REPO, "release-notes", "beta_*.txt")):
m = re.search(r"beta_(\d+)", os.path.basename(p))
if m:
tags.append(int(m.group(1)))
return "beta_%d" % max(tags) if tags else "beta_9"
TAG = sys.argv[1] if len(sys.argv) > 1 else newest_tag()
BASE = "https://firmware.wadamesh.com/releases/TOUCH/%s" % TAG
ICON = "https://wadamesh.com/wadamesh-icon-512.png"
# "What's new" — one user-facing line per non-blank, non-# line of the notes file.
notes = ""
nf = os.path.join(REPO, "release-notes", "%s.txt" % TAG)
if os.path.exists(nf):
notes = "\n".join(l.strip() for l in open(nf, encoding="utf-8")
if l.strip() and not l.strip().startswith("#"))
# Both touch boards are ESP32-S3, single touch-UI ("gui") role. ota_0 sits at
# 0x10000 on both (partitions_t*_touch.csv), so flash-update@0x10000 is correct.
BOARDS = [
{"name": "wadamesh — Heltec V4 TFT", "slug": "heltec-v4-tft"},
{"name": "wadamesh — LilyGo T-Deck", "slug": "tdeck"},
]
def device(b):
merged, appbin = "wadamesh-%s-merged.bin" % b["slug"], "wadamesh-%s.bin" % b["slug"]
return {
"maker": "wadamesh",
"class": "wadamesh",
"name": b["name"],
"type": "esp32",
"tooltip": "<img class='device' src='%s'>" % ICON,
"firmware": [{
"role": "gui",
"title": "Touch UI",
"version": {TAG: {
"notes": notes,
"files": [
{"type": "flash-wipe", "name": merged,
"url": "%s/%s" % (BASE, merged),
"title": "Full install (bootloader + firmware)"},
{"type": "flash-update", "name": appbin,
"url": "%s/%s" % (BASE, appbin),
"title": "Update (app only)"},
],
}},
}],
}
catalog = {
"maker": {"wadamesh": {"name": "wadamesh"}},
"device": [device(b) for b in BOARDS],
}
print(json.dumps(catalog, indent=2, ensure_ascii=False))
+73
View File
@@ -0,0 +1,73 @@
{
"maker": {
"wadamesh": {
"name": "wadamesh"
}
},
"device": [
{
"maker": "wadamesh",
"class": "wadamesh",
"name": "wadamesh — Heltec V4 TFT",
"type": "esp32",
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
"firmware": [
{
"role": "gui",
"title": "Touch UI",
"version": {
"beta_10": {
"notes": "",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_10/wadamesh-heltec-v4-tft-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_10/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
}
}
}
]
},
{
"maker": "wadamesh",
"class": "wadamesh",
"name": "wadamesh — LilyGo T-Deck",
"type": "esp32",
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
"firmware": [
{
"role": "gui",
"title": "Touch UI",
"version": {
"beta_10": {
"notes": "",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_10/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_10/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
}
}
}
]
}
]
}
+19 -3
View File
@@ -19,6 +19,11 @@ extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py
build_flags =
-w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
; NOTE: tried -D ARDUINO_USB_MODE=1 (HW-CDC, drops unused TinyUSB MSC/DFU ≈ 12 KB internal
; DRAM) — but on the real V4 the binary companion link regressed: small per-message frames
; arrived yet the larger device-profile frame (node name + keys) dropped bytes over HW-CDC's
; smaller TX FIFO. Reverted to the board JSON's TinyUSB CDC. Revisit only with an HW-CDC TX
; buffer/DTR-wait fix verified on hardware. (T-Deck ships MODE=1 and is fine — board-specific.)
-D LORA_FREQ=869.618
-D LORA_BW=62.5
-D LORA_SF=8
@@ -126,6 +131,9 @@ build_flags =
-D ENABLE_ADVERT_ON_BOOT=0
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide
; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins.
-D LV_INDEV_DEF_SCROLL_THROW=7
-D USER_SETUP_LOADED=1
-D ST7789_DRIVER=1
-D TFT_WIDTH=240
@@ -146,6 +154,7 @@ build_flags =
-D DISPLAY_CLASS=ST7789LCDDisplay
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
@@ -166,7 +175,7 @@ lib_deps =
; MeshCore core = the meshcomod monorepo built as a lib, pinned by tag.
; Touch-app files (TouchPrefsStore, WifiRuntimeStore, transports, ...) are
; vendored in this app's src/ and dropped from the lib via -DMC_VENDORED_TOUCH_APP.
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.3
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.4
SPI
Wire
jgromes/RadioLib @ ^7.6.0
@@ -238,7 +247,10 @@ build_flags =
-I variants/lilygo_tdeck
-D LILYGO_TDECK
-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 PIN_USER_BTN=0
-D PIN_PERF_POWERON=10
@@ -307,11 +319,15 @@ build_flags =
-D PIN_TB_RIGHT=1
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide
; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins.
-D LV_INDEV_DEF_SCROLL_THROW=7
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=ST7789LCDDisplay
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
@@ -329,7 +345,7 @@ build_src_filter =
+<../variants/lilygo_tdeck/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.3
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.4
SPI
Wire
jgromes/RadioLib @ ^7.6.0
+13
View File
@@ -0,0 +1,13 @@
# beta_10 — what's new (one user-facing note per non-blank, non-# line)
# --- T-Deck input ---
New trackball mode — switch the T-Deck trackball between a mouse cursor (the default) and D-pad navigation (roll to move the selection, click to choose), under Settings → Keyboard. Handy if you prefer button-style navigation.
# --- Map ---
microSD map tiles now fill in as you explore: with "Use microSD for tiles" on, the device also downloads any missing tiles over Wi-Fi and saves them straight onto the SD card, so your offline library grows instead of showing blank areas past its edge (thanks 1nerdherder, #20)
# --- Power ---
Experimental battery saver (idle power-save): throttles the CPU when the device is parked — screen off, on battery, no app connected and the mesh quiet — to trim idle power use, without ever missing a received packet. Off by default; enable it under Settings → System → Battery. (Dan Vybiral / @Vybo, #19)
# --- Settings ---
New "Battery" section under Settings → System — opens the battery & power-history screen (the same one you get by tapping the battery icon up top) and is where the experimental battery saver now lives (it moved out of the Lock settings).
+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
+26
View File
@@ -0,0 +1,26 @@
# beta_9 — what's new (one user-facing note per non-blank, non-# line)
# --- Stability ---
Wi-Fi now reconnects on its own after the link drops — no more rebooting just to get back online
Fixed a bug where a corrupted chat-history file could make the device reboot on every received message (and hide your messages) — the bad file is now detected and reset automatically so it recovers on its own
Hardened the boot path so a device with quirky memory degrades gracefully instead of getting stuck on the boot logo
# --- Crash reports ---
After a crash, Settings -> About now shows an "Export crash report" button that saves the crash dump to the microSD card, so you can send it to the devs and get the bug fixed
# --- Map ---
The reload button for corrupt tiles now actually clears the cached tiles — including when you're offline — and missing/blank "twilight zone" tiles re-download on their own
Fixed tiles not re-fetching at zoom levels 13 to 15
# --- Contacts ---
Favourited contacts now always appear in the contact list, even with hundreds of similarly-named contacts (they used to stay hidden until you searched for them)
# --- Chat ---
Room-server messages now show the time each message was actually sent, instead of when it arrived
Room-server messages now show who sent them
Sharing your contact QR code now produces a valid code that the MeshCore phone app can scan and import
Unread counts now show "99+" instead of an alarming raw number on busy channels
# --- App drawer ---
New icon-size setting: tap the cog at the top-right of the app drawer to switch between Compact and Large — bigger icons and labels, easier to read
Smoother scrolling through the app list (and the other scrollable lists)
+22
View File
@@ -54,6 +54,28 @@ echo "listing: $(python3 -c 'import json,sys;print(", ".join(x["name"] for x in
# frozen one. Notes come from release-notes/<tag>.txt (one per line; optional).
python3 "$ROOT/scripts/build/gen-flasher-meta.py" "$TAG" "$LATEST" "$ROOT/release-notes/$TAG.txt"
# 3c. Mesh America Configurator provider catalog (apps.meshamerica.com — the web
# flasher Cascadia Mesh uses). It is served LIVE from raw.githubusercontent
# main, so each release must regenerate it for $TAG and push that ONE file to
# main; otherwise the configurator keeps offering the previous beta. It points
# at the immutable /releases/TOUCH/$TAG/ bins published below.
# (Generator: deploy/gen-meshamerica-catalog.py — reads release-notes/$TAG.txt.)
CATALOG="$ROOT/deploy/meshamerica-catalog.json"
python3 "$ROOT/deploy/gen-meshamerica-catalog.py" "$TAG" > "$CATALOG.tmp" && mv "$CATALOG.tmp" "$CATALOG"
mkdir -p "$OUT/meshamerica" && cp "$CATALOG" "$OUT/meshamerica/catalog.json" # also mirror to the VPS (enables the branded URL once its nginx location is deployed)
if git -C "$ROOT" diff --quiet -- "$CATALOG"; then
echo "Mesh America catalog already current for $TAG"
elif [ "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" = "main" ]; then
if git -C "$ROOT" commit -q -m "chore: refresh Mesh America catalog for $TAG" -- "$CATALOG" \
&& git -C "$ROOT" push origin HEAD:main; then
echo "Mesh America catalog refreshed + pushed to main ($TAG)"
else
echo "WARN: Mesh America catalog commit/push failed — push deploy/meshamerica-catalog.json to main by hand"
fi
else
echo "NOTE: regenerated $CATALOG for $TAG but not on 'main' — commit + push it to main so apps.meshamerica.com updates."
fi
# 4. Publish to the VPS (Cloudflare caches at the edge).
if [ -n "$DEST" ]; then
rsync -av "$OUT/" "$DEST:$DEST_PATH/"
+15
View File
@@ -1880,6 +1880,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
const int8_t rssi = (int8_t)(_radio->getLastRSSI());
const bool is_flood = pkt->isRouteFlood();
uiStashRxMeta(pkt); // capture route + scope for the per-message Info popup
_last_sender_ts = sender_timestamp; // embedded send-time -> UI bubble ts (room history replay)
_ui->newMsgFromPubWithMeta(path_len, is_flood, from.id.pub_key, from.name,
text, history_count, snr_q4, rssi);
}
@@ -2553,6 +2554,20 @@ NodePrefs *MyMesh::getNodePrefs() {
uint32_t MyMesh::getBLEPin() {
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 {
uint32_t lower_freq, upper_freq;
+16
View File
@@ -113,6 +113,7 @@ public:
const char *getNodeName();
NodePrefs *getNodePrefs();
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
// mirror the web client's "Device (live)" panel — public key prefix, channel
@@ -392,6 +393,7 @@ public:
uint8_t _last_rx_path_n = 0;
uint16_t _last_rx_scope = 0; // transport_codes[0] of the last RX flood ("scope")
bool _last_rx_has_scope = false; // false if the packet carried no transport codes
uint32_t _last_sender_ts = 0; // embedded send-time of the last inbound msg (UI bubble ts; 0 = use now)
volatile bool _echo_dirty = false; // a repeat was counted -> UI should refresh
// ---- Live signal strength (top-bar icon) ----
@@ -480,6 +482,10 @@ public:
/** Scope (transport_codes[0]) of the last received flood; *has = false when
* the packet carried no transport codes. */
uint16_t lastRxScope(bool* has) const { if (has) *has = _last_rx_has_scope; return _last_rx_scope; }
/** Consume the embedded send-time stashed right before the last UI notify (room
* history replay carries old send-times; without this the UI stamps "now").
* Returns 0 when nothing was stashed -> the caller keeps the delivery time. */
uint32_t uiConsumeLastSenderTs() { uint32_t t = _last_sender_ts; _last_sender_ts = 0; return t; }
/** Capture route + scope of a just-received flood for the Info popup. Call
* synchronously right before the newMsg* notification. */
void uiStashRxMeta(mesh::Packet* pkt) {
@@ -774,6 +780,16 @@ public:
// To check if there is pending work
bool hasPendingWork() const;
// Number of companion clients currently connected on any transport.
// Used by the idle light-sleep gate (TouchSleep) to confirm no one is
// actively talking to us before the node parks in light sleep.
int getProtoNumClients() const { return proto_num_clients; }
// True if the radio is currently mid-receive of a packet (preamble→RxDone).
// _radio is the protected Dispatcher::Radio pointer; isReceiving() returns false
// by default and is overridden by RadioLibWrapper with a real preamble-detect check.
bool isRadioReceiving() const { return _radio && _radio->isReceiving(); }
private:
void writeOKFrame();
void writeErrFrame(uint8_t err_code);
+117 -1
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 3; // v2 adds sig_probe_en + sig_poll_min; v3 adds tz_zone
static const uint8_t TOUCH_CFG_VER = 10; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -73,6 +73,16 @@ struct __attribute__((packed)) TouchCfg {
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
uint8_t app_grid_large; // app drawer: large grid (one fewer column, bigger icons) — v7
uint8_t ui_scale; // UI resolution scale: 0=100% 1=150% 2=200% (Tanmatsu, applied at boot) — v8
uint8_t tb_keypad; // T-Deck trackball: 0=mouse cursor (default), 1=D-pad focus navigation — v9
uint8_t sleep_idle; // idle light-sleep feature on/off (bool) — v10 (trailing so existing blobs default it OFF)
};
static TouchCfg s_cfg;
@@ -122,6 +132,16 @@ static void cfgSetDefaults(TouchCfg& c) {
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;
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
c.ui_scale = 0; // default: 100% UI scale (native resolution)
c.tb_keypad = 0; // default: trackball = mouse cursor
c.sleep_idle = 0; // default: idle light-sleep OFF
}
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -682,6 +702,102 @@ bool touchPrefsSetTilesFromSd(bool from_sd) {
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 touchPrefsGetSleepIdle() {
if (!s_begun) touchPrefsBegin();
return s_cfg.sleep_idle != 0;
}
bool touchPrefsSetSleepIdle(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.sleep_idle = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowCoords() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_coords != 0;
}
bool touchPrefsSetMapShowCoords(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_coords = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowTileXYZ() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_tilexyz != 0;
}
bool touchPrefsSetMapShowTileXYZ(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_tilexyz = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowContacts() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_contacts != 0;
}
bool touchPrefsSetMapShowContacts(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_contacts = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetAppGridLarge() {
if (!s_begun) touchPrefsBegin();
return s_cfg.app_grid_large != 0;
}
bool touchPrefsSetAppGridLarge(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.app_grid_large = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetUiScale() {
if (!s_begun) touchPrefsBegin();
return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; // 0=100% 1=150% 2=200%
}
bool touchPrefsSetUiScale(uint8_t scale) {
if (!s_begun) touchPrefsBegin();
s_cfg.ui_scale = scale > 2 ? 0 : scale;
return cfgFlush();
}
bool touchPrefsGetTbKeypad() {
if (!s_begun) touchPrefsBegin();
return s_cfg.tb_keypad != 0;
}
bool touchPrefsSetTbKeypad(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.tb_keypad = on ? 1 : 0;
return cfgFlush();
}
// Store ALL device data (identity, prefs, contacts, channels) on the SD card
// under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE
// the data loads, so changing it needs a reboot. Key "use_sd" in the "touch"
+44
View File
@@ -80,6 +80,50 @@ bool touchPrefsSetUseMiles(bool use_miles);
bool touchPrefsGetTilesFromSd();
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);
/* App drawer: large grid (one fewer column → bigger icons + labels, for low vision).
* Default false = the compact grid (T-Deck 4 cols / Heltec V4 3 cols). */
bool touchPrefsGetAppGridLarge();
bool touchPrefsSetAppGridLarge(bool on);
/** Idle light-sleep feature: when ON, the device enters light sleep while the
* screen is off and all activity gates pass. Default OFF. */
bool touchPrefsGetSleepIdle();
bool touchPrefsSetSleepIdle(bool on);
/* UI resolution scale (Tanmatsu): 0=100% (native 800x480), 1=150%, 2=200%. Applied at boot —
* LVGL renders at a lower resolution and the flush upscales to the panel. Reboot to apply. */
uint8_t touchPrefsGetUiScale();
bool touchPrefsSetUiScale(uint8_t scale);
/* T-Deck trackball interaction mode: false (default) = the trackball drives a soft
* mouse cursor; true = D-pad focus navigation (↑↓←→ move focus, ball click = enter),
* like the Tanmatsu. Applied live (no reboot). */
bool touchPrefsGetTbKeypad();
bool touchPrefsSetTbKeypad(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
* /meshcomod instead of internal SPIFFS. T-Deck only; read at boot before data
* loads, so changing it requires a reboot. Default false (SPIFFS). */
+9
View File
@@ -177,6 +177,11 @@ bool wifiConfigConsumeApplyRequest() {
}
void wifiConfigApply() {
#if defined(HAS_TANMATSU)
printf("[WIFI] apply radio_en=%d hasRuntime=%d mode=%d status=%d\n",
(int)wifiConfigGetRadioEnabled(), (int)wifiConfigHasRuntime(),
(int)WiFi.getMode(), (int)WiFi.status());
#endif
if (!wifiConfigGetRadioEnabled()) {
WiFi.disconnect(true);
delay(50);
@@ -188,9 +193,13 @@ void wifiConfigApply() {
char pwd[WIFI_CONFIG_PWD_MAX];
wifiConfigGetSsid(ssid, sizeof(ssid));
wifiConfigGetPwd(pwd, sizeof(pwd));
WiFi.mode(WIFI_STA); // esp_wifi_remote needs an explicit STA mode before begin()
WiFi.disconnect();
delay(100);
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
#if defined(HAS_TANMATSU)
printf("[WIFI] begin ssid='%s' pwlen=%d\n", ssid, (int)strlen(pwd));
#endif
}
#endif
+15 -1
View File
@@ -12,6 +12,7 @@
#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 "ui-touch/TouchSleep.h" // idle light-sleep controller (loopEnd called at end of loop())
#endif
// Believe it or not, this std C function is busted on some platforms!
@@ -399,7 +400,9 @@ void setup() {
* happens later in loop(); this just inits the stack.) */
if (want_wifi) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false);
WiFi.setAutoReconnect(true); // let esp_wifi rejoin on its own after a beacon loss / AP reboot
// (was false — the 10 s poll below was the ONLY recovery, and a
// bare begin() on a wedged supplicant is a no-op -> never reconnected)
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
@@ -557,6 +560,10 @@ void loop() {
wifiConfigGetSsid(ssid, sizeof(ssid));
wifiConfigGetPwd(pwd, sizeof(pwd));
if (strlen(ssid) > 0) {
// A bare begin() on a supplicant wedged after a silent drop (AP reboot /
// beacon loss) can be a no-op — clear its state first so this forces a
// fresh association. Backs up setAutoReconnect(true) for the stuck case.
WiFi.disconnect(false, true);
WiFi.begin(ssid, pwd[0] ? pwd : nullptr);
}
}
@@ -666,4 +673,11 @@ void loop() {
#if defined(ESP32_PLATFORM)
board.pollHttpOtaReboot();
#endif
#if defined(HAS_TOUCH_UI)
// Idle light-sleep gate: evaluated every loop tick. g_enabled is false by
// default (Task 1 is inert); Task 2 sets it from the NVS pref and wires
// the real predicates so the gate can actually pass and arm light sleep.
touchSleep::loopEnd(millis());
#endif
}
+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);
};
+78
View File
@@ -0,0 +1,78 @@
#include "TouchSleep.h"
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
namespace touchSleep {
namespace {
// When the gate passes (device parked: screen off, no companion client, WiFi+BLE
// off, on battery, mesh idle) we THROTTLE the main loop with vTaskDelay instead of
// entering esp_light_sleep.
//
// Why not real light sleep: manual esp_light_sleep_start() from the Arduino loop
// task trips the Interrupt Watchdog in this build (dual-core, no PM framework,
// INT_WDT on both cores @ 300 ms, always-on companion/LVGL/USB tasks) — reliably,
// regardless of duration or wake-source handling. vTaskDelay is a standard scheduler
// yield: the FreeRTOS idle task halts the CPU (WFI) between 1 ms ticks, trimming the
// idle busy-spin draw, with zero watchdog/hang risk. The radio stays in RX; an
// incoming packet is serviced on the next loop iteration (<= THROTTLE_MS late).
constexpr uint32_t THROTTLE_MS = 50;
uint64_t g_acc_idle_us = 0; // cumulative time spent parked/throttled
Hooks g_hooks = {};
TransitionCb g_transition = nullptr;
bool g_enabled = false; // default OFF; driven from NVS via touchPrefsGetSleepIdle() at init
bool g_asleep_regime = false;
uint32_t g_cycle_count = 0; // throttle cycles while parked (liveness counter)
WakeReason g_last_reason = WakeReason::None;
bool gatePasses() {
if (!g_enabled) return false;
if (!g_hooks.screenOff || !g_hooks.screenOff()) return false;
if (!g_hooks.noClient || !g_hooks.noClient()) return false;
if (!g_hooks.wifiOff || !g_hooks.wifiOff()) return false;
if (!g_hooks.bleOff || !g_hooks.bleOff()) return false;
if (!g_hooks.onBattery || !g_hooks.onBattery()) return false;
if (!g_hooks.meshIdle || !g_hooks.meshIdle()) return false;
return true;
}
void emitTransition(bool entering) {
if (g_transition && g_hooks.epochNow) g_transition(g_hooks.epochNow(), entering);
}
} // namespace
void begin(const Hooks& hooks) { g_hooks = hooks; }
void onTransition(TransitionCb cb) { g_transition = cb; }
void setEnabled(bool on) { g_enabled = on; }
bool enabled() { return g_enabled; }
void loopEnd(uint32_t /*now_ms*/) {
const bool pass = gatePasses();
if (!pass) {
if (g_asleep_regime) { g_asleep_regime = false; emitTransition(false); } // sun — resumed activity
return;
}
if (!g_asleep_regime) { g_asleep_regime = true; emitTransition(true); } // moon — parked
// Yield the CPU to the idle task for THROTTLE_MS (replaces esp_light_sleep_start —
// see the note above). Standard FreeRTOS call: cannot starve a watchdog or hang.
const uint64_t t0 = esp_timer_get_time();
vTaskDelay(pdMS_TO_TICKS(THROTTLE_MS));
g_acc_idle_us += (uint64_t)(esp_timer_get_time() - t0);
g_cycle_count++;
g_last_reason = WakeReason::Timer; // a throttle is a timed yield
}
bool isSleeping() { return g_asleep_regime; } // true while parked/throttling
uint32_t wakeCount() { return g_cycle_count; } // throttle cycles while parked
WakeReason lastWakeReason() { return g_last_reason; }
uint8_t pctAsleep() {
const uint64_t up = (uint64_t)esp_timer_get_time();
if (up == 0) return 0;
uint64_t pct = (g_acc_idle_us * 100ULL) / up;
return pct > 100 ? 100 : (uint8_t)pct;
}
} // namespace touchSleep
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <stdint.h>
// Idle power-save controller for the T-Deck (ESP32-S3). When the device is parked
// (screen off, on battery, standalone) it throttles the main loop with vTaskDelay
// so the FreeRTOS idle task can halt the CPU between ticks. (Real esp_light_sleep
// trips the Interrupt Watchdog in this dual-core / no-PM build — see TouchSleep.cpp
// and .notes/2026-06-18-light-sleep-rx-design.md for the full story.)
//
// Decoupled from UI/mesh by predicate hooks supplied by the integration layer
// (UITask.cpp), so this module pulls in no UI/mesh/LVGL headers.
namespace touchSleep {
enum class WakeReason : uint8_t { None, Timer, Packet, Touch, Button, Other };
struct Hooks {
bool (*screenOff)(); // display is off
bool (*noClient)(); // no companion client on any transport
bool (*wifiOff)(); // WiFi fully off
bool (*bleOff)(); // BLE fully off
bool (*onBattery)(); // running on battery (no USB / charge source)
bool (*meshIdle)(); // radio not mid-RX AND send queue empty
// ms until the soonest wake-forcing deadline (advert / clock alarm),
// or UINT32_MAX when nothing must wake us:
uint32_t (*nextWakeForcingDueMs)(uint32_t now_ms);
uint32_t (*epochNow)(); // wall-clock epoch seconds (for the event log)
};
// regime transition: entering==true -> went to sleep (moon)
// entering==false -> woke to activity (sun)
using TransitionCb = void (*)(uint32_t epoch, bool entering);
void begin(const Hooks& hooks); // install hooks; arm static GPIO wake config once
void onTransition(TransitionCb cb);
void setEnabled(bool on); // feature toggle (driven by the NVS pref)
bool enabled();
void loopEnd(uint32_t now_ms); // call at the very END of the main loop()
// instrumentation (read by the settings / diag UI)
bool isSleeping(); // currently in the asleep regime
uint32_t wakeCount(); // cumulative meaningful wakes
WakeReason lastWakeReason();
uint8_t pctAsleep(); // % wall-time asleep since boot (0..100)
} // namespace touchSleep
+4418 -527
View File
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -29,7 +29,7 @@ enum class TouchUiScreen : uint8_t { Home = 0, ChatInbox = 1, Contacts = 2, Sett
class UITask : public AbstractUITask {
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_THREAD_NAME = 32;
static const int MAX_SENDER_NAME = 24;
@@ -160,8 +160,10 @@ private:
TouchUiScreen _touch_screen;
bool _active_dm_contact_set;
uint8_t _active_dm_contact_pub[32];
bool _history_dirty;
unsigned long _next_history_flush_ms;
bool _threads_dirty;
unsigned long _next_threads_flush_ms;
bool _msgs_dirty;
unsigned long _next_msgs_flush_ms;
void userLedHandler();
@@ -213,10 +215,15 @@ private:
void appendComposerText(const char* text);
void backspaceComposerChar();
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);
bool loadHistoryFromStorage();
bool saveHistoryToStorage();
bool loadThreadsFromStorage();
bool loadMsgsFromStorage();
bool loadLegacyHistoryFromStorage();
bool saveThreadsToStorage();
bool saveMsgsToStorage();
void setCurrScreen(UIScreen* c);
@@ -230,8 +237,10 @@ public:
_next_mesh_thread_refresh = 0;
_active_dm_contact_set = false;
memset(_active_dm_contact_pub, 0, sizeof(_active_dm_contact_pub));
_history_dirty = false;
_next_history_flush_ms = 0;
_threads_dirty = false;
_next_threads_flush_ms = 0;
_msgs_dirty = false;
_next_msgs_flush_ms = 0;
_last_input_ms = 0;
_screen_timeout_ms = 20000; // overridden by NVS in begin()
_screen_off = false;
@@ -255,6 +264,7 @@ public:
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 markActiveThreadRead(); // clear the currently-open thread's unread (viewing == read)
void markAllThreadsRead(); // clear every thread's unread count
bool threadHasMention(int idx) const; // unread @mention of me in this thread
int getThreadCount(bool channel_mode, int out_indexes[], int max_out) const;
+148
View File
@@ -0,0 +1,148 @@
/*******************************************************************************
* Size: 16 px
* Bpp: 4
* Opts: --no-compress --bpp 4 --size 16 --font /Applications/MeshCore.app/Wrapper/Runner.app/Frameworks/App.framework/flutter_assets/packages/font_awesome_flutter/lib/fonts/fa-solid-900.ttf -r 0xF185 -r 0xF186 --format lvgl --lv-font-name sleepicons_font -o src/ui-touch/sleepicons_font.c
******************************************************************************/
#include "lvgl.h"
#ifndef SLEEPICONS_FONT
#define SLEEPICONS_FONT 1
#endif
#if SLEEPICONS_FONT
/*-----------------
* BITMAPS
*----------------*/
/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+F185 "" */
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x8, 0xa1, 0x0, 0x1a, 0x80,
0x0, 0x0, 0x0, 0x0, 0xc, 0xfe, 0x55, 0xef,
0xc0, 0x0, 0x0, 0x0, 0x0, 0xf, 0xff, 0xff,
0xff, 0xf0, 0x0, 0x0, 0x0, 0x0, 0x4f, 0xff,
0xff, 0xff, 0xf4, 0x0, 0x0, 0x8, 0xcf, 0xff,
0xd5, 0x11, 0x5d, 0xff, 0xfc, 0x80, 0xa, 0xff,
0xfd, 0x18, 0xee, 0x81, 0xdf, 0xff, 0xa0, 0x1,
0xef, 0xf5, 0x8f, 0xff, 0xf8, 0x5f, 0xfe, 0x10,
0x0, 0x5f, 0xf1, 0xef, 0xff, 0xfe, 0x1f, 0xf5,
0x0, 0x0, 0x5f, 0xf1, 0xef, 0xff, 0xfe, 0x1f,
0xf5, 0x0, 0x1, 0xef, 0xf5, 0x8f, 0xff, 0xf8,
0x5f, 0xfe, 0x10, 0xa, 0xff, 0xfd, 0x18, 0xee,
0x81, 0xdf, 0xff, 0xa0, 0x8, 0xcf, 0xff, 0xd5,
0x11, 0x5d, 0xff, 0xfc, 0x80, 0x0, 0x0, 0x4f,
0xff, 0xff, 0xff, 0xf4, 0x0, 0x0, 0x0, 0x0,
0xf, 0xff, 0xff, 0xff, 0xf0, 0x0, 0x0, 0x0,
0x0, 0xc, 0xfe, 0x55, 0xef, 0xc0, 0x0, 0x0,
0x0, 0x0, 0x8, 0xa1, 0x0, 0x1a, 0x80, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0,
/* U+F186 "" */
0x0, 0x1, 0x7c, 0xee, 0x0, 0x0, 0x0, 0x4,
0xef, 0xfe, 0x30, 0x0, 0x0, 0x5, 0xff, 0xff,
0x20, 0x0, 0x0, 0x1, 0xff, 0xff, 0x80, 0x0,
0x0, 0x0, 0x7f, 0xff, 0xf2, 0x0, 0x0, 0x0,
0xc, 0xff, 0xff, 0x0, 0x0, 0x0, 0x0, 0xef,
0xff, 0xf1, 0x0, 0x0, 0x0, 0xe, 0xff, 0xff,
0x40, 0x0, 0x0, 0x0, 0xcf, 0xff, 0xfc, 0x0,
0x0, 0x0, 0x7, 0xff, 0xff, 0xf8, 0x0, 0x0,
0x0, 0x1e, 0xff, 0xff, 0xfa, 0x20, 0x0, 0x0,
0x4f, 0xff, 0xff, 0xff, 0xdc, 0xb0, 0x0, 0x4e,
0xff, 0xff, 0xff, 0xf5, 0x0, 0x0, 0x17, 0xce,
0xfc, 0x81, 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 = 256, .box_w = 18, .box_h = 18, .ofs_x = -1, .ofs_y = -3},
{.bitmap_index = 162, .adv_w = 192, .box_w = 13, .box_h = 14, .ofs_x = 0, .ofs_y = -1}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61829, .range_length = 2, .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 sleepicons_font = {
#else
lv_font_t sleepicons_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 = 18, /*The maximum line height required by the font*/
.base_line = 3, /*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 SLEEPICONS_FONT*/
+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*/