Compare commits

...
160 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 99423e5bf8 touch: beta_47 — Discover app (find/list/map nearby nodes + wardriving), Attaky release wiring, P4 LCD
- New Discover app: active NODE_DISCOVER sweep -> signal-ranked nearby list, tap-to-add-contact, GPS wardriving log (SD CSV) + signal-coloured coverage overlay on the map. Board-agnostic.
- T-Display P4 TFT-LCD (HI8561) variant support (WADA_P4_LCD build; AMOLED bin untouched).
- Wire the Attaky Core board (#158, @attakygit) into the release matrix + web flasher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:45:05 +02:00
Kaj Schittecat cab0c81208 chore: refresh Mesh America catalog for beta_46 2026-07-19 16:24:58 +02:00
Attaky b67f559d15 Attaky mesh series (#158)
* attaky_mesh_series: add board variant with FT6636 touch and landscape UI

* attaky_mesh_series: P3 battery (MAX17048) + GPS + shared-I2C mutex

- AttakySharedI2C: one Wire.begin() + a mutex serializing the shared board
  I2C bus (SDA=2/SCL=1) across the FT6636 touch poll (core 0), the MAX17048
  fuel gauge (core 1), and the future AW9523 keyboard. Arduino's TwoWire
  buffers are not concurrency-safe.
- board.begin() owns the authoritative bus init; the touch driver now takes
  the lock around every transaction instead of its own Wire.begin().
- getBattMilliVolts(): real MAX17048 VCELL read (78.125 uV/LSB -> mV = raw*5/64),
  2 s cache + lock, replacing the 4000 mV stub. Upstream maps mV -> % itself.
  Hardware-verified ~4058 mV.
- GPS: rename the dead GPS_BAUD flag to GPS_BAUD_RATE (the core reads that; 9600
  only worked via the #else default). Pins verified on hardware: ESP RX = IO17
  receives ATGM336H NMEA; MeshCore's module-centric PIN_GPS_TX=17 maps correctly.

* attaky_mesh_series: Control_Keyboard_(Bottom) dual-AW9523 keyboard

Add the Control_Keyboard_(Bottom)_1.0 module as an ADDITIVE hardware
keyboard on the touch board. Two AW9523 expanders (0x5A left / 0x5B
right) each scan a 5x5 matrix over the shared board I2C bus, serialized
through the AttakySharedI2C mutex alongside the FT6636 touch poll and the
MAX17048 fuel gauge.

The driver emits final bytes (printable ASCII, backspace, enter, space)
with momentary Shift resolved to uppercase in-driver. UITask injects them
into the focused textarea exactly like the web-mirror keystroke path, so
CAP_KEYBOARD stays 0 and the on-screen keyboard is not suppressed (touch
typing does not regress). Focus-gated: the matrices are only scanned
while an editable field is bound, so a key has zero side effect when
nothing is focused.

Hardware-verified, both halves: q/w/e/r/t (0x5A) + y (0x5B), Shift
uppercase, backspace, space, enter; on-screen keyboard intact.

* attaky_mesh_series: use reliable 115200 upload speed

* attaky_mesh_series: use core default GPS baud

* attaky_mesh_series: restore GPS_BAUD setting

* attaky_mesh_series: rename PlatformIO environment
2026-07-19 13:58:05 +02:00
Tesso Codemonkey 85e8681fef pager: keep encoder focus inside the chat message list, remap composer nav (#162)
* pager: keep encoder focus inside the chat message list, remap composer nav

The rotary encoder's plain turn stepped LVGL's focus group directly, which
only ever contains the currently-materialized bubbles (the message list is
virtualized; loading more history is scroll-driven, not focus-driven). So
turning past the first/last *loaded* bubble escaped the list onto the
header/composer even with plenty of un-loaded history in that direction.
A turn at that edge now scrolls the list instead when more messages exist
(reusing the existing free-scroll helper), which triggers virtualization to
load the next batch; focus only leaves the list at the true oldest/newest
message. Fast turning folds into a pending target so it doesn't escape
mid-load, and the render's focus re-aim now runs synchronously so the
transient auto-focus LVGL assigns a deleted row's neighbor never paints.

Also removes the two touch-only jump-to-oldest/latest arrows on this board
(no touchscreen to tap them) and remaps the chat's key bindings now that
plain turns stay in the list:
- Orange/Alt tap: jump to the newest message + focus the composer (was
  Backspace's job).
- Backspace tap: jump to the first unread message below the "NEW ----"
  divider, or the newest message if nothing's unread.
- Composer row edges: Send+NEXT reaches the channel-settings gear (falls
  back to the newest bubble in a DM, which has no gear); the quick-reply
  chip+PREV goes straight to the newest bubble.

TLORA_PAGER_SHORTCUTS.md documents the new chat-screen behavior.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>

* fix scroll

---------

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-19 13:58:02 +02:00
Kaj Schittecat 3fbc3e3582 chore: refresh Mesh America catalog for beta_45 2026-07-16 23:02:56 +02:00
Kaj SchittecatandClaude Opus 4.8 22a08c6df6 release-notes: beta_45 -> cumulative stable changelog (since beta_40)
Rewrites the beta_45 notes as the full delta since the last stable (beta_40)
for the stable promotion: browser remote control (VNC/REMOTE + web app), the
on-device Web browser + tappable chat links, three new boards (T-LoRa Pager,
T-Display P4, V4-R8), and fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:02:43 +02:00
041dd60b8d touch: beta_46 — control-center redesign, Do Not Disturb, contact safety + board fixes
- Contact-loss fix (microSD devices): atomic temp+swap saveContacts, unified robust
  boot SD-mount ladder, and a storage-truth line in the Store-on-SD setting.
- Heltec V4-R8: route MISO for the shared FSPI bus so the microSD mounts; wake-from-sleep
  freeze fixed (panel sleep goes through LovyanGFX's own bus, not the HSPI shim).
- Do Not Disturb (originally PR #156 by Tesso Codemonkey / @codemonkeybr): scheduled
  sound-silencing window in Settings > Sound + a status-bar bell.
- Control-center redesign: round icon-only chips that spread to fill each row (T-Deck 2x4,
  V4 portrait 2 rows), frosted-glass panel, real lock glyph + dynamic bell/bell-slash DND
  chip + keyboard icon+word chip (new cc_icons_16 FontAwesome font), per-chip confirmation
  toasts, press-grow clip fix, and a V4-portrait status-bar BLE/clock overlap fix.
- LilyGo T-Pager: map tile cache falls back to the microSD under the launcher (fixes the
  "storage error / reflash the tiles partition" dead-end).

Co-Authored-By: Tesso Codemonkey <798026+codemonkeybr@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:30:05 +02:00
Kaj SchittecatandClaude Opus 4.8 36e49d4d9e site: add two community videos (Signal Trek, KDHD Stuff 'WadaMesh Update')
Newest-first: Signal Trek's 'The Firmware That Finally Makes It Worth It'
(~Jul 12) on top, then KDHD Stuff's 'WadaMesh Update! Supercharge Your T-Deck'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:00:42 +02:00
Kaj SchittecatandClaude Opus 4.8 ae02fb6238 release: beta_45 infra — T-LoRa Pager + T-Display P4 in the flasher matrix
- release.sh: add both T-LoRa Pager envs (LR1121 + SX1262) to the PIO build set.
- gen-flasher-meta.py: add pager (×2) + T-Display P4 manifests; P4 carries
  chipFamily ESP32-P4 (per-board now, not hardcoded S3).
- site: LilyGo T-LoRa Pager board card (both radio variants) with the
  partially-supported badge; the P4 card already landed last cut.
- release-notes/beta_45.txt (leads with @codemonkeybr's pager port).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:32:31 +02:00
Kaj SchittecatandClaude Opus 4.8 fd0b952620 fix: R8 I2C freeze (shared-bus wedge) + Tanmatsu Wi-Fi settings unreachable
- Heltec V4-R8: the CHSC6x touch ran on a 2nd I2C controller (Wire1) bound to the
  SAME pads (17/18) as the board Wire (RTC + sensors), driven from two cores — a
  classic SDA-stuck-low wedge = the tester's random hard-freezes-needing-reset.
  Touch now shares the one board Wire (V4CAP_WIRE selector), so arduino-esp32's
  per-instance TwoWire mutex serialises all access, the proven T-Deck pattern.
- Wi-Fi settings: the join/details/hidden sheet floats on the base layer above the
  settings page; navMaybeRebuild only recognised the settings sheet + chat + top
  layer as focus roots, so on no-touch/keypad-nav boards (Tanmatsu) its SSID/
  password fields + Join button were never collected into the focus group and
  couldn't be selected. Add s_wifi_sheet as a nav focus root (priority over the
  settings sheet beneath it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:27:20 +02:00
Kaj SchittecatandClaude Opus 4.8 f029dcc744 site: per-board support-level badges + T-Display P4 board box
Every flasher board card now carries a support badge with a hover popover:
Heltec V4 + TFT and the T-Deck are 'Fully supported' (the two primary dev
boards); ThinkNode M9, RAK Tap V2, Heltec V4-R8, Tanmatsu and the new
T-Display P4 are 'Partially supported' — the popover spells out that the
core (LoRa mesh communication, chat, phone-app link) works while the rest
is still settling. New T-Display P4 card wired to the beta channel
(manifest-tdisplay-p4.json), same new-board pattern as the M9/RAK cards.

NOTICE: extend the ES8311 attribution to cover the P4's P4Audio.cpp
(esp-bsp-derived register sequence, re-based onto Arduino Wire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:24:55 +02:00
Kaj Schittecat be0a49255c Merge PR #149: LilyGo T-LoRa Pager port (LR1121 + SX1262) by @codemonkeybr
Full keyboard+encoder-driven board port: TCA8418 QWERTY matrix, rotary encoder
focus-nav, ST7796 display, ES8311 notification sound, both radio variants.
Hardware-verified by the author against a live mesh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	src/ui-touch/UITask.cpp
2026-07-15 19:16:51 +02:00
Kaj SchittecatandClaude Opus 4.8 2353c56adc p4: T-Display P4 port — web VNC/remote, time-sync hardening, brightness + sound
LilyGo T-Display P4 (ESP32-P4 + factory ESP-AT C6) reaches POC: LoRa mesh, Wi-Fi
companion (TCP:5000), GPS, SD, battery gauge, chat persistence, and now:

- Web VNC/REMOTE/terminal via a first-byte router on the single ESP-AT listener
  (HTTP verbs -> WS server, '<' frames -> phone companion; both live at once).
  C6Server gains a _begun gate so never-listening instances stay inert.
- VNC freeze fixed: bounded one-CIPSEND-chunk drains under the WS client mutex
  (a whole-band blocking write starved the main loop), fast-drop of stalled
  mirror clients, SEND-OK wait 8s->4s.
- Wi-Fi robustness: CWJAP? miss-streak tolerance, CIPSERVER liveness verify +
  re-arm (a re-association silently killed the listener until reboot).
- Time sync: HTTP-Date fallback when SNTP is blocked; ClockFloorRTC gains a
  MAX_PLAUSIBLE_EPOCH guard (all boards) after a garbage-future hardware RTC
  read (2043) latched the ratchet.
- Brightness: RM69A10 DCS 0x51 driven live (CC slider + persisted pref).
- Sound: ES8311 + NS4150B notification chimes (P4Audio, esp-bsp-derived
  register sequence on Wire1), full HAS_UI_SOUND surface + CC Screen/Sound
  sliders; ccVolumeReleaseCb's hardcoded tanBeep -> uiSoundPreview.
- tdisplay_p4/: IDF project (build.sh, c6_at AT-over-SDIO driver, sdkconfigs,
  fetch-deps.sh vendoring, .gitignore for local payloads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:13:23 +02:00
Tesso M Costa 0adcd26b78 Revert "pager: hide the touch-only chat jump-to-oldest/latest arrows"
This reverts commit 53a60dfbb2.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 21:52:21 -06:00
Tesso M Costa 53a60dfbb2 pager: hide the touch-only chat jump-to-oldest/latest arrows
Both floating jump arrows in the open-chat view are touch-only
affordances with no purpose on the T-LoRa Pager (no touchscreen), so
skip creating them on that board and leave the pointers null like
every other consumer already expects. The pager's existing
Backspace-tap "jump to latest" shortcut no longer depends on the
button object itself -- it checks chatVirtAwayFromBottom() directly
and calls chatVirtJumpToLatest(), the same virtualization-aware jump
the button's own click handler used.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 17:54:55 -06:00
Tesso M Costa e5cd766f73 pager: move Home-jump to Alt+Backspace, works everywhere including mid-edit
Alt+Shift previously did double duty: toggle Caps Lock while editing a
field, or jump Home otherwise. Splitting these apart -- Alt+Backspace is
now the dedicated Home-jump gesture, and unlike Alt+Shift it isn't
context-dependent: it works whether or not a field is being edited, so
it's reachable from anywhere without first backing out of what you're
typing. Alt+Shift outside a field is now a no-op instead.

PagerKeyboard.cpp suppresses a Backspace press entirely while Alt is
held (no '\b' ring-push, no hold-to-back/hold-to-unlock tracking) and
reports it as a one-shot chord instead, mirroring the existing Alt+Shift
chord plumbing. Also closes the accent/@-mention popups first, since
those live on lv_layer_top() outside the tab content the Home jump
switches away from.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 12:15:29 -06:00
Tesso M Costa 5c97d02fbe Merge remote-tracking branch 'origin/main' into tlora-pager-port-lr1121
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>

# Conflicts:
#	NOTICE
#	platformio.ini
#	src/ui-touch/UITask.cpp
#	src/ui-touch/device_caps.h
2026-07-14 10:45:53 -06:00
Tesso M Costa 01833d0365 docs: track T-LoRa Pager port tracker in git
Previously kept local-only (see f18929b/cca9a44), moving pager tracker
docs under gitignored dev-docs/. This one now ships in the repo instead,
per explicit request -- keep it current alongside pager code changes.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 09:44:42 -06:00
Tesso M Costa a95f976262 docs: note keyboard alternatives for the encoder's turn/click/hold gestures
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 09:43:26 -06:00
Tesso M Costa 2540e230c6 docs: document the @-mention picker's encoder navigation
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 09:29:38 -06:00
Tesso M Costa 6073b6fa4e ui: app-drawer tiles use their own accent tint for keyboard/encoder focus
App tiles previously fell through to navFocusCb's generic white
reverse-video fill when focused via keyboard/encoder nav, instead of
the accent-tinted LV_STATE_PRESSED look they already show on a touch
press -- a solid white block over a small rounded icon chip reads as a
glitch, not a highlight, and looked inconsistent with the touch-driven
boards. Tiles now opt into the same accent tint for both states.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 09:29:06 -06:00
Tesso M Costa 57802eba3d pager: make the @-mention contact picker encoder/keyboard-navigable
Typing "@partial" in a composer popped up a touch-only contact list
(NAV_SKIP_FLAG cells, same as the accent-variant box before it got the
same treatment) -- on this board's no-touch input model there was no
way to actually pick a name from it. Mirrors the accent box's pager nav
(rotary encoder walks the highlighted cell, Enter/click confirms,
Backspace cancels without touching the typed text), but grabs the
encoder the instant the list appears rather than requiring an explicit
Fn+Space arm: unlike the accent box (which pops up after almost every
letter typed and must not steal focus from ongoing typing), the mention
list only shows once the user has deliberately typed "@partial" looking
for someone to pick, so there's no ongoing typing to protect.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 09:28:29 -06:00
Tesso M Costa b16206bf7d pager: Fn+Shift toggles Caps Lock only while editing a field, else jumps Home
The Fn(Alt)+Shift chord previously toggled Caps Lock unconditionally,
even with no text field focused to see the change reflected in --
reported as surprising and purposeless outside of typing. PagerKeyboard
now only reports the chord (pagerKeyboardConsumeAltShiftChord()); the UI
decides the effect: Caps Lock toggle while actually editing a field,
otherwise a direct jump to the Home screen (mirrors the "ta" derivation
handleHwKey() already uses to tell bound-but-unfocused fields apart from
one actually being edited).

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 08:46:55 -06:00
Tesso M Costa c4c28d493d pager: rotary encoder short click opens the message action menu
A focused chat bubble already opened its Ack/Mention/Copy/Info/Block
action menu on Enter (navEnterBubble), but the encoder's short click
only sent a plain ENTER, which a bubble doesn't otherwise react to.
Mirrors handleHwKey()'s Enter branch so both inputs agree.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 08:46:18 -06:00
Tesso M Costa 8e7d6ae902 docs: add T-LoRa Pager keyboard & rotary encoder shortcut guide
User-facing reference for MeshCore users new to this board's touch-less
input model: keyboard layout, Shift/Caps, accent-variant picker, sliders,
map panning, keyboard backlight, and a full gesture quick-reference.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-14 08:27:05 -06:00
Kaj SchittecatandClaude Opus 4.8 a87ebe1e81 site: tidy the per-board download link — two-line 'Download .bin' + version caption
The first cut used an inline-flex link that wrapped and scattered the icon, text
and version across the narrow card. Restructure as a clean teal 'Download .bin'
row with the version as a small muted caption underneath; drop the double spacing
(the .board flex gap already separates it from Connect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:11:27 +02:00
Kaj SchittecatandClaude Opus 4.8 69ffe71bfe site: standalone flasher — per-board "download the .bin" links (find them where Connect is)
Community feedback: the only .bin download was buried in the Launcher path, so
people couldn't find a download at all. Add a compact "or download the .bin" link
to every standalone board card, right under its Connect button. Each follows the
Stable/Beta toggle (beta-only boards stay pinned to latest-beta) and shows the
exact version that feed serves (both channel tags fetched once so every link is
accurate). Downloads the full merged image — the same build Connect flashes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:03:43 +02:00
Kaj SchittecatandClaude Opus 4.8 e617bf306c site: Launcher flasher page — Stable/Beta toggle + "you're downloading version X" box
Community feedback (gubbinsgalore): on the Launcher install page there was no way
to tell which version the "Download .bin" gives you (it's the version-less
wadamesh-tdeck.bin), and the Stable/Beta channel toggle only existed on the
Standalone page — so Launcher users were pinned to stable and couldn't grab a
beta app image.

- Add the same Stable/Beta toggle to the Launcher page (the existing wmSetChannel
  JS already drives dl-tdeck + keeps both toggles in sync).
- Add a version box under the download, auto-filled from version.json and updating
  with the toggle: "You're downloading version <tag> · <channel>".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:49:31 +02:00
Kaj SchittecatandClaude Opus 4.8 687eb3865c touch: add Heltec WiFi LoRa 32 V4-R8 board (8 MB octal PSRAM + Expansion Kit V2 SD)
The V4-R8 uses an ESP32-S3R8 (8 MB OCTAL PSRAM) which claims GPIO33-37, so
Heltec moved every control signal off them and the Expansion Kit V2 rebuilt the
display/touch/SD block onto a hardware-SPI bus:
  Vext 36->40 (active-LOW) . GPS_EN 34->42 . LED 35->46 . ADC_Ctrl removed
  TFT (LovyanGFX HW-SPI): MOSI=15 SCK=16 MISO=45 CS=47 DC=48 RST=21 BL=44
  touch (CHSC6x, shares board I2C 17/18) . micro-SD (shared TFT SPI, CS=3)

The build defines HELTEC_LORA_V4_TFT too, so it reuses all the V4 touch-UI code;
HELTEC_LORA_V4_R8 gates only the deltas. 8 MB enables the on-device web browser
(CAP_WEB_BROWSER) and the SD card (CAP_SD). Also wires the board into the release
+ web-flasher pipeline (release.sh, gen-flasher-meta.py, the flasher picker).

UNTESTED — no V4-R8 hardware yet. Pin sources: Meshtastic heltec_v4_r8 variant,
Heltec V4-R8 pinmap, and the Expansion_board_V2.03 schematic. All three touch
envs build clean; V4/T-Deck unregressed. On-device TODO: CHSC6x shared-Wire on
17/18, SD_CS=3, and the V2 PAM8904 buzzer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:27:40 +02:00
Tesso M Costa 9881bb0038 pager: WASD pans the Map tab, move slider nudge to Q/E
Reuses mapNudge() (previously Tanmatsu's Ctrl+Arrow-only helper, now
shared) so W/A/S/D pan the map north/west/south/east on this board's
touch-less Map tab. Frees up D from the slider-nudge binding, which
moves to Q/E so the two gestures don't collide when a slider (e.g. the
map zoom bar) is focused while WASD panning is in play.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-13 13:15:16 -06:00
Tesso M Costa 4b0c2e9de1 pager: add tlora_pager_sx1262_companion_radio_touch env
Milestone 10: serve pager units sold with SX1262 instead of LR1121.
Same board JSON, same variant dir, same NSS/RESET/BUSY/DIO1/SCLK/MISO/
MOSI pins as the LR1121 env -- confirmed against trail-mate's own
working SX1262 pager reference that it's the same physical radio slot,
just a different chip populated. RADIO_CLASS/WRAPPER_CLASS swap to the
core fork's CustomSX1262/CustomSX1262Wrapper, the exact same generic
classes the T-Deck already uses, so radio_init() just calls
radio.std_init(nullptr) -- no new radio C++ code needed, only build
flags.

TCXO voltage and the DIO2-as-RF-switch setting are NOT copied from the
T-Deck wholesale: trail-mate's reference for this exact PCB uses
RadioLib's own 1.6V TCXO default with no override, so
SX126X_DIO3_TCXO_VOLTAGE is left undefined here too (std_init() already
retries at 0.0f on an SPI-cmd failure). SX126X_DIO2_AS_RF_SWITCH=true
and SX126X_CURRENT_LIMIT=140 ARE carried over from the T-Deck, but
because both are tied to the shared LORA_TX_POWER=22 target (RadioLib's
default 60mA current limit silently caps real TX power below 22dBm on
any board, not something T-Deck-specific), not board-specific analog
circuitry.

Also fixes a real bug found compiling this for the first time:
target.h only ever included the LR1121 wrapper (vendored locally in
this variant dir), never the core lib's CustomSX1262Wrapper -- unlike
T-Deck/Heltec V4's own target.h, which already include it. RADIO_CLASS/
WRAPPER_CLASS resolved to nothing and every radio_driver.* call site in
MyMesh.cpp failed to compile.

Hardware-verified: flashed a real SX1262 unit via the documented
out-of-box recipe (full erase + merged image), boot log confirmed
radio init succeeded alongside the rest of the board, and a live
mesh test confirmed send/receive on the public channel, a custom
channel, and DM.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-13 11:34:43 -06:00
Tesso M Costa 43f040ec0a pager: make the accent-variant popup reachable via Fn+Space + encoder
The accent-variant popup (issue #22) that appears above the field when
typing a letter with accented variants was touch-only -- its cells are
NAV_SKIP_FLAG, deliberately excluded from the keyboard/encoder focus
group. On the pager (no touch at all) this made the popup pure visual
noise: it would appear and just sit there unreachable.

Added a small parallel nav path scoped to this board: Fn (Alt) held +
Space jumps into the popup (instead of typing a space or the usual
double-tap-cycle-layout action); the rotary encoder then walks the
highlighted variant; the encoder's own click confirms (reusing the
existing accentBoxCellCb via a synthesized CLICKED event, rather than
duplicating its delete-base-letter + insert-variant logic); Backspace
cancels without deleting the base letter. Everything else is swallowed
while active so a stray keypress can't leak into the message mid-pick.
Focus never actually leaves the text field throughout -- the popup's
cells stay outside the normal nav group the whole time -- so nothing
extra is needed to "return" focus after a pick or cancel.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-13 10:45:31 -06:00
Kaj SchittecatandClaude Opus 4.8 c22c76b704 docs: add Web browser section + screenshots to the site docs
New "Web browser" section in deploy/site/docs.html (and a TOC entry)
covering the on-device text browser and tappable chat links
("Open in web" / "Create QR"), with three new device screenshots
(app_web, app_web_links, app_web_qr). Existing screenshots unchanged.

Also render the reader page inline in the DOC_CAPTURE tour: the reader
paints from the UI-update poll, which doesn't run while the tour blocks
the loop, so the tour now drives the fetch-wait + render itself before
capturing the Web app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:08:43 +02:00
Tesso M Costa 79df5281e4 pager: real Shift-hold, Alt+Shift chord for Caps Lock
The Shift/Caps key previously toggled Caps Lock on every bare press --
a deliberate design choice reused from a working reference build, but
not conventional keyboard behavior. Changed to: held alone, it's a
momentary Shift (uppercases letters typed while held, same as a real
Shift key); held Alt then a Shift press instead chords into a toggle
for persistent Caps Lock. kCapsPos renamed kShiftPos to match its new
primary role -- zero other call sites reference it, and s_caps itself
is unchanged (still just "is Caps Lock engaged", only the trigger
changed).

Alt (row2,col0) and Shift (row2,col8) share a matrix row, so holding
all three of Alt+Shift+O or Alt+Shift+L at once phantom-ghosts a
'q'/'a' (diode-less-matrix 3-key rectangle, no software fix possible)
-- documented in the matrix-legend comment; harmless in practice since
the intended gesture is hold-Alt-tap-Shift-release-both, not holding
all three down together.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-13 09:57:39 -06:00
Kaj SchittecatandClaude Opus 4.8 308be40b96 touch: beta_44 — on-device text web browser + tappable chat links + French keyboard fix
- New "Web" app: on-device HTTPS text browser (strip HTML to readable text,
  tappable links, back/fwd/refresh, no proxy). Gated to 8 MB boards via
  CAP_WEB_BROWSER; hidden on the 2 MB V4 (can't do the TLS handshake).
- Tappable URLs in chat -> Open in web (8 MB) / Create QR (all boards).
- Drawn vector globe icon for the Web tile.
- Fix: French AZERTY on-screen keyboard lost its spacebar/OK/kbd-switch row
  (a stray "" mid-map terminated the LVGL button map early). Restored.
- V4 web fetch resilience (192->96->48->24 KB buffer fallback).
- Home signal box: "tap for details" cue back at the top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:26:05 +02:00
Kaj SchittecatandClaude Opus 4.8 8a2825e7fd docs: cache-bust the map screenshot URL past the Cloudflare edge
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:25:40 +02:00
Kaj SchittecatandClaude Opus 4.8 5f40653e58 docs: restore the previous (better) map screenshot
The DOC_CAPTURE tour re-captured the map from the live device, which showed a
less representative view. Keep the earlier curated map shot (tiles + markers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:23:44 +02:00
Kaj SchittecatandClaude Opus 4.8 d787d77365 docs: web control panel + Remote/VNC guide, refreshed device shots
- New "Remote & web app" docs section covering the browser control panel
  (Chats / Contacts / Discovered / Settings / Terminal), Screen mirror (VNC)
  and Remote UI, with a 7-shot phone gallery of the real web app.
- Refreshed all device screenshots via the DOC_CAPTURE tour (now also
  captures the Remote screen) and added the MQTT bridge settings page.
- New automated web-capture harness (scripts/doc/web-demo/): extracts the
  real page from firmware, serves it with mock data, screenshots via Playwright.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:18:20 +02:00
Kaj SchittecatandClaude Opus 4.8 831959ea5c touch: beta_43 - web control panel becomes a full remote app + V4 map-tile fix
The browser control panel gains full Chats / Contacts / Discovered / Settings
tabs (search, sort, filter, per-contact actions, notifications + live refresh,
ACK/repeats, add-from-discovered, live radio settings, quick-command picker).

Heltec V4 map tiles: fixed the "only the top renders" bug on the 2 MB-PSRAM
board with a persistent tile-buffer pool + viewport culling (no more per-render
128 KB churn/fragmentation). Adds an opt-in "Tile debug overlay" (Map options).

Also: V4 physical-keyboard input in remote/VNC, clearer remote-access screen,
complete Terminal help on device + web, WADAMESH header + status icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:29:08 +02:00
Kaj SchittecatandClaude Opus 4.8 4df391a283 touch: beta_42 - REMOTE fits the Heltec V4 + keyboard-less exit
Remote mode showed a black screen on 2 MB-PSRAM boards (Heltec V4): the
mirror ring/shadow were too big to allocate. Size the ring to one frame
and drop the remote render to 400x240 so the whole footprint is smaller
than VNC's (which already worked). Add a touch-and-hold (3 s) exit for
keyboard-less touch boards and an Exit button on the web page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:34:30 +02:00
Kaj SchittecatandClaude Opus 4.8 3f375f2fe4 touch: beta_41 - VNC + REMOTE web mirror (experimental)
Stream the live LVGL UI to a phone/desktop browser over the WS companion
server (VNC app) plus a headless REMOTE mode that renders the UI at a
browser-friendly resolution. Non-blocking per-client TX buffering, a
core-0 stream task guarded by a recursive mutex, an on-page Rotate
orientation toggle, and a small 480x288 render upscaled in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:32:25 +02:00
Kaj Schittecat 9da367d0bc chore: refresh Mesh America catalog for beta_40 2026-07-11 12:28:49 +02:00
Kaj SchittecatandClaude Opus 4.8 01a5238662 touch: beta_40 stable — Tanmatsu About version fix + comprehensive release notes
- Tanmatsu (ESP32-P4) now bakes FIRMWARE_VERSION="wadamesh <tag>" like the S3
  release path, and the app-store publish passes the exact beta tag into the IDF
  build — so the About page shows the real beta number instead of the MeshCore
  default "v1.16.0-touch".
- beta_40 stable release notes: rolls up beta_36 through beta_40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:27:00 +02:00
Tesso M Costa 1514e7638c pager: pick notification WAVs from the SD card
Completes the microSD work from the previous commit: wavOpen()'s
"sd:"-prefixed path branch, fmIsAudio()/fmOpenAudio() (the .wav ->
notification-sound chooser opened from the File Manager), and the
popup-dismiss registry entry for it (so Back/Escape via the encoder or
keyboard closes the picker, not just the on-screen X) are all widened
the same way as the previous commit's browsing support -- specific
call sites only, CAP_SD/CAP_FILESYSTEM stay 0 in device_caps.h.

Also updates that file's now-stale CAP_SOUND_FILES comment, which said
the pager's WAV picker only ever reads from SPIFFS -- it can read from
the SD card now too.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-10 16:25:16 -06:00
Tesso M Costa 8c7fb6af5c pager: mount the microSD card and browse it in the File Manager
pagerKeyboardSetBacklight-style dead-hardware situation: the board-level
SD wiring (power rail, card-detect, chip-select parking, shared-SPI bus
reuse) was already done during earlier bring-up, but nothing ever called
SD.begin(), so the slot was unusable. CAP_SD/CAP_FILESYSTEM already
existed for exactly this in device_caps.h, left at 0 with a "pager needs
its own SD wiring" comment.

CAP_SD turns out to gate ~37 call sites across UITask.cpp -- file
manager, WAV picker, DataStore contacts/channels routing, map-tile
fallback, wallpaper scanning, settings backup, screenshots. Scope for
this pass is file-manager browsing only (WAV picker follow-up commit);
flipping the top-level CAP_SD/CAP_FILESYSTEM macros would have silently
turned on all ~37 at once, several referencing T-Deck-only helpers never
vetted for this board. So device_caps.h is untouched -- CAP_SD/
CAP_FILESYSTEM stay 0 for the pager -- and only the specific call sites
needed for browsing are widened with `|| defined(TLORA_PAGER)`.

- fmSdTryMount()/fmSdUnmount()/fmSdClickCb(): widened, plus a pager-only
  sdSharedSPI() (reuses TFT_eSPI::getSPIinstance(), the same shared bus
  the radio already uses -- variants/lilygo_tlora_pager/target.cpp) and
  a card-detect pre-check via the XL9555 expander (PAGER_EXPAND_SD_DET,
  inverted polarity confirmed against LilyGoLib's own installSD()).
  Format support is explicitly out of scope -- the pager's SD row is
  tap-to-open only, no long-press-to-format.
- fmShowRoots(): pager SD row, shown only when a card is actually
  detected (no T-Deck-style "tap to mount" fallback -- the detect line
  makes "not present" unambiguous).
- fmIsSd(), the CAP_FILESYSTEM-gated Apps-drawer "Files" entry (dispatch
  + grid tile), and the insert/remove poll: widened the same way.

All ~30 other CAP_SD/CAP_FILESYSTEM sites (DataStore SD routing, tile
fallback, wallpaper, telemetry, settings backup, screenshots) are
untouched and stay exactly as unavailable on the pager as before.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-10 16:21:46 -06:00
Tesso M Costa c9033a3823 pager: wire up the keyboard backlight (off/on/auto)
pagerKeyboardSetBacklight() existed but nothing called it. The mode
state, NVS persistence, and boot restore were already generic across
every CAP_KEYBOARD board (s_kb_bl_mode, touchPrefsGetKbBacklight()),
so only two pieces were missing for this board:

- updatePagerKbBacklight(): a per-tick apply step, the pager's
  analog of the T-Deck's inline backlight calc / Tanmatsu's
  tanKbBacklightTick(). No brightness curve needed here (the pager's
  backlight is a plain LEDC PWM strip, not a dimmable slider like the
  T-Deck's) -- on/auto-active drive full duty (255), everything else
  (off, idle, screen-off, locked) drives 0. Runs unconditionally in
  the pager's loop branch so it can force the light dark the instant
  the screen goes off/locked.
- A "Keyboard backlight" row in Settings -> Keyboard (pager-only,
  sibling to the T-Deck's slider block) that cycles off/on/auto,
  mirroring the Control Center chip's semantics -- the Control Center
  itself is unreachable on this board (its launcher is touch-only).

Also fixed auto mode actually responding to input: neither
handleHwKey() nor updatePagerEncoder() called noteKbActivity() (the
function that stamps the auto-mode idle timer) -- they only called
the separate noteUserInput() (the screen idle-dim timer), so auto
mode would light briefly at boot and then never again regardless of
typing or turning the encoder. Both now stamp it too.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-10 10:40:25 -06:00
Kaj SchittecatandClaude Opus 4.8 946e73bb0b touch: beta_40 - card-less contacts-save stall fix + long-text auto-fit
Fixes:
- Card-less devices (Heltec V4) froze for seconds: a full contacts rewrite to
  SPIFFS on every advert refresh triggered a multi-second GC in the mesh loop.
  Coalesce advert-driven saves (a contact-set change still saves promptly); SD
  boards unchanged. Flush on reboot/power-off so nothing is lost.
- Long translations (FR/DE/...) overflowed fixed-width labels: new
  uiFitLabelWidth() shrinks the font to fit one line instead of clipping/wrapping
  - applied to the message menu, confirm dialogs, and app-drawer tiles.
- Home signal graph: "tap for details" hint moved to bottom-right so it no longer
  overlaps the "Sig" chip in longer languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:31:26 +02:00
Kaj Schittecat 326d34aaec site: add BFGNeil community video (newest first) 2026-07-10 13:05:02 +02:00
Kaj Schittecat d2fe2ed728 release-notes: beta_39 2026-07-09 23:39:20 +02:00
Kaj SchittecatandClaude Opus 4.8 9547ce6ede touch: chat-history persistence fix + faster delete + emoji tofu fix
Persistence (Leon P / MrWeavis, beta_38):
- Deleting a message or clearing a chat now persists synchronously, so it
  survives an immediate hardware reset instead of "coming back" (the tombstone
  used to ride the 30 s lazy SD flush, which a reset beats).
- The history file is now written compactly — only the used records,
  oldest-first, instead of all ~5000 ring slots every time. A typical chat
  writes ~48 KB instead of ~1.2 MB, so the synchronous delete/clear/reboot
  flush no longer hitches the UI (and normal message flushes are lighter too).
  Old history files still load (the reader derives its slot count from size).

Emoji picker:
- Baked the 14 glyphs that were listed but never in the font (umbrella, coffee,
  pizza, cake, rocket, camera, phone, laptop, calendar, antenna, e-mail, house,
  car, watch); they rendered as the notdef box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:38:37 +02:00
Kaj SchittecatandClaude Opus 4.8 49a8f31b8c touch(i18n): full untranslated-text audit + 307 new translation rows
Audited every user-visible string sink across the touch UI and closed the
gaps that had accumulated since the language table was last touched:

- TR() now strips a leading LVGL-symbol glyph (+ spaces) off the lookup key
  and rebuilds the label, so icon-prefixed menu/sheet rows ("<glyph>  Copy")
  translate instead of silently falling back to English.
- Wrapped 95 bare literal sinks (settingsRowLabel / showAlert / showConfirm /
  lv_label_set_text / dropdown / placeholder / checkbox) in TR(); unwrapped 29
  format-only strings that never should have been keys.
- Added 307 missing rows to kI18n[] covering all 11 non-English languages
  (NL DE FR ES IT RU UK BG SR EL PT-BR); keys byte-identical, printf
  placeholders and %-order preserved, Serbian in Cyrillic.

Builds green on T-Deck + Heltec V4; Tanmatsu app 3,169,296 B (107 KB under
the AppFS 50-page load ceiling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:38:07 +02:00
Kaj SchittecatandClaude Fable 5 ded72bb91a docs: DEVICES.md support matrix + README board list refresh
One page with every supported board, its install path, release channel
and maturity, plus the open hardware requests and the channel model.
The README board list now covers all five targets and links there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:26:47 +02:00
Kaj SchittecatandClaude Fable 5 cb08116eae flasher/site: ThinkNode M9 + RAK WisMesh Tap V2 web-flash support
Both new S3 boards are installable from the browser now: release.sh
builds and ships their bins per channel, gen-flasher-meta emits their
esp-web-tools manifests, and the site gets install cards for both.
The cards are pinned to the beta feed (with a note) until the first
stable promote that includes their bins; the wmSetChannel comment says
how to unpin then. beta_38's beta feed and GitHub release carry the
new bins retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:55:35 +02:00
Kaj SchittecatandClaude Fable 5 f0a7265dec touch: beta_38 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:15:59 +02:00
Kaj 814c8f5941 Merge pull request #130 from scratchdiver/steve/touch-chat-ui-pr
touch: virtualize chat scrolling (bubble + compact)
2026-07-09 13:14:44 +02:00
Kaj SchittecatandClaude Fable 5 76cd025bf5 tanmatsu: move identity + prefs to the SD card; open-probe the prefs load chain
The P4's internal FFat 'locfd' has a broken FAT metadata layer: open/
read/write work but f_stat/exists()/size() return garbage - and WHICH
garbage shifts with the build. At -Og the exists()-gated identity and
NodePrefs loads worked by luck; the -Os switch flipped the lie and the
device booted with a fresh identity and default name, and profile
changes never survived a reboot (the loads failed, not the saves).
Chat history was unaffected because it already lives on SD_MMC.

- DataStore::useSdMmcStorage(): full-store adoption of the card
  (/meshcomod root, identity store included), mirroring the T-Deck's
  useSdStorage(). The old card-root contacts3/channels2/adv_blobs from
  the secondary-FS era are renamed under /meshcomod.
- Tanmatsu boot: one-time rescue of identity + prefs off FFat by OPEN
  and READ probing (never exists()/size() on that FS), then adopt the
  card. FFat remains the no-card fallback only.
- DataStore::loadPrefs() probes candidates by opening and reading a
  byte instead of exists() - truthful on the P4, identical elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:00:54 +02:00
Kaj SchittecatandClaude Fable 5 d20241627c touch: flush chat history on the companion CMD_REBOOT (fix messages lost on app-initiated reboot)
Since the beta_35 lazy history writer, message-ring flushes are spaced
up to 30 s apart on the deep SD ring. Every on-device reboot/power path
compensates by calling persistHistoryNow() first, but the companion
protocol's CMD_REBOOT (the phone app's reboot button) went straight to
board.reboot(), dropping everything since the last flush - the newest,
just-read messages, which reads as 'my channel and DM messages get
deleted after a manual reboot' (Leon P, T-Deck under Launcher, beta_35
through beta_37).

persistHistoryNow() is now an AbstractUITask hook (default no-op, so
non-touch companions are unchanged) and CMD_REBOOT calls it before
rebooting, matching the power-menu contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:48:53 +02:00
Kaj SchittecatandClaude Fable 5 0b886e4fee tanmatsu: build the P4 app at -Os instead of -Og (boot-verified)
The app has been compiling at debug optimization since the port began,
costing ~300 KB of flash-mapped footprint against the launcher's hard
50-page (3,276,800 B) app-load ceiling. At -Os current main is
2,973,520 B, ~296 KB under the ceiling. Boot-verified on the device:
app starts, C6 esp-hosted link up, Wi-Fi associates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:39:55 +02:00
Kaj SchittecatandClaude Fable 5 a49726d4ce touch(tdeck): migrate SPIFFS data to SD on adoption + recovery copy button (beta_36 lost-profile fix)
Users who flipped 'Store data on SD' before beta_36 ran with the toggle
IGNORED (the flag never survived a reboot), so their identity, prefs and
contacts kept living on SPIFFS. When beta_36 made the flag finally take
effect, useSdStorage() pointed the store at an EMPTY card, the identity
store minted a brand-new node, and the user 'lost' their profile. The
data was never gone - it was orphaned on internal flash.

Two-part fix:
- Boot: adopting the card while SPIFFS holds an identity the card lacks
  now copies every SPIFFS file into SD:/meshcomod first (kv files
  flatten to the card's SdNvsPrefs layout). If the identity file fails
  to land, the device stays on SPIFFS for the boot instead of adopting
  a card without it.
- Settings -> Storage gains 'Copy internal data to SD' for the users
  already bitten (their card now holds a fresh identity the boot-time
  no-clobber gate rightly won't touch): confirmation, overwrite-copy of
  everything from internal flash, force the SD pref on, reboot.

Workaround until this ships: toggle 'Store data on SD' OFF and reboot -
the device reads internal flash again and the old profile returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:29:54 +02:00
Kaj SchittecatandClaude Fable 5 6aa1147eb3 touch: message action menu lays out two buttons per row
With Delete the menu carries up to 7 actions; a single column no longer
fit the 240-px screens without scrolling. Two per row (wider 200-px card,
odd last button on its own row) keeps the whole menu on one screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:06:52 +02:00
Kaj SchittecatandClaude Fable 5 d9f45405d5 touch: slim scroll-fade jump arrows + message delete + per-chat history wipe
- The chat jump buttons are bare arrows flush against the right edge now
  (28x36 transparent touch target, accent-colored glyph) instead of 40 px
  filled circles; they run at full opacity while the list scrolls and dim
  to 50 percent one second after the last scroll activity. The Tanmatsu
  keeps its F-key chip styling and geometry.
- The long-press message menu gains a Delete row: the message is
  tombstoned in the ring (thread tag blanked, so every walker skips the
  slot and live ring indexes stay valid) and both the chat and the thread
  list refresh.
- The chat/channel settings sheet (in-chat gear and list long-press)
  gains a 'Delete history' row for both DMs and channels: confirmation
  prompt, then every message of the thread is tombstoned. The thread row,
  channel membership and contact stay; only the conversation is wiped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:01:46 +02:00
Kaj SchittecatandClaude Fable 5 f04e01769d tanmatsu: build the P4 app at -Os instead of -Og
The app has been building at debug optimization since the port began.
At -Og the AppFS image sat 2.2 KB OVER the launcher's 50-page load
ceiling with the chat virtualization in; at -Os it is 2,980,688 bytes,
296 KB under the ceiling, retiring the size-locked-platform problem
(and likely speeding the UI up). Needs one on-device boot check before
anything ships with it - the P4 has never run an -Os build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:38:38 +02:00
Kaj SchittecatandClaude Fable 5 9785d8b7d1 fix(chat-virt): review fixes from the PR 130 deep review
- chatVirtReset no longer queues lv_obj_del_async on the divider: the
  empty-thread paths lv_obj_clean two lines later freed it first and the
  deferred lv_obj_del then ran on freed memory (confirmed UAF, plus a
  cross-panel variant when both panels are open). Downstream cleans own
  the widget; the reset only nulls the pointer now.
- Compact rows register bubbleLongPressMenuCb for LV_EVENT_LONG_PRESSED
  (the only event the callback acts on) instead of PRESSED/RELEASED/
  PRESS_LOST/DELETE, all of which it filtered out - the per-message menu
  was completely dead in compact mode.
- Bubble mode gets tap-to-resend back on failed sends (CLICKED handler
  plus the 'tap to resend' footer), matching the compact path and the
  pre-virtualization behavior.
- need_layout gains a content-generation guard (endpoint ring slots): at
  ring capacity n stays constant while content rotates, and equal-count
  thread switches reused stale offsets - rows rendered the next message's
  text at the previous message's position. The dead dirty_timeline rescue
  (cleared before its async consumer could read it) is removed.
- New chatVirtTryAppendLayout fast path: pure appends extend offsets for
  the new tail instead of re-measuring the whole thread (the full measure
  is ~100 ms of UI stall per received message on a 5000-message SD ring).
- lv_async_call failure no longer wedges the render busy flag; the nav
  focus fallback validates s_nav_objs[0] before focusing it (bubbles now
  recycle on scroll-end, so slot 0 can be one tick stale); dead
  chatVirtClearAllChildren/DeleteMsgsChild pair removed; stray
  FIRMWARE_BUILD_DATE bump reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:38:25 +02:00
Kaj bf96253308 Merge pull request #138 from 446564/finish-m9
m9 stable
2026-07-09 08:31:47 +02:00
Kaj SchittecatandClaude Fable 5 65a485d28c Merge main (RAK Tap V2 port) into finish-m9
Union of both PRs' landscape home-chart gate (TDECK || TANMATSU || RAK_TAP_V2 || THINKNODE_M9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:26:49 +02:00
Kaj SchittecatandClaude Fable 5 b2950a7f88 fix(m9): restore the T-Deck/M9 control-center card arm + drop unreachable HOME leftover + lock-hint typo
The control-center sizing lost its '#elif HAS_TDECK_GT911' line, which
put the 200 px set_size inside the Tanmatsu arm (shrinking its card
from 384 to 200) and dropped the T-Deck into the portrait else-arm.
Restored as TDECK || M9. Also removed the unreachable duplicate lines
after the M9_KEY_HOME return and fixed 'hole the d-pad' on the lock
screen hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:24:33 +02:00
Kaj SchittecatandClaude Fable 5 e95dd755e1 fix(rak_tap_v2): stray paren in the CAP_LARGE_SCREEN arm + keep power-menu height on existing boards
The bare ')' under #if CAP_LARGE_SCREEN broke the Tanmatsu (P4) build,
the only target that compiles that arm. The power-menu #else arm went
back to the original card_h = 206 so T-Deck/V4 keep their exact layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zy 2532ddc7ba fix(rak_tap_v2): gate shared-path changes behind HAS_RAK_TAP_V2 2026-07-09 08:09:21 +02:00
Ethan-chen1234-zyandCursor 6309503060 fix(rak_tap_v2): align target with upstream ClockFloorRTC and PubSubClient dep
Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zyandCursor b7bebd508b feat(rak_tap_v2): rebase onto upstream, add device_caps and README
Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zy b470097534 feat(rak_tap_v2): HW CDC upload, tiles partition, hide download mode
Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zy 1aab86b530 feat(rak_tap_v2): BOOT button support, USB CDC, upload fix
- PIN_USER_BTN: -1 -> 0 (enable BOOT key on GPIO0)
- Short press (<1s): toggle screen on/off (no hard-lock)
- Long press (>=1s): open power menu (Power off / Reboot / Cancel)
- Add ARDUINO_USB_CDC_ON_BOOT=1 (stable USB CDC COM port)
- boards/rak_tap_v2.json: use_1200bps_touch=false, wait_for_upload_port=false
  (manual download mode via BOOT+RESET, esptool upload on COM7)
- Remove unused boards/wiscore_rak3312.json
- Remove LovyanGFX from Heltec lib_deps (accidental regression in baseline)

Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zy bc129b5ffb feat(rak_tap_v2): LovyanGFX HW FSPI display driver (30+ fps)
Replace USE_PIN_TFT bit-bang software SPI (~8-10 fps) with LovyanGFX
hardware FSPI (SPI2_HOST, 40 MHz, DMA) matching Meshtastic's architecture.

Files:
- variants/rak_tap_v2/LGFXDisplay.{h,cpp}: new DisplayDriver via LovyanGFX
- variants/rak_tap_v2/target.{h,cpp}: SPI.begin() in radio_init() for FSPI
- platformio.ini: DISPLAY_CLASS=LGFXDisplay, LGFX_INVERT_COLOR, LovyanGFX dep
- src/ui-touch/UITask.cpp: RAK path includes LGFXDisplay + 320x40 LVGL buffer
- NOTICE: LovyanGFX dependency

Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
2026-07-09 08:09:21 +02:00
Ethan-chen1234-zy 8133a47769 feat(rak_tap_v2): add RAK WisMesh Tap V2 board support (working baseline)
Display: USE_PIN_TFT software SPI, 8-10 fps, 40-line LVGL buffer
Touch: FT5x06 I2C 0x38, runtime rotation 3 + FLIP_X/Y
UI: landscape LV_DISP_ROT_270, mapRaw with RAW_W=240/RAW_H=320
Radio: LoRa SX1262 on HSPI (SPI3_HOST), 240MHz CPU
Signed-off-by: Ethan-chen1234-zy <ethan.chen@rakwireless.com>
2026-07-09 08:09:21 +02:00
Tesso M Costa 4bbfcb5550 pager: let the rotary encoder scroll an open dropdown's list
updatePagerEncoder() never special-cased an open lv_dropdown, so a
plain turn always sent LV_KEY_NEXT/PREV -- but lv_dropdown's own key
handling only understands LV_KEY_UP/DOWN to move the highlighted row
when its list is open, and ignores NEXT/PREV entirely. Those instead
got consumed by the focus group to move focus off the dropdown, so
turning the encoder never moved the highlight and the list stayed
stuck on whatever was already selected (reported: opens fine, but
can't scroll to a choice, in Radio & Mesh's Community Preset and
other dropdowns).

Capture the encoder the same way Tanmatsu's navPump() already does
for its own input path: while navOpenDropdown() is true, route turns
as LV_KEY_DOWN/UP instead of NEXT/PREV. The short-click path already
sends LV_KEY_ENTER, which lv_dropdown uses to confirm the highlighted
row and close, so no change needed there.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 18:56:43 -06:00
Tesso M Costa 9bcad3c2c2 pager: skip redundant radio-param save/reapply on unedited field blur
Radio & Mesh settings auto-saved (flash write + live LR1121 SPI
reconfigure) on every textarea LV_EVENT_DEFOCUSED, even when nothing
changed. Touch boards rarely trip this (a field only blurs on a
deliberate tap-out after editing), but the pager has no touch
fallback, so plain keyboard/encoder navigation blurs a field on every
step -- arrowing through the freq/bw/sf/cr/tx/airtime row re-ran a
real radio reinit up to six times with no edits made, observed on
hardware as escalating multi-second [STALL] ui:lvgl entries.

Skip the save/reapply on a silent (defocus) blur when the parsed
values already match NodePrefs, gated to TLORA_PAGER so touch boards
keep their existing always-save-on-blur behavior unchanged.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 17:54:01 -06:00
Tesso M Costa bf5c6828a3 pager: Enter opens message action menu, Backspace jumps to latest
Enter on a focused chat bubble now opens the same Ack/Mention/Copy/
Info/Block action menu the T-Deck opens on a long-press, instead of
being a no-op. Hoisted navEnterBubble() out of its HAS_TANMATSU-only
guard (it was already board-agnostic) and reused it from the pager's
Enter branch in handleHwKey() -- it forwards Enter as a synthetic
LV_EVENT_LONG_PRESSED to the focused bubble, the same event the touch
long-press itself sends.

A plain Backspace tap also now does what tapping the T-Deck's floating
"scroll to bottom" circle does: jump the chat to the latest message
and hide the circle (mirroring the Tanmatsu F6 hardware-key handler,
which had the jump but was missing the hide), then move nav focus to
the composer so a reply can be typed immediately instead of leaving
focus on whatever bubble was selected pre-jump. Only intercepted while
the circle is actually showing, so Backspace at the bottom of a chat
(or outside one) keeps its normal no-op / hold-to-back behavior.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 15:44:05 -06:00
Tesso M Costa facbe49b01 pager: fix Enter not activating a focused non-composer widget
handleHwKey() treated "a textarea is bound to the on-screen keyboard"
as "we're editing a field", but showKb() binds the composer as soon as
a chat panel opens and never unbinds it just because nav focus moved
elsewhere in the same panel. So with focus on, say, the quick-reply or
emoji icon, Enter fell into the composer's submit/newline handling
instead of activating the actually-focused widget -- the rotary
encoder's short click worked because it bypasses this function
entirely (navPushTap(LV_KEY_ENTER) goes straight into the focus
group). Now only treated as editing when nav focus is really on that
field, mirroring the CAP_TRACKBALL boards' existing s_kbd_nav/
s_nav_ta_editing distinction for the same problem.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 15:10:50 -06:00
Tesso M Costa 3c845115bf pager: lay out Quick Reply as a 2-column grid, widen the card
The pager's 480px landscape screen left most of the quick-reply card's
width empty, since it shared the same 220px single-column sizing as
the narrower T-Deck/Heltec V4 screens. Widened to 360px and switched
to a 2-column grid (3 rows instead of 6) for this board specifically;
the button-placement loop is now column/row-based instead of a single
vertical stack, driven by per-board cols/col_gap constants so the
other boards' layout is unchanged. Focus navigation needed no changes
since it's already purely position-based (navMoveDir scores by each
widget's actual on-screen rect).

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 15:00:40 -06:00
Tesso M Costa f59d9f4b23 pager: add hold-space-to-lock / hold-backspace-to-unlock
Mirrors the T-Deck's spacebar-lock/trackball-unlock experience for the
pager. Unlike the T-Deck's keyboard, the TCA8418 here reports real
press/release, so this is a genuine hold (with a live-filling
"Locking…" progress popup) rather than the T-Deck's tap+countdown
workaround. Backspace was chosen for unlock over the rotary encoder:
its raw held-state stays readable via PagerKeyboard's own poll loop
regardless of screen power state, while the encoder's poller currently
exits early whenever the screen is off.

Fixes three existing bypasses that would otherwise make the new lock
pointless -- on this board, a plain encoder turn, a BOOT-button press,
or any ordinary keypress all unconditionally called wakeScreen()
(which clears the manual-lock flag) with no hold requirement, so
anything at all would instantly unlock a "locked" screen. Each now
checks isManualLock() first; only the deliberate Backspace hold clears
it. The BOOT-button fix is scoped to TLORA_PAGER specifically, since
that same code path is shared with the Heltec V4, which has no
keyboard and relies on BOOT as its only lock/unlock control.

Also updates the two user-facing "how to unlock" hint strings (the
Settings lock-on-screen-off toggle, and the T-Deck-style lock overlay
text, the latter currently dead code on this board since that overlay
is never built here) so they don't keep pointing at a trackball this
board doesn't have.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 14:49:27 -06:00
Kaj SchittecatandClaude Fable 5 48e77933e2 touch: beta_37 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:09:36 +02:00
Tesso M Costa 49a6f80419 pager: let D/F nudge a focused slider, quieten default volume
No touch or trackball on this board means a focused lv_slider (Control
Center brightness, a Settings slider, the Map zoom bar, ...) couldn't
be adjusted at all via keypad nav. D/F now drive it through
navMoveDir()'s existing slider-capture branch -- the same proportional
step, live-update, and persist-on-release the T-Deck trackball's
LEFT/RIGHT already does -- instead of a fixed one-unit-per-press step
that's wrong for every slider's range.

Also lower the pager's default notification volume to 50%: its ES8311
codec + NS4150B amp run louder at a given percentage than the T-Deck's
I2S amp/Tanmatsu's ES8156, so the shared 70% default clipped into
uncomfortable territory on this board specifically.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 13:59:19 -06:00
Kaj SchittecatandClaude Fable 5 667436fdd2 touch(tdeck): GT911 phantom-press watchdogs + bounded init retry
A flash/reset landing mid-I2C-transaction can wedge the GT911 (the S3
resets, the touch controller keeps running) into repeating a stale
pressed frame. The driver's keep-prev-state-on-no-data poll then latched
an eternal press: real touches ignored, trackball clicks eaten (a live
touch outranks the click injection), idle timeout never firing, keyboard
lock/unlock still working - a dead-looking device that survived every
reboot and reflash because only a full power cycle resets the GT911.

Watchdogs in gt911Poll() now force a release when status reads fail
mid-press, when no frame arrives for 250 ms while pressed (a real held
finger produces a frame every GT911 scan cycle), or when one bit-exact
point stays pressed for 10 s (a human touch always jitters) - that last
point is then ignored until it moves or a real lift frame arrives. A
jitter-y phantom can still need a power cycle, but the common wedge
modes self-heal in under a second.

Init is no longer one-shot: the boot probe retries every 500 ms for
~6 s before caching failure, so a GT911 still resetting at the first
attempt no longer costs the session its touch AND keyboard (the kb
poll task only starts once touch inits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:43:40 +02:00
Tesso M Costa d59cf5d9ae pager: fix control-center card overflowing the 222px screen
Adding the Sound chip's HAS_UI_SOUND to the pager built put 6 chips
into a row whose width was hardcoded for 5 (a leftover from when the
row only served the Heltec V4), and the chip's gap constant didn't
match the row's own pad_column, so the extra chip wrapped onto a
clipped 2nd line. Chip width is now derived from the actual chip
count instead of a fixed divisor.

The card height itself was also a shared V4/T-Deck-landscape constant
tuned for a 240px-tall screen; the pager's screen is only 222px tall,
so that height overflowed the visible root and pushed the toggle row
and sysinfo line off the bottom of the physical display. The pager
now sizes its card from the actual available height.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 12:10:23 -06:00
Tesso M Costa e682fbcb5c pager: add notification sound via the onboard ES8311 codec
Brings the T-LoRa Pager to feature parity with the T-Deck's notification
sound (built-in per-slot chimes, custom WAV playback, per-event on/off,
volume, Settings > Sound UI) using the board's ES8311 codec + NS4150B
Class-D amp, previously undriven.

- Es8311Codec: a minimal, hand-rolled I2C register driver (DAC/output path
  only) rather than vendoring Espressif's much larger esp_codec_dev HAL,
  which this single-chip playback-only board doesn't need. Register
  sequence and MCLK clock-divider coefficients are derived from Espressif's
  public es8311.c/es8311_reg.h (Apache-2.0, credited in NOTICE).
- TLoraPagerBoard::setAmpEnabled() toggles the amp around playback rather
  than leaving it permanently on; the existing boot-time AMP_EN drive stays
  as-is since it exists for shared-bus integrity reasons unrelated to audio.
  A boot-time I2C presence probe logs [BOOT] es8311 ok/PROBE FAILED.
- UITask.cpp: pager I2S/codec playback functions mirroring the existing
  T-Deck ones, plumbed into the same uiPlaySlot()/uiPreviewWavFile()
  dispatch the other boards use (which also had two T-Deck-hardcoded call
  sites generalized as part of this). New CAP_SOUND_FILES capability flag
  gates the file-browsing sound picker without reopening real SD support
  (the pager remains SPIFFS-only, decoupled from CAP_SD).
- platformio.ini: I2S pin flags for the pager env, confirmed against
  arduino-esp32's canonical pins_arduino.h for this board.

Verified on hardware: boot log shows es8311 ok, and chimes/volume/WAV
playback/DM+mention sounds all confirmed audible. Build-green on all three
envs; Heltec V4 binary byte-identical, T-Deck flash +16 bytes.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 11:45:33 -06:00
Tesso M Costa db7a43fcc4 pager: add Alt-tap-to-NEXT and backspace-hold-to-back input gestures
PagerKeyboard now distinguishes a solo Alt tap (press+release with
nothing else happening) from Alt held as a modifier (symbol-layer
typing, or the encoder's Alt+turn free-scroll) via
pagerKeyboardConsumeAltTap()/pagerKeyboardMarkAltUsed(), and exposes
pagerKeyboardBackspaceHeld() alongside the existing immediate-'\b'
press behavior.

Wires both into UITask: a solo Alt tap advances focus one step
(LV_KEY_NEXT), same as one rotary detent, and holding Backspace for
1s triggers the same "back" ladder as the encoder's long-press
(pagerNavGoBack(), factored out of updatePagerEncoder so both paths
agree exactly) — giving keyboard-only users a way back without
relying on the encoder. Also routes Enter through navPushTap when no
text field is focused (plain widget nav, including during the setup
wizard) and widens the radio settings SF/CR/TX/AF row's top margin
(was crowding the label above it).

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 11:02:49 -06:00
Tesso M Costa cca9a444e8 gitignore: generalize pager doc tracking to the whole dev-docs/ dir
Replaces the three individually-named TLORA_PAGER_PORT*.md entries with
/dev-docs/ so any future tracker added under that directory is covered
without another gitignore edit; also ignore /crash-dumps/.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 11:02:38 -06:00
Enot (ded) Skelly 381658ab2a Merge branch 'main' into finish-m9 2026-07-08 09:44:41 -07:00
Enot (ded) Skelly 4846d4fa51 fix: bug in batt logging, sd didn't check if directory exists
which would mean the user had not yet enabled loggin to sd but only
mounted a card
2026-07-08 09:40:02 -07:00
Enot (ded) Skelly c322b1fa3a chore: update m9 port doc 2026-07-08 09:31:05 -07:00
Steve GlennerandCursor d964c029d9 touch: virtualize chat scrolling (bubble + compact)
Viewport-based message virtualization for open chats: only the visible
window of LVGL widgets is materialised while layout offsets cover the
full thread (up to msgCap). Supports bubble and compact IRC modes, day
separators, jump to oldest/latest, and compressed LVGL scroll coords
for very long histories.

Finger drags advance layout scroll 1:1 even when coords are compressed,
so long histories scroll fluidly without micro-drags that trigger bubble
long-press menus.

Includes LVGL crash hardening from soak testing: defer refreshChatDetail
and bubble reflow off the indev/refresh path, sync-delete during async
render, size chatVirtEnsureMsgIdx to msgCap for the SD deep ring, and
open-to-bottom scroll that does not rely on premature scroll_bottom.

Fixes #126

Signed-off-by: Steve Glenner <steveglenner@hotmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 09:21:49 -07:00
Kaj SchittecatandClaude Fable 5 d2d3060051 touch: don't double-shift the layout indicator in the tall chat bar
It is align_to'd against the clock's final translated coords each tick; the
tall-bar top-row translate on top of that pushed its upper half out of the
bar (clipped) inside a chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:58:49 +02:00
Kaj SchittecatandClaude Fable 5 e2eb0d3118 touch: show the keyboard-layout indicator only inside a chat/channel
Typing (and thus the active layout) only matters in an open conversation;
everywhere else the language name was permanent bar clutter next to the clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:53:21 +02:00
Kaj SchittecatandClaude Fable 5 dd19cbe52d touch: park the keyboard-layout indicator relative to the clock on every board
The EN/DE indicator sat at a fixed -182 right offset while the clock's left
edge reaches past that in 12-hour mode ('7:45 PM'), overlapping on the T-Deck
bar. The Tanmatsu branch already parked it align_to the clock; un-gate that so
all boards track the clock wherever it lands (12/24h width, charging slide,
centred hide-name mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:43:15 +02:00
Yaz aa598d626b ui-touch: GPS position as the first quick-reply row
Adds the node's live GPS position as the first row of the chat quick-reply
picker: with a fix it shows the coordinates and a tap inserts them into the
composer at the cursor (same no-auto-send contract as the macro rows, so the
operator can add context before sending); without a live fix the row is
greyed out and disabled. Sharing where you are is one of the most common
things typed into a mesh chat, and hand-copying coordinates from the GPS
screen was the only way to do it.

Signed-off-by: Yaz <yazutsu@users.noreply.github.com>
2026-07-08 16:23:05 +02:00
Steve GlennerandCursor 862ec32f80 touch: fix SD message history flush reliability
Ensure the tile-fetch worker is running before arming a message-ring flush,
fall back to a synchronous write if spawn fails, write ui_msgs_v1.bin via a
.tmp rename for crash-safe replacement, and retry when the worker snapshot is
missing instead of treating the flush as successful.

Signed-off-by: Steve Glenner <steveglenner@hotmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:21:16 +02:00
Yaz 1eeeb267d2 ui-touch: make the Settings keyboard-backlight Off preset switch the mode off
Follow-up to the keyboard-backlight brightness control (issue #84). The Off
preset used to set the brightness percent to 0 while leaving the backlight
MODE untouched, inventing a second dark state: the keyboard went dark but the
control-center chip - which cycles and shows the mode - kept saying on/auto.

Off now switches the mode off, the same axis the CC chip shows, so the two
surfaces can never disagree. The brightness slider becomes 1-100: dark is
only expressible via the mode, and the on/auto modes always glow visibly at
the chosen level. The stored percent is left untouched by Off, so re-enabling
the backlight restores the previous brightness; a stored 0 from the previous
build is clamped to 1 at boot.

Signed-off-by: Yaz <yazutsu@users.noreply.github.com>
2026-07-08 16:18:16 +02:00
Kaj SchittecatandClaude Fable 5 2b30ca2a5a touch: Tanmatsu umlauts (#129) — ALT accent picker, double-space language cycle, compressed fonts
- ALT-accent picker on the Tanmatsu physical keyboard: hold ALT + letter
  opens the accent popup with the first variant highlighted, the letter
  cycles (auto-repeat walks the options), releasing ALT (its own scancode
  event) inserts the highlighted choice. Popup is nav-skipped/non-clickable
  so keypad-nav focus can't steal the cycle key, SC()-scaled, align-centred.
- Double-tap-space language cycle ported to the Tanmatsu typing path
  (navPump), and keyboardLayoutsApply now updates the active layout even
  with no on-screen keyboard object (was a permanent no-op there).
- Fonts: LV_USE_FONT_COMPRESSED on; extras_font_12/14/16 regenerated
  compressed (identical text glyphs; redundant mono emoji dropped — the
  colour imgfont covers emoji) freeing ~340 KB; new compressed Latin-accent
  extras_lat_20/24/28 spliced into the Large/Huge fallback chain
  (Tanmatsu-only) so umlauts size-match the scaled UI. Tanmatsu app.bin =
  3,269,440 B, UNDER the known-booting 3,271,856 B (50-page AppFS ceiling).
- TEMP: TDECK_KEYCODE_PROBE toast (build-flag gated, inert in releases)
  for mapping the T-Deck alt key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:16:53 +02:00
Tesso M Costa dc4ba96825 stop tracking the M7 hardware debug log, same as the other pager trackers
TLORA_PAGER_M7_HW_DEBUG_LOG.md is a blow-by-blow hardware bring-up log,
not a published project doc -- same treatment already applied to
TLORA_PAGER_PORT.md/TLORA_PAGER_PORT_MILESTONES.md and CLAUDE.md. File
stays on disk locally, just out of version control going forward.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 17:19:45 -06:00
Tesso M Costa f18929b312 stop tracking pager port trackers, matching CLAUDE.md's convention
TLORA_PAGER_PORT.md and TLORA_PAGER_PORT_MILESTONES.md are working
hardware bring-up notes, not published project docs -- same treatment
CLAUDE.md/.claude/ already get in this .gitignore. Files stay on disk
locally, just out of version control going forward.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 17:01:06 -06:00
Tesso M Costa df7f9bc2ab pager: fix encoder direction, and Alt+turn free-scroll for nested screens
Encoder direction was backwards on hardware (confirmed by user testing):
kQuadTable's CW/CCW sign didn't match this physical part's wiring, so
turning the way you'd expect to move forward/down moved focus and
scroll backward/up instead. Negated once in pagerEncoderReadDelta()
so every consumer (nav NEXT/PREV, Alt+turn tab-switch, Alt+turn page
scroll) inherits the corrected sense from one place.

Also scope the pager's Alt+encoder gesture: it used to always jump
between main tabs, even from inside a settings sheet or chat, silently
abandoning it. Now Alt+turn only tab-switches at the main-tab level
(navOnMainPage()); inside a nested scrollable screen it instead
free-scrolls the page like a touch drag (navScrollFocused()'s ANIMATED
lv_obj_scroll_by, unlike navFocusCb's one-shot LV_ANIM_OFF
scroll_to_view, reliably repaints on this board), then snaps focus to
whatever's now nearest the top of the visible viewport
(navRefocusFirstVisible()) so plain NEXT/PREV resumes from a visible
spot instead of a focus stuck off-glass.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 16:52:44 -06:00
Enot (ded) Skelly 2a34ad476e fix: m9 lock screen wake + bugfix lock paint
lockscreen was not being painted until after the display
wakes showing the previous screen, changed to paint lock
screen then wake
2026-07-07 15:16:18 -07:00
Enot (ded) Skelly 0ea242c81f feat: m9 long press enter key context menus 2026-07-07 14:55:56 -07:00
Tesso M Costa b3c638842e pager: fix LR1121 TX power default and RX-boosted-gain gating
Boot TX power was left at a conservative 10dBm (Heltec V4's pattern) with
no way to raise it, unlike the T-Deck which boots straight at the chip's
real 22dBm ceiling. On hardware this was silently degrading outbound
range: adverts/DMs sent from the pager were unreliable while inbound
reception was fine, since the far node's own TX power was unaffected.
22dBm is confirmed as this chip's actual sub-GHz HP-PA ceiling via
RadioLib's LR1120::checkOutputPower() (LR1121 inherits it) and matches
trail-mate's own working config for this board.

Also widen the three RX-boosted-gain gates in MyMesh.cpp/DataStore.cpp
that only checked USE_SX1262/USE_SX1268 to include USE_LR1121 --
CustomLR1121Wrapper already implements setRxBoostedGainMode/
getRxBoostedGainMode, so the setting was silently a no-op for this radio.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 15:50:07 -06:00
Enot (ded) Skelly a8f8a79d3f fix: show lock screen settings for m9 2026-07-07 14:47:25 -07:00
Enot (ded) Skelly 30ed2d77f6 fix: m9 control toggle layout 2026-07-07 14:06:23 -07:00
Enot (ded) Skelly c87cd3cc98 chore: update m9 port doc 2026-07-07 13:42:31 -07:00
Enot (ded) Skelly 996eac21fd feat: add buzzer for m9 2026-07-07 13:35:00 -07:00
Enot (ded) Skelly 3a3a37d193 feat: add m9 sd card 2026-07-07 13:23:20 -07:00
Tesso M Costa 05b24f01ad pager: fix encoder wake-selects-Skip, and confirm-before-advance on region pick
Two related first-boot-wizard bugs found on hardware:

- updatePagerEncoder() woke the screen on either a turn OR a bare click.
  Waking via click let the SAME press-release cycle fall straight into
  the short-click logic further down as an ordinary click on whatever was
  already focused -- concretely, it selected "Skip" on the wizard's
  welcome screen the instant the screen lit up. Only a turn wakes the
  screen now; BOOT is unchanged and still wakes via its own path.

- The region step's 20-preset list auto-jumped focus to Next on the
  FIRST click, before the row's colour change was even visible -- no way
  to confirm the right region got picked before focus moved off the
  list. Changed to two-step: the first click on a row selects/recolours
  it and focus stays put; a second click on the row that's ALREADY
  selected is treated as the explicit confirm and jumps focus to Next.

Both gated CAP_KEYPAD_NAV (Tanmatsu + pager); T-Deck/Heltec V4 touch
flows are unaffected either way (moving the internal group focus is
invisible without active keypad-nav).

User-verified fixed on hardware (fresh first-boot flash, region select,
Wi-Fi connect, and map tile auto-download all working).

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 14:16:21 -06:00
Enot (ded) Skelly dcf3958cc7 fix: m9 gps 2026-07-07 12:30:15 -07:00
Enot (ded) Skelly 6de8346e7f feat: add m9 mentions and advert hw button handlers 2026-07-07 10:34:57 -07:00
Tesso M Costa f61b8fbbcf pager: Alt+rotary jumps between main tabs
The bottom tab bar (Chats/Contacts/Home/Map/Settings) is deliberately not
a nav-group focus target, same as T-Deck/Tanmatsu -- but those boards
reach it via their own dedicated hotkeys (programmable keys / coloured
shape keys), which the pager has none of. Plain rotary turning could
therefore only ever move focus within the current screen; the tab bar
icons were unreachable from Home (or anywhere else).

Exposed pagerKeyboardAltHeld() (raw modifier state already tracked by
pagerKeyboardPoll() to drive the symbol layer, just never surfaced
before) and used it in updatePagerEncoder(): holding Alt (the bottom-left
orange key) while turning the encoder now calls navSwitchTab(+-1) per
detent instead of the normal focus-move NEXT/PREV, cycling through the
5 main tabs directly. Alt alone still types nothing, so this doesn't
take anything away from the keyboard.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 11:21:57 -06:00
Enot (ded) Skelly be32315b98 chore: remove old m9 kb debug print 2026-07-07 10:14:39 -07:00
Tesso M Costa fe4ed029f5 pager: make rotary long-press a real universal back gesture
Two gaps left "hold to go back" not actually working anywhere but inside
a popup:

- The popup registry (anyPopupOpen()/hwKeyDismissTopPopup(), which drives
  the actual dismiss) only recognized HAS_TDECK_GT911/HAS_TANMATSU, so it
  never knew how to close the pager's own Terminal fullscreen view or its
  command picker -- the encoder's long-press was sending a raw Esc key
  that nothing handled. Widened to TLORA_PAGER.

- Even with that fixed, a BARE main tab (Chats, Contacts, Map, Settings)
  is neither a popup nor an open chat/channel detail, so the back cascade
  fell through to a no-op Esc. T-Deck/Tanmatsu don't hit this because they
  have separate dedicated Home hotkeys; the pager has none (no
  programmable keys, and the bottom tab bar is deliberately not a
  nav-group target). Added a third rung: fall back to the Home tab before
  plain Esc, matching every other "close" path in this app (the
  Terminal/Files Home button, the fullscreen-view popup closer) already
  landing on Home rather than a real back-stack.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 11:12:18 -06:00
Tesso M Costa 53251c33e0 pager: reset idle timer on keyboard and encoder activity
Neither input path called noteUserInput() for the common no-field-focused
case, so the idle timeout kept counting down during active use:

- handleHwKey()'s no-textarea branch only calls noteUserInput() when
  dismissing a popup or matching a tab hotkey. tabForKey() no longer maps
  any letters (tab jumps moved to the trackball-only programmable
  hotkeys) and isDismissKey() always returns false for the pager's full
  QWERTY, so with no field focused, every keypress silently no-op'd here.

- updatePagerEncoder() only called noteUserInput() to wake an already-off
  screen; turning or clicking the encoder while the screen was ON never
  reset the timer.

This board has no touch and no trackball to fall back on for idle-reset,
so both gaps were fatal: the screen dimmed after ~30s of real use.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 10:45:17 -06:00
Tesso M Costa f9c3db99c2 ui: scope keypad/trackball nav to the frontmost top-layer popup
navMaybeRebuild() re-roots keyboard/rotary focus onto lv_layer_top() as
soon as ANY child is visible there, but collected the WHOLE top layer.
Fullscreen views that spawn their own popups (Terminal's command picker,
file manager prompts/action sheets) add those popups as separate
lv_layer_top() siblings rather than nesting them in the view, so with two
visible top-layer children at once, nav walked the hidden view underneath
before ever reaching the popup on top of it (reported: opening the
terminal's command picker left rotary focus on the terminal behind it).

LVGL has no z-index — child order is stacking order — so the last
visible, non-NAV_SKIP child is the one actually on top. Added
navTopFrontmostChild() and root nav on it instead of the whole top layer.
Single-popup screens are unaffected (same one child either way).

Not board-gated: this is a general nav-focus correctness fix that
benefits any board using keypad/trackball navigation, not pager-specific.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 10:34:59 -06:00
Tesso M Costa 230b0936bc pager: fix terminal Enter-to-submit and always-on rotary focus highlight
Two related non-touch navigation bugs on the pager's Terminal screen:

- handleHwKey()'s ENTER handling only routed to terminalSubmit() under
  HAS_TDECK_GT911, so physical-keyboard Enter fell through to hideKb()
  instead of running the typed command. Widened to TLORA_PAGER.

- s_nav_show (whether the amber keypad-focus ring is painted) only
  defaulted true for HAS_TANMATSU ("keyboard-only device: focus highlight
  is always visible"); every other board, including the pager, defaulted
  false and only flips true inside CAP_TRACKBALL-gated code, which never
  runs on the pager (no trackball). Since the pager also has no touch,
  rotary-driven focus was moving but never rendered, so the terminal's
  send button looked unselectable. Widened to TLORA_PAGER, matching the
  Tanmatsu's rationale exactly.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 10:25:10 -06:00
Enot (ded) Skelly 5da14d8bda fix: highlight selected textarea for hw nav 2026-07-07 09:21:29 -07:00
Enot (ded) Skelly 32e1668406 fix: bug in deep nested containers
they did not scroll because the parent was scrollable but they were not.
so scrolling with navigation through elements did not work
2026-07-07 09:15:25 -07:00
Tesso M Costa d4ca18335e pager: keep home TX/RX chart clear of the right-hand button column
makeHome()'s chart_w gate only widened the chart's cleared strip for
HAS_TDECK_GT911/HAS_TANMATSU, so the pager (landscape, same right-hand
Advert/Terminal/Apps/Control column) fell into the full-width else branch
and the chart border/Sig chip ran under the buttons. Add TLORA_PAGER to
the gate, matching the other landscape boards.

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 10:10:00 -06:00
Enot (ded) Skelly 259aa36e40 fix: m9 correct home button behavior
was not working correctly on app drawer
2026-07-07 09:01:16 -07:00
Tesso M Costa 78a6c01f95 pager: fix M7 hardware bring-up bugs, verify UI/keyboard/encoder on device
Six real bugs found and fixed across two hardware sessions (full account in
TLORA_PAGER_M7_HW_DEBUG_LOG.md): stale-NVS BLE bond crash, no screen-wake
path for keyboard/encoder input, wrong TFT_eSPI SPI-port index on ESP32-S3,
missing panel INVON for this ST7796 batch, two SPI hosts fighting over the
shared radio/display pins, and a floating XL9555 ch6 panel hardware-reset
line (the actual root cause of the intermittent black screen, not the SPI
race originally suspected). TLoraPagerBoard::begin() now drives the full
vendor rail set including the display reset pulse and parks shared-SPI
selects high before first bus traffic.

Boot logo, full LVGL UI, QWERTY keyboard navigation, and rotary encoder
nav/select are now verified working on real hardware. Trackers updated:
Milestone 7's UI portion is done; the remaining radio/USB-companion/SD
gates move to a new Milestone 7b so they don't block Milestone 8's
on-device UI pass, which is now active.

All three envs (Heltec V4, T-Deck, pager) build green.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 09:44:36 -06:00
Enot (ded) Skelly 51b513b181 fix: m9 backlight level 2026-07-07 08:42:03 -07:00
Kaj SchittecatandClaude Fable 5 6dc09b3185 touch: beta_36 — Launcher SD storage both ways, edit menu on all fields (#122), Wi-Fi full-screen sheets, lockscreen unread (#93), SD update download, AU Mid preset (#121)
- Edit menu (Cut/Copy/Paste/All/Sym) + double-tap select on every settings
  field via attachSettingsTaEvents; menu actions route through the keyboard
  mirror when bound (#122)
- Wi-Fi join/hidden/details sheets: full-screen settings-style pages riding
  the tall status bar title (s_apppage hook), on lv_scr_act so the bar's
  glass row isn't clipped
- Lock screen unread badge (envelope + count, anti-burn-in drift) (#93)
- About: Save update bin to SD (/BINS/wadamesh-<tag>-<channel>.bin) for
  Launcher installs; beta-channel toggle no longer gated on the OTA slot
- Australia (Mid) preset 915.075/125/SF9/CR5 (#121)
- M9: [M9KB] key debug print (bring-up)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:18:55 +02:00
Kaj SchittecatandClaude Fable 5 b3004b85eb fix(esp32): use_sd toggle follow-up — both directions on Launcher installs (PR #123 review)
Two edges from the #123 review:
- The raw-NVS mirror is best-effort now: on installs with unusable NVS
  (Launcher) the file write is the authoritative copy, so a failed mirror
  no longer fails the setter.
- When prefs actively live on the SD card, the toggle also rewrites the
  SPIFFS copy of touch.kv (record-preserving; new SdNvsPrefs::fileFs()
  accessor tells the setter which fs is active). Without this, boot's
  SPIFFS fallback kept reading the stale use_sd=true and SD storage could
  be turned on but never off again where NVS is broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:06:02 +02:00
Steve GlennerandCursor a7d435b03b fix(esp32): honor use_sd from touch prefs at boot
UI stores the SD toggle in /prefs/touch.kv via SdNvsPrefs; boot only read
raw NVS. Dual-write on change and read NVS with touch.kv fallback so SD
storage selection survives reboot.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Steve Glenner <steveglenner@hotmail.com>
2026-07-07 10:58:58 +02:00
Kaj Schittecat 8718e49827 chore: refresh Mesh America catalog for beta_35 2026-07-07 09:50:26 +02:00
Tesso M Costa 641700fad0 touch: wire T-LoRa Pager into UITask (M6)
Adds the pager as a first-class non-touch UI target: new device_caps.h
block (no touch, hw keyboard + rotary-encoder nav, 480x222, GPS/OTA/lock
screen; SD/filesystem left off since the mount code is still hardcoded to
HAS_TDECK_GT911, not genuinely CAP_SD-generic), KEYPAD indev registration
reusing the Tanmatsu keypad plumbing, forced 480x222 landscape rotation,
keyboard/encoder input drain in the main loop, and AW9364-correct
brightness wiring ahead of the existing PWM backlight branch.

Also fixes two latent gaps this surfaced: navMaybeRebuild() was
unreachable for the pager's cap combination (would have left the focus
group permanently empty), and several HAS_TDECK_KEYBOARD-gated helpers
needed their reopened regions individually widened rather than the file
touched wholesale.

All three envs build green; Heltec V4 TFT and T-Deck binaries are
byte-identical to pre-change (25.3%/73.5%), confirming every edit stayed
behind the pager gate.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 19:06:31 -06:00
Enot (ded) Skelly 38c0e7a016 correct m9 adc and add backlight handling 2026-07-06 15:45:29 -07:00
Tesso M Costa 3e71867e71 pager: add TCA8418 keyboard + rotary encoder drivers
PagerKeyboard.{h,cpp}: TCA8418 matrix keyboard over I2C. Unlike the
T-Deck's keyboard (a second MCU that resolves ASCII itself), the
TCA8418 only reports raw row/col events, so the keymap and
shift/sym/alt state machine live here -- keymap tables cribbed from
trail-mate's LilyGoKeyboard (same physical PCB). Press/release
polarity verified against the TCA8418 datasheet directly, since it
disagrees with the Adafruit library's own header comment. Backlight is
direct LEDC PWM on GPIO 46, using this framework version's actual
channel-based API (confirmed before writing the call, not assumed).

PagerEncoder.{h,cpp}: true A/B quadrature decode via a standard
Gray-code transition table, ISR-cheap-arithmetic-only like
TDeckTrackball's pattern (that driver's 4 discrete direction pins
aren't actually quadrature, so its exact shape doesn't transfer, only
the ISR/snapshot-read style does). Detent scaling (4 raw transitions
per detent) is an unverified assumption pending hardware bring-up.

Both gated on HAS_PAGER_KEYBOARD/HAS_PAGER_ENCODER, already reached by
every env's existing helpers/input build filter. All three envs build
green.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 16:35:11 -06:00
Enot (ded) Skelly 0be7c20600 fix m9 commander layout 2026-07-06 15:14:46 -07:00
Tesso M Costa ae690e3fbb pager: add platformio.ini env (first full compile)
[env:tlora_pager_lr1121_companion_radio_touch], cloned from the T-Deck
env with board/radio/display/GPS deltas for the LR1121 + ST7796 pager.

Two structural fixes were needed to actually get this to compile:

- pins_arduino.h moves to its own variants/lilygo_tlora_pager_pins/
  folder (board JSON's "variant" field updated to match). PlatformIO's
  arduino-esp32 build script unconditionally compiles every source
  file under board_build.variants_dir/<board.variant>/ as a separate
  framework-level library with none of our own lib_deps -- since this
  board (unlike T-Deck/Heltec, which point at framework-bundled
  variants) has no framework-bundled variant of its own, our
  variants_dir override was colliding with our own board-glue files
  and failing to find Wire.h/RadioLib.h.

- CustomLR1121Wrapper.h's bare quoted includes of RadioLibWrappers.h/
  LR11x0Reset.h only resolve for core-lib files living in the same
  directory as their targets; switched to angle-bracket includes with
  the full core-lib subpath.

Also required one small, forced addition to UITask.cpp: its display
class selection only recognized the Tanmatsu vs. everything-else split,
which made this env's own hard compile gate unreachable. Added a single
#elif arm for the pager's display type: no other UITask.cpp behavior
changes, indev/resolution/layout wiring is still a later milestone.

All three envs (Heltec V4, T-Deck, pager) build green.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 16:14:00 -06:00
Enot (ded) Skelly ad98f661cc fix m9 navigation 2026-07-06 15:02:15 -07:00
Tesso M Costa 546cc9079a pager: add ST7796 display driver + AW9364 backlight (src/helpers/ui)
ST7796LCDDisplay implements the core's DisplayDriver interface on
TFT_eSPI for the pager's 480x222 landscape panel -- the first real
TFT_eSPI consumer in this codebase, since ST7789LCDDisplay (the sibling
this was modeled on) turns out to be Adafruit_GFX-based for both
Heltec V4 and T-Deck. Every TFT_eSPI call was verified against the
pinned library source rather than assumed.

Backlight is the AW9364 stepped pulse-dimmer, wrapped directly in the
display class via SensorLib's AW9364LedDriver (already a dependency)
rather than a hand-rolled pulse timer, exposing a pct-based
setBrightness()/getBrightness() hook for a later UITask.cpp wiring
pass.

Also adds a compile-time guard for CGRAM_OFFSET: this panel's 222px
glass is narrower than the ST7796 controller's native GRAM, and
TFT_eSPI only applies the required coordinate offset when that flag is
set. Without the guard, a missing flag would build clean but silently
shift every frame by 49px.

Nothing references these files yet, so the shipping T-Deck/Heltec V4
envs are unaffected.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 15:10:18 -06:00
Tesso M Costa 9d5df3001d pager: add port tracker + milestone playbook docs
TLORA_PAGER_PORT.md is the living status/decisions/risks tracker for
the T-LoRa Pager port; TLORA_PAGER_PORT_MILESTONES.md breaks the work
into ordered, agent-executable milestones with gates. Kept off main
until the port is further along.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 14:24:33 -06:00
Tesso M Costa 82b6d3b56d pager: land LR1121 radio glue (target.h/.cpp + CustomLR1121 wrapper)
CustomLR1121{,Wrapper}.h are adapted from the core fork's CustomLR1110
pair (upstream MeshCore has no LR1121 support yet to crib instead).
target.cpp's radio_init() spells out the LR1121 bring-up sequence
directly since there's no std_init() helper for this chip: begin()
with the NodePrefs-driven params and TCXO voltage passed straight in,
the DIO5/DIO6 RF-switch table, and a CRC-length override to match the
other radio wrappers' wire-protocol framing. Nothing references these
files yet, so the shipping T-Deck/Heltec V4 envs are unaffected.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 14:22:51 -06:00
Kaj SchittecatandClaude Fable 5 a38ea1e3bb touch: Elecrow ThinkNode M9 port (ESP32-S3 + LR1110 + I2C QWERTY) — bring-up builds 1-9
New board: ThinkNode_M9_companion_radio_touch env, variants/thinknode_m9/
(board glue, LR1110 target, keyboard driver, partitions, M9_PORT.md pin
reference), boards/thinknode_m9.json, device_caps HAS_THINKNODE_M9 block,
HAS_M9_KEYBOARD input path in the touch UI (d-pad focus nav, tab jump keys,
QWERTY typing).

Based on a contributed bring-up patch by ded (SalishMesh), who also remote-
tested every build on a preproduction unit. Hard-won fixes on top of the
patch, each verified on hardware:

- LR1110 radio init -706: preprod chips run transceiver FW 0x0303, which
  rejects DriveDiosInSleepMode (0x012A, added in FW 0x0308) that RadioLib
  7.x sends unconditionally during begin(). New build-time patch
  scripts/build/patch_radiolib_lr11x0.py tolerates it on old FW;
  radio_init() prints the chip's device/FW/errors either way.
- TCXO: DIO3 at 3.3 V (the patch's active-oscillator/tcxo=0 theory gave
  -707 on hardware).
- Display: rotation 1 (not 3), plus the missing landscape s_ui_rotation
  override so LVGL renders 320x240 instead of portrait-into-landscape.
- Keyboard: the controller is a separate ESP32-S2 I2C slave (0x6C) with
  addressed registers - reads select register 0x01 first (bare reads
  return the HW-version register forever). Boot probe logs the
  controller's HW/FW; backlight duty register wired.
- USB pad release: the keyboard bus lives on GPIO20/21 = the S3's native
  USB D-/D+ pads, owned from reset by the ROM USB-Serial-JTAG (D+ pullup
  on SDA). M9Board::begin() releases them before any bus init.
- GPIO35-37 are octal-PSRAM pins on the S3R8 - never drive them (the
  patch's "SD CS = 36" misread package pin 36 = GPIO48; SD is on the
  shared SPI bus, support returns with PIN_SD_CS=48 later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:49:25 +02:00
Tesso M Costa be1996ce77 pager: land T-LoRa Pager variant skeleton (board JSON + board class)
Board class, pins, and partition table for the LilyGo T-LoRa Pager
(ESP32-S3, LR1121 radio variant), modeled on the T-Deck's shape.
TLoraPagerBoard brings up the XL9555 power-rail expander and BQ27220
fuel gauge over I2C (this board has no VBAT ADC divider). Nothing
references these files yet, so the shipping T-Deck/Heltec V4 envs are
unaffected; radio/display/env wiring land in follow-up commits.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 13:43:24 -06:00
Kaj SchittecatandClaude Fable 5 01900296f3 touch: beta_35 — worker-thread history flush, radio memory guards, 30.5 KB DRAM freed, crash-safe prefs
- History flush runs on the core-0 worker (snapshot + storage-busy gate +
  bounded shutdown wait): kills the V4's multi-second ui:hist stalls and
  the refuses-to-wake-after-message symptom (SPIFFS GC off the UI thread).
- BLE + Wi-Fi runtime enables share the boot co-init heap guard (50 KB
  free + 20 KB block): refuse with a toast + revert the switch instead of
  panicking (BLE) or silently not starting while claiming on (Wi-Fi).
- Internal DRAM: static footprint 95,365 -> 64,908 B (30.5 KB freed):
  serial_interface -> PSRAM; 23 keyboard layout maps const'd -> flash;
  nine rings/tables -> psAlloc PSRAM (UITask/TouchPrefsStore/MyMesh);
  unused TinyUSB MSC + DFU-mode class drivers stubbed out of the link
  (usb_unused_class_stubs.c) — CDC + runtime-DFU untouched, double-flash
  verified over the stubbed stack.
- DataStore prefs writes are crash-safe (write .tmp, swap) with a
  self-healing loader (.tmp recovery) and a truthful save result up to
  the profile-name toast — fixes the boots-with-default-name-once report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:34:24 +02:00
Kaj SchittecatandClaude Fable 5 4e1ac2ca6b touch: beta_34 — buffered LoRa receive ON by default (test channel) + core pin core-v1.16.5
Consumes the new core (buffered receive drain task + RX counters, #13
stock payload layout, GPS-date guard). Fork side:
- TouchPrefs v33 rx_queue, DEFAULT ON for the test channel (fresh
  installs + a one-time migrate for upgraders); opt-out toggle
  'Buffered receive (experimental)' in Radio & Mesh, applies live.
- About live tier shows LoRa RX heard/read/err/late-lost/qdrop, the
  field evidence for the missed-messages fix.
- Spectrum parks/resumes the drain task around raw radio use; MyMesh
  setParams sequences hold the radio mutex.
- TanmatsuLoraRadio: no-op API stubs (P4 keeps its own RX path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:15:52 +02:00
Kaj Schittecat 5d9a184314 chore: refresh Mesh America catalog for beta_33 2026-07-05 23:50:32 +02:00
Kaj SchittecatandClaude Fable 5 8e17b1b071 touch: beta_33 — async MQTT connect (freeze fix) + About sketch-size cache + favorites-full toast
- MqttBridge: reconnect() (blocking DNS + TCP + CONNACK, 2-10 s vs an
  unreachable broker, every 15 s) now runs on a one-shot core-0 task;
  _connecting hands the client between threads. Broker socket writes get
  the same zero-timeout writability probe as the TCP/WS companion
  servers, so a wedged broker fails a publish instead of stalling loop().
- About: ESP.getSketchSize()/getFreeSketchSpace() esp_image_verify-walk
  the whole ~3 MB image (~190 ms each); the 1 Hz refresh cost ~390 ms/s
  and flooded the stall ring (the ui:sbar entries field testers
  photographed). Computed once and cached.
- Favorites: adding past the 16-cap now toasts 'Favorites full (max 16)'
  instead of the wrong 'Removed from favorites'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:34:22 +02:00
Kaj SchittecatandClaude Fable 5 b58cecab5c touch: beta_32 — non-blocking TCP/WS companion writes (Wi-Fi freeze fix) + chunked history flush + stall diagnostics
Root cause of the beta_31 periodic 1-2s freezes with Wi-Fi on: every
received mesh packet pushes an RX-log frame to all connected TCP/WS
companion clients from the loop thread, and writeAllBytes called
WiFiClient::write() unguarded — against a stale peer (phone asleep,
half-open socket) that blocks in the framework's 1s select() retries.
The 120ms app timeout only bounded attempts, not the first call.

- TCP+WS servers: probe writability (zero-timeout select on the client
  fd) before every write; lwIP reports writable only with ~2.8KB free,
  a whole frame is ~180B, so probed writes complete instantly.
- Reap clients whose writes fail continuously for 10s (half-open
  sockets previously stayed "connected" forever, eating the write
  budget on every pushed frame).
- Chat-history ring: chunked writer + wider flush spacing (same
  treatment saveContacts got in beta_30).
- Stall tracer: 16-entry ring of >=0.2s loop stalls, shown in About.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:55:42 +02:00
Kaj Schittecat 816045bb6b chore: refresh Mesh America catalog for beta_31 2026-07-05 21:13:27 +02:00
Kaj SchittecatandClaude Fable 5 11939a744e touch: beta_31 — WhatsApp-style chats tab (avatars + previews) with per-channel custom icons
- Default chat list: 56px rows with a round initials avatar in the thread's
  FNV-hue signature colour, last-message preview (getThreadLastMessage ring
  accessor), time top-right, unread pill + @ below; compact chat mode keeps
  the dense 34px contact-style rows (layout mode mixed into the rebuild sig)
- Per-channel avatar emoji: 'Chat icon' in the thread-settings sheet (tap =
  emoji sheet in new pick mode, long-press = reset to letters); persisted in
  a chmute-style keyed blob (touchPrefsGet/SetChannelEmoji, 24 x 48B)
- Fix: name/preview overlap (LONG_DOT labels need fixed one-line heights)
- Thread-sheet grid gap 6->4 so the 7th channel cell keeps one screen on T-Deck

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:07:42 +02:00
Kaj SchittecatandClaude Fable 5 f47d71a05b touch: beta_30 — big-mesh contacts perf (buffered save, in-place age labels, coalesced rebuilds) + live Spectrum
- DataStore::saveContacts packs byte-identical 152B records into ~5KB chunk
  writes (was ~12 tiny FS writes per contact: 2.6-3.0s at ~570 contacts on the
  loop thread — the #82 sluggishness); falls back to per-field writes on alloc failure
- Contacts 60s age tick updates the Heard labels in place (was a ~1.3s full
  teardown+rebuild); advert-driven rebuilds coalesced to one per 2.5s
- Spectrum: rolling waterfall row + live trace every quarter sweep (~250ms),
  8-bin/8ms sweep chunks (~20% faster full sweep); trace Y range now tracks the
  live min/max — instant expand, 3dB/sweep contract, 30dB minimum span

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:08:40 +02:00
Kaj SchittecatandClaude Fable 5 8eb63a7edd touch: beta_29 — clock floor for room-server replay guards, BLE repeats-heard, deep SD history, one-screen popups
- ClockFloorRTC on all boards: persisted monotonic send-timestamp floor (touchPrefs v32),
  garbage-set rejection, trusted-backstep cap; Join no-reply watchdog + server clock
  skew warning from the LOGIN_OK timestamp prefix (issue #89 follow-up)
- BLE repeats-heard: own-echo RX-log frames pass to BLE one-shot; the #46/#54
  congestion skip stays for everything else (issue #94)
- Mirror app-sent channel messages on-device (was Tanmatsu-only from birth)
- Chat delete: persist the ring purge; mesh-channel drop prefers the pinned slot
- Deep SD history (5000-msg ring) + date separators; chat-list rows restyled compact
- Popup unification: popupClose() + k_popup_registry drive dismiss/count/swipe-block
- Thread/channel sheet as one-screen 2-col grid (contact-sheet layout)
- Keypad nav: strip LVGL SCROLL_ON_FOCUS from collected widgets (About-page jump);
  update dot no longer focusable; verchk waits for an IP + retries with backoff
- Tanmatsu: FIRMWARE_RELEASE_TAG from git describe (drop the hand-bumped hardcode)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:07:16 +02:00
Kaj SchittecatandClaude Opus 4.8 5623237951 site: dismissible donate banner -> support the MeshCore founders
Sticky top banner asking would-be donors to help the MeshCore founders instead — links to the blog post (Learn more) and the givealittle donation page (Donate). Dismissible (x + localStorage), and it shifts the corner FAB rails down while shown so they don't collide with the close button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:22:37 +02:00
Kaj SchittecatandClaude Fable 5 b90b8bac14 touch: beta_28 — room-server session fix (#89), ST7789 panel sleep, wyvern feedback batch
- Rooms: re-enable the keep-alive pinger (checkConnections), arm a 128s
  keep-alive on LOGIN_OK (servers send 0), blank-relogin self-heal while a
  room chat is open, 'Log in again' in the room sheet, read-only warning.
  Root-caused against simple_room_server: 3 unACKed pushes freeze a client
  until the server hears from it — and we never pinged (issue #89).
- Burn-in: ST7789 SLPIN/SLPOUT on screen-off (backlight-off alone kept the
  panel driving the static image), lit-lock dim guard on all boards, 10s
  notify-wake re-dim, lock clock pixel drift.
- wyvern.red batch: scroll-keys map zoom, backspace exits empty composer,
  Reset path in the chat sheet, compact chat toggle (TouchPrefs v31),
  clearer delivery glyphs + tap-to-resend on failed sends.
- OTA: hide Install-update when the running image's physical address falls
  outside the esp_ota running slot (mixed/legacy tables; beta_21 coredump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:40:02 +02:00
Kaj Schittecat 3585b3a1e0 chore: refresh Mesh America catalog for beta_27 2026-07-04 08:45:41 +02:00
Kaj SchittecatandClaude Fable 5 a4d3715529 release-notes: beta_27 stable rollup (beta_26 + beta_27 since stable beta_25)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:45:29 +02:00
Kaj SchittecatandClaude Opus 4.8 ffc501d09d touch: hide on-device Install-update button on Launcher installs
touchHasOtaUpdateSlot() checked only that a spare OTA partition exists
(esp_ota_get_next_update_partition != NULL). Under bmorcelli's Launcher a
spare slot exists but is smaller than our image, so the check passed, the
Install-update button showed, and Update.begin() failed "won't fit: Bad Size
Given" (reported by Keith/KM1USR on beta_24 under Launcher). Require the target
slot be at least as large as the running slot; the whole OTA block (Install /
previous-version / beta toggle, already gated on this) then hides on Launcher
installs, where updates go through the Launcher. The update-available notice
still shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:04:35 +02:00
Yaz 61666ac033 ui-touch: keyboard-backlight brightness control for the T-Deck (issue #84)
The T-Keyboard's C3 firmware takes a 2-byte I2C command [0x01, level 0-255],
but the level the UI drove it at was hardcoded 0xFF, so the backlight was
always 100% when lit. Adds a Keyboard backlight control at the top of
Settings -> Keyboard: a live slider plus Off/25/50/75/Max one-tap presets,
scaling the lit level for the on and auto modes (and the new-message flash).
The percent persists in the existing kbd_bl pref shared with the Tanmatsu
slider; adjusting it while the mode is off flips the mode to on, mirroring
the Tanmatsu control-centre behaviour.

Three latency lessons learned on the way:
- pct->duty is gamma-2 mapped: perceived LED brightness is roughly
  logarithmic in duty, so a linear map felt coarse at the dim end.
- the NVS save is debounced (one-shot timer re-armed per change): the pref
  setter rewrites the whole TouchCfg blob via cfgFlush(), which can block
  the UI thread for a good fraction of a second per tap.
- backlight requests now flush on every touch-poll tick (~8 ms) instead of
  riding the ~32 ms keyboard-scan cadence, and the Settings controls request
  the level directly instead of waiting for the next UITask::loop pass - so
  the control feels as immediate as the screen-brightness slider (whose
  ledcWrite hits the ESP32's own PWM peripheral synchronously).

Signed-off-by: Yaz <yazutsu@users.noreply.github.com>
2026-07-03 07:16:08 +02:00
Kaj SchittecatandClaude Opus 4.8 5f69a61c9c touch: beta_26 — fix SPIFFS-GC watchdog reboot loop (contacts->SD + shared guard)
Decoded a T-Deck coredump (Task WDT, loopTask stuck in spiffs_gc_clean under
DataStore::saveContacts): a contact save on a churned SPIFFS can trigger a
multi-second garbage-collection pass that starves core 0 and trips the task
watchdog, giving a reboot loop.

- Route contacts/channels to SD when a card is present, even on upgraded
  devices whose data is still on SPIFFS (main.cpp mounts SD every boot; if not
  fully adopting SD, store.setSecondaryFS(&SD)). DataStore::begin() migrates the
  existing /contacts3 + /channels2 to the card once; identity/prefs stay put.
- Move the ref-counted WdtHeavyGuard into a shared header so the core
  DataStore::saveContacts shares one count with the UI history/backup saves, and
  wrap saveContacts with it (guaranteed fix for card-less devices).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:11:19 +02:00
Kaj SchittecatandClaude Opus 4.8 ddc4a34d4d docs(site): add microSD-card storage tip to the overview
Recommend keeping a card inserted on SD-capable devices (T-Deck, Tanmatsu) so tiles,
contacts and chat history live on the card and avoid the limited/less-reliable internal flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:08:02 +02:00
Kaj SchittecatandClaude Opus 4.8 df4a675cd3 deploy(meshamerica): match official MeshCore device names + add provider description
TJ Downes flagged that provider device names must match MeshCore's canonical names exactly
or they list as NEW devices instead of folding into the existing tiles. Rename to the official
'Heltec v4 + Expansion Kit (Touch)' and 'LilyGo T-Deck' (verified against the live config), and
add the new optional 'description' blurb shown on the provider badge tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:40:15 +02:00
Kaj SchittecatandClaude Opus 4.8 8d068cfcc1 docs(site): add 'Bring your own tiles' offline-maps section to the Map page
Explains the microSD tile-pack layout (/maps/osm or /tiles, z/x/y, PNG/JPEG), the
'Tiles from SD card' toggle, making a pack, and per-board differences (T-Deck / V4 / Tanmatsu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:58:16 +02:00
Kaj Schittecat c4f31e5a69 chore: refresh Mesh America catalog for beta_25 2026-07-01 23:44:43 +02:00
Kaj SchittecatandClaude Opus 4.8 0af47e5936 release: beta_25 stable notes — aggregate beta_22..25 over stable beta_21
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:44:40 +02:00
226 changed files with 54144 additions and 27882 deletions

No files matched your search

+2
View File
@@ -28,6 +28,8 @@ scripts/build/wifi_secrets.h
# Local working notes + agent config (contain ops/infra details — never publish)
CLAUDE.md
.claude/
/dev-docs/
/crash-dumps/
# Custom timestamped build outputs
/builds/
+51
View File
@@ -0,0 +1,51 @@
# Supported devices
Current hardware support, install paths and maturity. "Stable" means the board
ships on the stable release channel; "Beta" means it is new and lives on the
test channel until the next stable promote. Install links:
[flasher.wadamesh.com](https://flasher.wadamesh.com) (browser, Chrome/Edge over
USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases).
| Device | MCU / radio | Display and input | Install | Channel | Status |
|---|---|---|---|---|---|
| LilyGo T-Deck / T-Deck Plus | ESP32-S3, SX1262 | 2.8" 320x240 touch, QWERTY, trackball | Web flasher (standalone) or Launcher app image | Stable | Fully supported, reference device |
| Heltec V4 + TFT | ESP32-S3, SX1262 | 2.4" 240x320 touch (CHSC6x) | Web flasher | Stable | Fully supported; Expansion Kit sensors, V4.3 high-gain RX toggle |
| Tanmatsu | ESP32-P4 + ESP32-C6, SX1262 | 4" 800x480, 69-key keyboard (no touch) | Tanmatsu app store on the device (runs under the badge.team launcher, not web-flashable) | Store tracks the test channel | Fully supported; LoRa + Wi-Fi + Bluetooth simultaneously, standalone and companion in one |
| Elecrow ThinkNode M9 | ESP32-S3, LR1110 | 2.4" 240x320 (no touch), I2C QWERTY + d-pad | Web flasher | Beta (new in beta_38) | Hardware-complete community port by ded (#138): GPS, microSD, buzzer, lock screen, d-pad navigation |
| RAK WisMesh Tap V2 (RAK3312) | ESP32-S3, SX1262 | Touch display (LovyanGFX, 30+ fps) | Web flasher | Beta (new in beta_38) | Early community port by Ethac.chen (#136); core mesh, chat and map working |
## Feature notes per board
- **T-Deck**: the everything device: touch, physical keyboard, trackball cursor
or d-pad navigation, microSD (deep 5000-message chat history, map tile packs,
data storage), GPS on the Plus, notification sounds through the I2S speaker.
- **Heltec V4 + TFT**: touch UI with the on-screen keyboard; the optional
Expansion Kit adds environment sensors (home-screen chart) and a piezo
buzzer. V4.3 boards get the switchable high-gain receive LNA toggle.
- **Tanmatsu**: keyboard-driven UI (no touchscreen) with the coloured function
keys mapped to tabs, ALT accent picker, UI scaling (Normal/Large/Huge),
microSD for all persistent data. Ships through the Tanmatsu launcher store,
updates arrive as store updates.
- **ThinkNode M9**: keyboard plus d-pad navigation (no touch), same feature set
as the other boards where the hardware allows. New in beta_38; expect rough
edges and report them.
- **RAK WisMesh Tap V2**: newest port, touch-driven. The browser-flash path is
fresh; if the flasher cannot open the serial port, put the board in download
mode manually and retry, and please report it.
## Requested boards
Open hardware requests, roughly in demand order. Ports are welcome, see
[CONTRIBUTING.md](CONTRIBUTING.md); the M9 and Tap V2 both started as community
PRs.
- LilyGo T-Deck Pro Max: [#62](https://github.com/ALLFATHER-BV/wadamesh/issues/62)
- LilyGo T-pager: [#60](https://github.com/ALLFATHER-BV/wadamesh/issues/60)
- SenseCAP Indicator D1L: [#14](https://github.com/ALLFATHER-BV/wadamesh/issues/14)
## Channels
- **Stable**: the tested default. The flasher installs it unless you pick Beta.
- **Beta**: new features and fixes earlier; on-device opt-in via Settings,
About, "Get test builds (beta)". New boards debut here and move to Stable
with the next promote.
+20 -2
View File
@@ -51,14 +51,32 @@ ed25519 (bundled in lib/ed25519)
AsyncElegantOTA (vendored in arch/esp32/AsyncElegantOTA)
Copyright (c) Ayush Sharma — MIT License
TFT_eSPI (Heltec V4 display backend)
TFT_eSPI (T-LoRa Pager display backend)
Copyright (c) Bodmer — MIT / FreeBSD License — https://github.com/Bodmer/TFT_eSPI
Adafruit GFX / ST7735_ST7789 (T-Deck display backend)
Adafruit GFX / ST7735_ST7789 (T-Deck / Heltec V4 display backend)
Copyright (c) Adafruit Industries — BSD License
chsc6x (Heltec V4 CHSC6x cap-touch driver)
Quency-D — https://github.com/Quency-D/chsc6x
SensorLib (T-LoRa Pager XL9555/BQ27220/AW9364 drivers)
Copyright (c) Lewis He — MIT License — https://github.com/lewisxhe/SensorLib
ES8311 register sequence and clock-divider coefficients (T-LoRa Pager audio
codec driver, variants/lilygo_tlora_pager/Es8311Codec.cpp; T-Display P4 audio
driver, variants/tdisplay_p4/P4Audio.cpp)
Copyright (c) 2023-2026 Espressif Systems (Shanghai) CO LTD
Apache License 2.0 — https://github.com/espressif/esp-adf-libs and
https://github.com/espressif/esp-bsp (components/es8311)
wadamesh's drivers are original code (not copies of esp_codec_dev's
plugin/HAL architecture) but their ES8311 register addresses, startup/
power sequences, and MCLK clock-divider coefficients are derived from
Espressif's public es8311.c/es8311_reg.h; P4Audio.cpp additionally
re-bases the esp-bsp init flow onto Arduino Wire.
LovyanGFX (RAK WisMesh Tap V2 display backend)
Copyright (c) lovyan03 — FreeBSD License — https://github.com/lovyan03/LovyanGFX
This list is not exhaustive and is expanded as dependencies are vendored or
added.
+8 -2
View File
@@ -17,8 +17,14 @@ MeshCore fork via PlatformIO `lib_deps`.
## Boards
- LilyGo T-Deck / T-Deck Plus — env `LilyGo_TDeck_companion_radio_touch`
- Heltec V4 + TFT + CHSC6x touch — env `heltec_v4_tft_companion_radio_usb_tcp_touch`
See **[DEVICES.md](DEVICES.md)** for the full support matrix, install paths and
per-board status.
- LilyGo T-Deck / T-Deck Plus — env `LilyGo_TDeck_companion_radio_touch` (stable)
- Heltec V4 + TFT + CHSC6x touch — env `heltec_v4_tft_companion_radio_usb_tcp_touch` (stable)
- Tanmatsu (ESP32-P4) — built from `tanmatsu/` (ESP-IDF), ships via the Tanmatsu app store
- Elecrow ThinkNode M9 — env `ThinkNode_M9_companion_radio_touch` (beta)
- RAK WisMesh Tap V2 (RAK3312) — env `rak_tap_v2_companion_radio_touch` (beta)
## Architecture
+996
View File
@@ -0,0 +1,996 @@
# T-LoRa Pager port — working tracker
Goal: run the **full** wadamesh UI + functionality on the **LilyGo T-LoRa Pager**
(ESP32-S3, **LR1121** radio variant first), kept as **one codebase** with the
existing boards so a UI change ships everywhere at once. This file is the running
plan/status — update it as we go.
Status: **M7's UI portion DONE — boot logo, full LVGL UI, QWERTY
keyboard nav and rotary encoder all VERIFIED WORKING on real hardware
(2026-07-07)**, after root-causing the intermittent black screen to the ST7796
panel's hardware-reset line floating: it's wired to **XL9555 channel 6**, which
both the LilyGoLib doc's channel table and the canonical arduino-esp32 pins
header omit (full war story: `TLORA_PAGER_M7_HW_DEBUG_LOG.md`, session 2 —
including why session 1's radio/display "SPI race" theory was disproven from
TFT_eSPI source). Hardware-comms gates split out to **Milestone 7b**: radio
vs. a live mesh node is now **DONE** (adverts/DMs confirmed reliable both
directions as of 2026-07-11, after the TX-power fix — risk 10; still watch
upstream LR1121 ACK issue #1376 for anything beyond basic reliability), **SD
storage is DONE** (mount/browse/read/write/delete/insert-remove all
hardware-verified — risk 1h), and USB companion device-profile frame
remains untested — see ⑦b for the current breakdown. **Milestone 8
(on-device UI pass) is now active**: fixing weird UI/keyboard behaviors found
by manual testing on the device, human supplying photos/repro steps.
Notification sound (ES8311 codec + NS4150B amp) landed in **Milestone 8b** —
hardware-verified 2026-07-08 (commit `e682fbc`). **M8 is still active**
(on-device UI pass); besides the nav-coverage/222-px audit that's its actual
stated scope (still not started, see risk 8 — this is the single biggest
remaining item), several concrete bugs surfaced and got fixed this
milestone: the radio-settings blur-autosave lag (risk 12), the encoder not
scrolling open dropdowns (fixed 2026-07-08, commit `4bbfcb5`), the keyboard
backlight never being wired up (risk 9, fixed 2026-07-10), and microSD file
manager + WAV-picker support (risk 1h, fixed 2026-07-10, scoped — DataStore
SD routing/chat-history/tiles/wallpaper/backup remain deferred). An
unread-message LED notification was investigated and explicitly decided
against (risk 13) — this board has no onboard LED at all. Risk 11
(keyboard/encoder-driven scroll not repainting) was **ACCEPTED as
workaround-final 2026-07-13** — the Alt+turn free-scroll gesture is the
permanent, intended fix for this hardware, root cause not being pursued
further absent new evidence (see risk 11). **Milestone 10 (SX1262-variant
env) is DONE and hardware-verified** (2026-07-13, `tlora_pager_sx1262_
companion_radio_touch` — see worklist ⑩): a real SX1262 unit completed
setup and confirmed bidirectional live-mesh messaging (public, a custom `#`
channel, and DM). A cluster of keyboard/encoder input-ergonomics fixes
landed 2026-07-08 through 2026-07-14 — Enter/Backspace chat-bubble parity,
the Shift/Caps rework, the accent-variant and @-mention popups both made
encoder-navigable, WASD map panning + Q/E slider nudging, the Fn+Shift
chord's conditional Caps-Lock-vs-Home-jump behavior, and app-drawer tiles'
focus highlight matching their touch-press accent tint — all hardware-
verified; see the dated paragraphs below and the UI-changes inventory table
for specifics. **M8's own stated scope (the 222-px vertical-layout audit +
full focus-nav-coverage pass) still has not been started** — everything
landed in M8 so far has been reactive bug-fixing from user-reported issues,
not the systematic audit itself (risk 8) — this remains the single biggest
open item in the port.
**M8 session 2026-07-07 findings** (radio + nav, all committed:
`b3c6388`/`df7f9bc`):
- **TX power was silently capped at a conservative 10dBm with no way to raise
it** — unlike the T-Deck (boots straight at 22dBm). Found while
investigating a real-mesh DM test: adverts/DMs sent FROM the pager were
unreliable while inbound reception was fine (the far node's own TX was
unaffected) — classic asymmetric-range signature. Fixed to
`LORA_TX_POWER=22`, confirmed as this chip's real sub-GHz HP-PA ceiling via
RadioLib's `LR1120::checkOutputPower()` (LR1121 inherits it: HP PA
auto-selects above 14dBm, valid range -9..22 below 1GHz) and trail-mate's
own `TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22` for this exact board. Existing
saved prefs on an already-flashed device do NOT pick up a new compiled
default — only a fresh/factory-reset device does; raise it manually in
Settings → Radio & Mesh to test on a unit that predates this fix.
- **Risk 1g (RX-boosted-gain never applied for LR1121) fixed** — see risk
entry below.
- **Encoder direction was inverted** — confirmed on hardware, not a
guess: turning the way a user expects to move forward/down moved
focus/scroll backward/up. Fixed in `PagerEncoder.cpp`'s
`pagerEncoderReadDelta()` (negated once at the source, so every consumer
inherits the fix) rather than in UI code.
- **Found a real, still-partially-open rendering bug**: keyboard/encoder-
driven `lv_obj_scroll_to_view(f, LV_ANIM_OFF)` (in `navFocusCb`) does NOT
reliably repaint the newly-scrolled region on this board — confirmed via
hardware logging that focus and DEFOCUSED save side-effects (blur-to-save
toasts) correctly reach fields far down a settings page, but the glass
keeps showing the OLD scroll position throughout. Small new overlays
(the toast itself) still paint fine, since those are fresh objects, not a
repaint of already-rendered scrolled content. Touch-drag scrolling on the
other boards never surfaced this because LVGL's drag/animation handler
re-invalidates every animation tick, papering over the same gap that a
single one-shot `LV_ANIM_OFF` scroll doesn't cover. **Root cause NOT fixed
yet** — an attempted fix (force `lv_obj_invalidate()` on the scrolled
ancestor after `scroll_to_view`) made things WORSE (very slow, ~5 presses
per visible update) without actually fixing the viewport scroll, and was
reverted. **Workaround shipped instead**: holding the pager's orange Alt
key + turning the encoder now free-scrolls the page via
`navScrollFocused()`'s ANIMATED `lv_obj_scroll_by` (which DOES repaint
correctly, consistent with the animation-tick theory above), then snaps
focus to whatever's nearest the top of the now-visible viewport
(`navRefocusFirstVisible()`). This also fixed a pre-existing bug where
Alt+turn always jumped main tabs even from inside a settings sheet/chat —
now scoped to the main-tab level only (`navOnMainPage()`). Plain
NEXT/PREV-driven `scroll_to_view` on a tall settings page is still broken;
picking that apart further (why does one-shot `LV_ANIM_OFF` fail to
invalidate on THIS board specifically?) is unscheduled follow-up — likely
worth checking whether `lv_timer_handler()`'s own per-pass duration
(`[STALL] ui:lvgl`, separately observed at 400ms–1.6s on this board during
this investigation) is a contributing/related factor, not just the
scroll-invalidate call itself.
**M8 session 2026-07-08, chat-bubble Enter/Backspace ergonomics (commits
`facbe49`/`bf5c682`):** a focused non-composer widget (e.g. a Settings row)
didn't activate on Enter in one specific case — fixed. Also added, matching
Tanmatsu's identical no-touch gestures: Enter on a focused chat message
bubble opens the same Ack/Mention/Copy/Info/Block action menu a touch
long-press opens elsewhere (`navEnterBubble()`, shared code — bubbles are
the focusable leaves inside a chat's `msgs` container, identified by
parent); Backspace jumps straight to the newest message when the "scroll to
bottom" button is showing (this board has no touch to tap that floating
button). The rotary encoder's own short click did NOT get the bubble-menu
behavior in this pass — that gap wasn't closed until 2026-07-14 (below).
**M8 session 2026-07-13, Shift/Caps rework + accent-popup keyboard nav
(commits `79df528`/`43f040e`), hardware-verified:** (1) The Shift/Caps key
(`kCapsPos`, renamed `kShiftPos`) changed from trail-mate's original
bare-press-toggle-Caps-Lock to conventional semantics — held alone it's a
momentary Shift (uppercases while held, releases back to normal); held Alt
(Fn) then a Shift press chords into a Caps Lock toggle. New `s_shift_held`
static, same pattern as `s_backspace_held`/`s_space_held`. One documented
edge case: Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a stray
'q'/'a' (Alt and Shift share row2; col8 is 'o'/'l' on rows 0/1 — a
diode-less-matrix 3-key rectangle, no software fix possible, harmless since
it's not the intended hold-Alt-tap-Shift gesture). (2) The accent-variant
popup (issue #22 — shows accented options when typing a vowel or
`n c s y t z l r`) was completely unreachable on this no-touch board — its
cells are `NAV_SKIP_FLAG`, excluded from the nav group by design (touch
boards pick them by tap). Added a pager-only parallel nav path: Fn(Alt)+
Space arms it, the rotary encoder walks the highlighted variant, the
encoder's own click confirms (synthesizes `LV_EVENT_CLICKED` on the
highlighted cell via `accentNavConfirm()`, reusing `accentBoxCellCb` rather
than duplicating its logic), Backspace cancels without deleting the base
letter. See worklist ⑤ for the full writeup, including the 2026-07-14
follow-up that changed what the Alt+Shift chord actually *does*.
**Same session, Milestone 10 (SX1262-variant env) — DONE, hardware-verified
(commit `4b0c2e9`):** a real SX1262 unit completed setup and confirmed
bidirectional live-mesh messaging (public, a custom `#` channel, and DM).
Full writeup, including the `target.h` missing-include bug found and fixed,
is in worklist ⑩ and `TLORA_PAGER_PORT_MILESTONES.md`'s Milestone 10.
**Same session, WASD map panning + Q/E slider nudge (commit `9881bb0`),
hardware-verified:** the Map tab's pan/drag needed a no-touch equivalent —
W/A/S/D now pan north/west/south/east while the Map tab is active, reusing
`mapNudge()` (previously Tanmatsu's Ctrl+Arrow-only helper, widened to
`defined(HAS_TANMATSU) || defined(TLORA_PAGER)`). This freed up D from the
existing slider-nudge binding (added 2026-07-08 for Control Center
brightness / Settings sliders / the map zoom bar, since none of those can
be dragged without touch or a trackball), which moved from D/F to Q/E so
the two gestures never collide (e.g. the map zoom slider focused while
WASD-panning).
**M8 session 2026-07-14, four more hardware-verified fixes:**
1. **Encoder short click now opens the message action menu too** (commit
`c4c28d4`) — the 2026-07-08 fix above only wired this into keyboard
Enter; the encoder's short click still sent a plain `LV_KEY_ENTER` a
bubble doesn't react to. `updatePagerEncoder()`'s short-click branch now
calls `navEnterBubble()` first, mirroring `handleHwKey()` exactly.
2. **Fn+Shift's effect now depends on UI state** (commit `b16206b`) — the
chord used to toggle Caps Lock unconditionally, even with no text field
focused to see the change reflected in, which was reported as surprising
and purposeless outside of typing. `PagerKeyboard.cpp` no longer decides
the effect (it has no UI visibility) — it only reports the chord via
`pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s new
`updatePagerAltShiftChord()` toggles Caps Lock ONLY while a text field is
actually being edited (same "ta" derivation `handleHwKey()`'s TLORA_PAGER
branch already uses to tell a bound-but-unfocused field apart from one
actually being edited), and jumps straight to Home (`navGoToMainTab`)
everywhere else.
3. **@-mention contact picker made encoder-navigable** (commit `57802eb`) —
the same unreachable-without-touch problem the accent box had, fixed the
same way (`mentionNavRestyle()`/`mentionNavConfirm()` mirror
`accentNavRestyle()`/`accentNavConfirm()` almost exactly), with one
deliberate difference: it grabs the encoder the INSTANT the list appears
rather than requiring an explicit Fn+Space arm, since — unlike the accent
box, which pops up after nearly every letter typed and must not steal
focus from ongoing typing — the mention list only shows once the user
has deliberately typed "@partial" looking for someone to pick, so there's
no ongoing typing to protect.
4. **App-drawer tiles' keyboard/encoder focus highlight normalized to their
own accent tint** (commit `6073b6f`) — tiles previously fell through to
`navFocusCb`'s generic white reverse-video fill when focused via
keyboard/encoder nav (a solid white block over a small rounded icon chip
read as a glitch), instead of the accent-tinted `LV_STATE_PRESSED` look
they already show on a touch press. New `NAV_ACCENTFOCUS_FLAG`
(`LV_OBJ_FLAG_USER_3`) lets `navFocusCb` special-case flagged objects
(mirrors the existing switch/slider special-case) to use that same
accent tint for both states, on every board — not just the pager.
All three envs build green — Milestone ⑥ (UITask wiring)
landed; the pager is a first-class, non-touch UI target. Board JSON verified
(`boards/lilygo-t-lora-pager.json`). Milestones ①–⑥ (variant skeleton, LR1121
radio glue, ST7796 display + AW9364 backlight, platformio.ini env, TCA8418
keyboard + rotary encoder drivers, UITask wiring) landed.
`[env:tlora_pager_lr1121_companion_radio_touch]` compiles clean: **RAM 23.8%
(78088/327680 B), Flash 66.4% (2696465/4063232 B)** — both still comfortably
lower than the two shipping boards (Heltec V4/T-Deck: RAM 25.3%, Flash 73.5%,
unchanged — confirmed byte-identical to pre-M6, zero regression). M1–M5's
build-blocking fixes (variant/pins split, radio-wrapper include paths, the
minimal display-typedef addition) are recorded in Decisions ②/⑧ and worklist
③/⑤ below; M6's own findings follow.
**M6 findings** — the UI-inventory table + worklist ⑥ below have full detail;
the highlights:
- **Two capability flags I initially set were premature and had to be walked
back**: `CAP_SD`/`CAP_FILESYSTEM` looked right from the hardware table (the
pager does have a microSD slot), but the actual mount code
(`fmSdTryMount()`, `#include <SD.h>`) is still hardcoded to
`HAS_TDECK_GT911` specifically — `device_caps.h`'s own `CAP_SD` flag was
never actually wired to it. Turning it on for the pager just produced
`SD`/`CARD_NONE`/`fmSdTryMount` "not declared" errors, not real SD support.
Set back to 0 — a real mount needs its own pager-specific wiring (CS 21, a
non-T-Deck shared-SPI helper), unscheduled follow-up, not this milestone.
- **`navMaybeRebuild()` — the function that actually populates the LVGL focus
group every screen — was unreachable for the pager** in my first pass: it's
only called under `#if defined(HAS_TANMATSU) ... #elif CAP_TRACKBALL ...`,
neither of which the pager matches. Without a fix here the KEYPAD indev
would have an eternally-empty focus group — nothing focusable, navigation
completely dead, despite the indev registration itself being correct. Added
a `#elif defined(TLORA_PAGER)` arm.
- **A self-inflicted `#elif` scoping mistake**: `handleHwKey()` (and several
helpers it calls) live inside a large, multiply "paused and reopened"
`#if defined(HAS_TDECK_KEYBOARD)` region. Widening the wrong reopen point
first orphaned ~50 lines of genuinely T-Deck-specific code (keyboard
backlight-mode timer, notify-flash, spacebar-lock countdown) into what was
meant to be the pager's own simpler branch. Fixed by tracing each reopen's
actual `#endif` (nesting depth, not just grep hits) before touching it, and
keeping the T-Deck-only pieces under their own unwidened, more specific gate.
- **`isDismissKey()`'s T-Deck logic doesn't transfer**: it treats the letters
p/q/a as "dismiss popup" because the T-Deck's sparse keyboard has no
dedicated Esc key. The pager's full QWERTY types those letters constantly —
reusing that mapping would eat normal typing. Returns `false` for the pager
instead; the rotary encoder's long-press already covers ESC.
**Execution playbook: `TLORA_PAGER_PORT_MILESTONES.md`** — the worklist below,
broken into agent-executable milestones with gates and per-file instructions.
---
## Why this port is cheaper than it looks
The scary part — "the device has no touchscreen" — is **already solved in this
codebase**. UITask carries a complete non-touch navigation layer built for the
Tanmatsu (keypad-only) and the T-Deck trackball D-pad mode:
- **Focus-group nav**: `s_nav_group` (`lv_group_t`), amber focus ring + scroll-into-view
(`navFocusCb`), per-screen group rebuild (`navMaybeRebuild`), 2-D directional focus
(`navMoveDir`), tab-bar handling (`navOnTabBar`/`navSwitchTab`) — `UITask.cpp` ~2138–3128.
- **A KEYPAD indev + key FIFO**: `navFifoPush/Pop` feeds `LV_KEY_UP/DOWN/LEFT/RIGHT/
NEXT/PREV/ENTER/ESC` into `tanmatsuKeypadRead` (`UITask.cpp` ~3000). The Tanmatsu
registers ONLY this indev (no pointer) — `UITask.cpp` ~35706–35717. **That branch is
the pager's template.**
- **Physical-keyboard routing**: `handleHwKey()` (`UITask.cpp` ~27990) routes keys
into the focused textarea (edit mode) or into nav (navigate mode, `s_nav_ta_editing`
flag), with the on-screen LVGL keyboard suppressed — exactly how the T-Deck works
today. The pager's QWERTY plugs into this unchanged.
- **Rotary encoder**: no `LV_INDEV_TYPE_ENCODER` needed — encoder ticks map to
`navFifoPush(LV_KEY_NEXT/PREV)` (focus walk), press → `ENTER`, long-press → `ESC`.
Reuses everything above.
So the genuinely NEW work is: the **480×222 ST7796 display driver + layout pass**,
the **TCA8418 keyboard driver** (raw matrix → chars, unlike the T-Deck's C3 which
resolves ASCII for us), the **rotary driver**, the **LR1121 radio glue**, and a
**board class** whose battery/power goes through I²C chips (fuel gauge + IO
expander) instead of an ADC pin.
## Hardware / platform facts
Confirmed from the LilyGo product page, CNX-Software (2025-08-12), Meshtastic docs,
and cross-checked against three working/authoritative sources: upstream
`meshcore-dev/MeshCore`'s pager target (see caveat under Decision ②), the official
[LilyGoLib hardware page](https://github.com/Xinyuan-LilyGO/LilyGoLib/blob/master/docs/hardware/lilygo-t-lora-pager.md),
and `~/dev/trail-mate` (local project with a running LR1121 pager build — pin map
below is from its `variants/lilygo_tlora_pager/pins_arduino.h`, which is
byte-identical on every pin/channel to the canonical
[`espressif/arduino-esp32` pins_arduino.h](https://github.com/espressif/arduino-esp32/blob/master/variants/lilygo_tlora_pager/pins_arduino.h)
for this board). **Caveat**: the LilyGoLib page's own "Pins Map" table has
internal inconsistencies for the XL9555 channel assignments (e.g. it lists
keyboard-enable at ch10 and SD-detect/enable at ch12/ch14, which contradicts
its own "PowerManage Channel" table on the same page *and* the canonical
arduino-esp32 header). We treat the arduino-esp32 header + trail-mate (which
agree with each other and with the "PowerManage Channel" table) as ground
truth — that's what our own `TLoraPagerBoard.cpp` already uses.
| | |
|---|---|
| SoC | ESP32-**S3** @ 240 MHz, 16 MB flash (QIO), **8 MB QSPI PSRAM** (`memory_type: qio_qspi`) |
| Display | 2.33" IPS **ST7796U**, 480×222 (221 PPI, 262K colors, 450 cd/m²), **480×222 landscape**, SPI, **no touch**. CS 38, DC 37, backlight 42 (AW9364 16-level stepped driver). **Hardware reset is XL9555 ch6, NOT unwired** — `TFT_RST=-1` only because it isn't an ESP GPIO; `TLoraPagerBoard::begin()` owns the reset pulse (LOW→50ms→HIGH, vendor sequence). Leaving ch6 floating = intermittent black screen, clean boot log (M7 root cause) |
| Shared SPI bus | **SCK 35, MOSI 34, MISO 33** — display + LoRa + SD + ST25R3916 NFC all on it (like the T-Deck's 40/41/38 — solved pattern, CS discipline + SPI transactions) |
| Radio | **LR1121** (sub-GHz 830–945 MHz + 2.4 GHz; we use sub-GHz only). CS 36, RST 47, BUSY 48, IRQ/DIO1 14. **Also sold with SX1262** (the LilyGoLib page documents the SX1262 retail SKU as primary) — same board/pins, different defines (cheap 2nd env later) |
| Keyboard | Physical QWERTY via **TCA8418** I²C matrix controller (addr `0x34`), INT 6, backlight 46. Raw matrix events — keymap/shift/sym handled on our side |
| Encoder | Rotary A 40, B 41, **press 7** |
| Buttons | **BOOT = GPIO0** (usable as user button + sleep wake — matches both existing boards' `PIN_USER_BTN=0`). Physical power key is PMU QON, not a GPIO — can only wake the device (1s hold), never programmable |
| Power | **BQ25896** charger PMU (addr `0x6B`) + **BQ27220 fuel gauge** (addr `0x55`, battery % / mV over I²C — NOT an ADC divider). Battery: 3.7 V / 1500 mAh (5.55 Wh). DeepSleep ≈530 µA, LightSleep ≈2.26 mA, Power-off ≈26 µA |
| IO expander | **XL9555** (addr `0x20`): DRV_EN ch0, AMP_EN ch1, KB_RST ch2, LORA_EN ch3, GPS_EN ch4, NFC_EN ch5, **DISP_RST ch6 — the ST7796 panel's hardware reset** (missing from BOTH the LilyGoLib doc's channel table and arduino-esp32 master's pins header; confirmed from LilyGoLib's `LilyGo_LoRa_Pager.cpp` begin(), which pulses it LOW→50ms→HIGH before display init — leaving it floating was M7's intermittent-black-screen root cause, see `TLORA_PAGER_M7_HW_DEBUG_LOG.md` session 2), GPS_RST ch7, KB_EN ch8, GPIO_EN ch9, SD_DET ch10, SD_PULLEN ch11, SD_EN ch12 |
| GPS | u-blox **MIA-M10Q**: TX 12, RX 4, PPS 13 |
| I²C bus | SDA 3, SCL 2 — shared by TCA8418 (`0x34`), XL9555 (`0x20`), BQ25896 (`0x6B`), BQ27220 (`0x55`), PCF85063 RTC (`0x51`), BHI260AP IMU (`0x28`), DRV2605 haptics (`0x5A`), ES8311 codec (`0x18`) |
| SD | microSD on the shared SPI bus, CS 21, card-detect via expander (ch10), max 32 GB, **FAT32 only** |
| Audio | ES8311 codec (I²C `0x18`; I2S MCLK 10, SCK/BCK 11, WS 18, SDOUT 45, SDIN 17 — confirmed against arduino-esp32's canonical `variants/lilygo_tlora_pager/pins_arduino.h`, matching LilyGoLib's own `codec.setPins(I2S_MCLK, I2S_SCK, I2S_WS, I2S_SDOUT, I2S_SDIN)` call in `LilyGo_LoRa_Pager.cpp`) driving an **NS4150B** 3 W Class-D amp (enabled via expander AMP_EN ch1, toggled at runtime around playback — not the boot-time permanently-on drive; see Decision below) |
| Misc | PCF85063A RTC (INT 1), BHI260AP IMU (INT 8), ST25R3916 NFC (unused — CS 39, INT 5, powered via expander NFC_EN ch5), USB VID/PID `0x303A:0x82D4` |
## Board JSON — VERIFIED ✅ (landed as `boards/lilygo-t-lora-pager.json`)
The JSON (Meshtastic-lineage; byte-identical to trail-mate's copy) is **correct for
the LR1121 unit but radio-agnostic**: it describes only the S3 module (flash/PSRAM/
USB id/CDC-on-boot), which every radio variant of the pager shares. The radio is
selected by our build flags (`RADIO_CLASS`/`WRAPPER_CLASS` + pins), same as the
existing boards. Notes:
- `partitions: app3M_fat9M_16MB.csv` is just the default — we override with our own
OTA+tiles+spiffs csv via `board_build.partitions` (see worklist ①).
- `variant: lilygo_tlora_pager` + `variants_dir: variants` → needs
`variants/lilygo_tlora_pager/pins_arduino.h` (write our own; don't copy
trail-mate's — it brands `USB_PRODUCT "TRAIL MATE"`).
- `-DARDUINO_USB_MODE=1` (HW-CDC) is in the JSON's extra_flags. The T-Deck ships
MODE=1 fine; the Heltec V4 regressed on it (large companion frames dropped —
see the note in `platformio.ini`). **Verify the device-profile frame over USB
companion early** (worklist ⑦); if it drops bytes, switch to TinyUSB CDC like
the V4.
## Decisions (architecture)
1. **Normal PlatformIO env, T-Deck model** — NOT the Tanmatsu IDF-subproject route.
The pager is a plain ESP32-S3 Arduino target; it slots into `platformio.ini`
next to the existing two envs and into `release.sh`'s env list later.
2. **LR1121 wrappers vendored in the variant dir — zero core-fork churn for
bring-up.** The core fork (`core-v1.16.5`) only has `CustomLR1110*`.
**Re-verified 2026-07-06: upstream `meshcore-dev/MeshCore`'s `main` branch does
NOT currently have `CustomLR1121*` or a `variants/lilygo_tlora_pager/` dir**
(checked `src/helpers/radiolib/` — only `CustomLR1110{,Wrapper}.h` exists there
too; issue [meshcore-dev/MeshCore#861](https://github.com/meshcore-dev/MeshCore/issues/861)
"Support for LR1121" is still open). Earlier research that assumed an
upstream crib source was wrong or looked at a branch/fork that no longer
exists — **re-check upstream at Milestone ② time**, but plan for having to
author `CustomLR1121{,Wrapper}.h` ourselves by adapting the core fork's own
`CustomLR1110{,Wrapper}.h` pair (same RadioLib `LR11x0` family — swap the
base type from `LR1110` to `LR1121`), cross-checked against trail-mate's
`initLoRa()` (which drives RadioLib's stock `LR1121` class directly, no
custom wrapper) for the RF-switch table / `setTCXO` sequence. Since
`CustomLR1121{,Wrapper}.h` only subclass RadioLib's `LR1121` and the core's
`RadioLibWrapper` (both on the include path), they can live in
`variants/lilygo_tlora_pager/` for now and move into the fork at the next
`core-*` tag. Precedent: the Tanmatsu keeps its whole radio bridge in its
variant dir.
**Found at Milestone ④'s first real compile**: `CustomLR1121Wrapper.h`'s
bare quoted `#include "RadioLibWrappers.h"`/`"LR11x0Reset.h"` (copied
verbatim from `CustomLR1110Wrapper.h`'s shape) don't resolve — those work
in the core fork only because the file sits in the *same*
`src/helpers/radiolib/` directory as its targets (quote-include searches
the including file's own directory first); ours lives in the variant dir
instead. Fixed to angle-bracket `<helpers/radiolib/...>` includes, matching
`target.h`'s already-correct pattern for the same headers.
3. **LR1121 init is explicit** (no `std_init`): RF-switch table on **DIO5/DIO6**
(`STBY {L,L} / RX {L,H} / TX {H,L} / TX_HP {H,L}`) + **`setTCXO(3.0f)`** —
confirmed in both trail-mate (`boards/tlora_pager/src/tlora_pager_board.cpp`,
`initLoRa()`) and upstream. Sync word / preamble / CR come from the same
NodePrefs plumbing as the other boards so it interoperates with the mesh.
4. **Display = new `ST7796LCDDisplay` app-side** (in `src/helpers/ui/`), implementing
the core's `DisplayDriver` interface (`begin/width/height/startFrame/endFrame/
setDisplayRotation/writePixelsRGB565`) — that's all the LVGL flush path uses.
TFT_eSPI has `ST7796_DRIVER` (`USER_SETUP_LOADED` + `-D` pin set).
**Correction, landed with Milestone ③**: "the Heltec V4 already builds on
TFT_eSPI, so mirror that wiring" was wrong — `ST7789LCDDisplay` (Heltec
V4's *and* T-Deck's display class, same file) is actually Adafruit_GFX/
Adafruit_ST7789-based; Heltec V4's TFT_eSPI lib_dep/`-D` flags are
vestigial (nothing else in the repo `#include`s `TFT_eSPI.h`). This makes
`ST7796LCDDisplay` the first real TFT_eSPI consumer in this codebase —
every method call was verified directly against the pinned
`bodmer/TFT_eSPI @ ^2.5.43` source, not cribbed from the sibling class.
**Also found and mitigated**: this panel's 222px glass is narrower than
the ST7796 controller's 320px GRAM, requiring a 49px column/row offset
that TFT_eSPI only applies automatically when `-D CGRAM_OFFSET=1` is set —
missing from this decision's original flag list; a `#error` guard in
`ST7796LCDDisplay.cpp` now catches the omission at M4 compile time (see
worklist ③ for the full writeup). Backlight is the AW9364 (stepped pulse
dimming), not a plain GPIO PWM — wrapped in `ST7796LCDDisplay` itself using
`lewisxhe/SensorLib`'s `AW9364LedDriver` directly (a maintained library,
already an M1-established dependency) rather than a hand-rolled driver.
5. **Input = the Tanmatsu registration branch** (KEYPAD indev + `s_nav_group` only,
no pointer indev), gated by a new device cap. Rotary → nav FIFO; TCA8418 →
`handleHwKey()`. New pollable drivers in `src/helpers/input/` following the
existing style (begin/poll/read API, no LVGL inside the driver).
6. **Battery/power via libraries, not hand-rolled** (CONTRIBUTING rule): lewisxhe
**SensorLib** covers BQ27220 (`GaugeBQ27220`), XL9555 (`ExtensionIOXL9555`),
PCF85063, DRV2605; **XPowersLib** covers the BQ25896. `TLoraPagerBoard :
public ESP32Board` overrides `getBattMilliVolts()` (gauge query),
`getManufacturerName()`, power-rail bring-up in `begin()` (expander), and sleep.
7. **One codebase**: all pager-specific UI behavior rides existing/new `CAP_*`
flags in `src/ui-touch/device_caps.h` — no forked screens.
8. **`variants/lilygo_tlora_pager/` app glue and the board's Arduino "variant"
pin map had to split into two directories** — found only by actually
compiling in Milestone ④, not anticipated by any earlier decision.
PlatformIO's arduino-esp32 build script (`platformio-build.py`) always
compiles every source file under `board_build.variants_dir/<board.variant>/`
as a separate `FrameworkArduinoVariant` library, in a build context with
none of our app's `lib_deps` include paths. T-Deck/Heltec V4 never hit this
because their board JSONs point `"variant"` at a variant the *framework*
already bundles (`esp32s3`, `heltec_v4`) — completely separate from our own
`variants/lilygo_tdeck/`/`variants/heltec_v4/` app-glue directories, zero
collision. The pager has no framework-bundled variant, so its board JSON
was forced to set `"variants_dir": "variants"`, pointing PlatformIO's
variant resolution at the exact same directory we'd used for
`TLoraPagerBoard.*`/`target.*`/`CustomLR1121*` — so the framework tried to
compile those too, and failed on missing `Wire.h`/`RadioLib.h`. Fixed by
moving `pins_arduino.h` alone into a new `variants/lilygo_tlora_pager_pins/`
folder and pointing the board JSON's `"variant"` there instead; our own
`TLoraPagerBoard.*`/`target.*`/`CustomLR1121*` stay in
`variants/lilygo_tlora_pager/`, reached only via our own
`build_src_filter`, exactly once. Confirmed safe against
`get_partition_table_csv()`'s similar `variants_dir`-based fallback logic
(moot here since `board_build.partitions` is set to a full explicit path,
which that function returns verbatim once its variant-relative lookup
attempts fail).
9. **Notification sound: hand-rolled ES8311 driver, not a vendored codec HAL.**
`espressif/esp_codec_dev`'s ES8311 device (Apache-2.0, ~4.5k lines across
its generic multi-codec plugin architecture) is more than this
single-chip, playback-only board needs, and defaults to the *new*
`driver/i2s_std.h` API where this codebase's existing T-Deck sound code
uses the legacy `driver/i2s.h` (which already supports MCLK via
`i2s_pin_config_t.mck_io_num`, so no new API family was needed). Wrote
`variants/lilygo_tlora_pager/Es8311Codec.{h,cpp}` instead — ~200 lines,
DAC-only, register sequence and MCLK clock-divider coefficients derived
from Espressif's public `es8311.c`/`es8311_reg.h` (see `NOTICE`), fixed to
a 256x-sample-rate MCLK ratio covering the standard 8k-48k rates
`wavParse()` already accepts. Matches this port's own precedent (T-Deck's
sound code is similarly hand-rolled, ~200 lines) and the milestone rule
against introducing new abstraction layers.
10. **AMP_EN toggled around playback, not left permanently on.** The
boot-time `TLoraPagerBoard::begin()` drive of every expander rail
(including AMP_EN) exists for bus-integrity reasons specific to the boot
window (unpowered chips clamping the shared I²C bus) — unrelated to
audio and left untouched.
`TLoraPagerBoard::setAmpEnabled()` is the runtime toggle the sound code
brackets each chime/WAV with (amp off at idle), matching both the
vendor's own `codec.setPaPinCallback()` pattern and this port's existing
on-demand-resource philosophy (T-Deck installs/uninstalls its I2S driver
per chime for the same reason). Cost is one extra I²C write per
chime start/end on a bus already used for every other rail toggle.
## Worklist (ordered, each step ends build-green for ALL envs)
- [x] ⓪ Research + board JSON verification; land `boards/lilygo-t-lora-pager.json` + this tracker.
- [x] ① **Variant skeleton**: `variants/lilygo_tlora_pager/{pins_arduino.h, TLoraPagerBoard.h/.cpp}` + `partitions_tlora_pager_touch.csv` (T-Deck's OTA/tiles/spiffs/coredump layout, byte-identical offsets). Board class (`TLoraPagerBoard : public ESP32Board`): `begin()` re-inits Wire on SDA3/SCL2, probes the XL9555 expander (addr 0x20) and enables LORA_EN/GPS_EN/KB_EN/KB_RST/SD_EN rails, probes the BQ27220 gauge, handles the deep-sleep RX-packet wake reason (mirrors `TDeckBoard.cpp`). `getBattMilliVolts()` reads the gauge (`refresh()` + `getVoltage()`), falling back to 3700 mV if the probe/refresh fails. `getManufacturerName()` → "LilyGo T-LoRa Pager". `enterDeepSleep()` mirrors `TDeckBoard.h`. BQ25896 charger deliberately left as a TODO (gauge alone covers the UI). `target.{h,cpp}` deferred to ② — nothing references the new files yet, so both shipping envs build unchanged (verified green).
- [x] ② **Radio**: `variants/lilygo_tlora_pager/{CustomLR1121.h, CustomLR1121Wrapper.h, target.h, target.cpp}`.
`CustomLR1121{,Wrapper}.h` authored by adapting the core fork's own
`CustomLR1110{,Wrapper}.h` (LR1110/LR1121 share RadioLib's `LR11x0` base —
confirmed by reading RadioLib 7.6.0 source directly: same protected
`freqMHz`/`spreadingFactor` members, same `getIrqStatus()`/`getRssiInst()`
inherited from `LRxxxx`/`LR11x0`), since upstream has nothing to crib (see
Decision ②). `radio_init()` in `target.cpp`: `spi.begin(...)` →
`radio.begin(LORA_FREQ, LORA_BW, LORA_SF, cr, ..._SYNC_WORD_PRIVATE,
LORA_TX_POWER, 8, 3.0f)` → `setRfSwitchTable(DIO5/DIO6)` → `setCRC(1)`.
**Three deliberate deviations from trail-mate, found by reading RadioLib's
actual source rather than copying its call sequence — see Decision ② for
the full reasoning:**
1. No explicit `radio.reset()` before `begin()` — RadioLib's
`LR11x0::modSetup()`→`findChip()` already resets the chip internally
(with retries); trail-mate's explicit reset is redundant, not wrong.
2. No second `radio.setTCXO(3.0f)` call after `begin()` — trail-mate only
needs that because its `initLoRa()` calls the *zero-arg* `begin()`
(default `tcxoVoltage=1.6V`) and fixes it up after. Our `begin()` passes
`3.0f` as the 8th arg directly, which `LR11x0::modSetup()` already
applies internally — a second call would be a no-op.
3. Added `radio.setCRC(1)` after `begin()` (LR11x0's `begin()` defaults to a
2-byte CRC) to match the 1-byte CRC every other MeshCore radio wrapper
uses for wire-protocol interop (`CustomSX1262::std_init()` makes the
identical override) — trail-mate never needed this since its app isn't
interoperating with MeshCore's own framing.
Also **not** calling `rtc_clock.begin(Wire)` in `radio_init()` — see the new
Risk item below (RTC address collision). `RfSwitchMode_t`/`OpMode_t`/DIO5-6
constants verified to exist with the expected shape directly in the pinned
`jgromes/RadioLib @ ^7.6.0` source (not just trusted from trail-mate).
Env defines for M4 to use: `RADIO_CLASS=CustomLR1121`,
`WRAPPER_CLASS=CustomLR1121Wrapper`, `P_LORA_NSS=36 / _RESET=47 / _BUSY=48 /
_DIO_1=14`, SPI 35/34/33, **`PIN_GPS_RX=12` / `PIN_GPS_TX=4`** (see the GPS
risk item below — these are swapped relative to trail-mate's raw
`GPS_RX`/`GPS_TX` macro values, on purpose), `GPS_BAUD_RATE=38400`. Keep the
standard `RADIOLIB_EXCLUDE_*` set (LR11X0 stays IN; can also exclude SX126X
here).
- [x] ③ **Display**: `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` on TFT_eSPI
(`ST7796_DRIVER`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, MADCTL rotation for
landscape). This is the **first real TFT_eSPI consumer in this codebase** —
`ST7789LCDDisplay` (the sibling this was "modeled on") turned out to be
Adafruit_GFX/Adafruit_ST7789-based for both Heltec V4 and T-Deck; the
Heltec V4 env's TFT_eSPI lib_dep/`-D` flags are vestigial (nothing
`#include`s `TFT_eSPI.h` anywhere else in the repo). Only the
`DisplayDriver`-satisfying shape was mirrored, not any API calls — every
TFT_eSPI method used (`init`, `setRotation`, `setAttribute`/`CP437_SWITCH`,
`textWidth`, `pushColors`, `writecommand`/`TFT_DISPOFF`, `fillScreen`,
`fillRect`/`drawRect`/`drawPixel`, `setAddrWindow`/`startWrite`/`endWrite`)
was verified directly against the pinned `bodmer/TFT_eSPI @ ^2.5.43` source
in `.pio/libdeps/*/TFT_eSPI/TFT_eSPI.h`, not assumed.
**Found and fixed a real correctness bug in the same pass**: this panel's
222px glass is narrower than the ST7796 controller's 320px GRAM (trail-mate
applies explicit 49px column/row offsets — confirmed the source of the
magic number: `320 - 222 = 98`, halved/centered = 49px each side). TFT_eSPI's
own `ST7796_Rotation.h` already has this exact fix (`colstart=49`/
`rowstart=49` depending on rotation, applied automatically inside every
`setAddrWindow()` call) — but only when `CGRAM_OFFSET` is `#define`d.
Unlike `ST7789_Defines.h` (which self-defines it), `ST7796_Defines.h` does
not, and it was **missing from this milestone's own drafted M4 flag list**.
Without it the build compiles clean but every frame renders shifted/cropped
by 49px with no error — added a `#error` guard in the new `.cpp` that fires
the moment M4 compiles this file without the flag, with the fix spelled out
in the message. **M4 must add `-D CGRAM_OFFSET=1`.**
Backlight: AW9364 stepped pulse-dimmer, wrapped directly in
`ST7796LCDDisplay` (no separate `Aw9364Backlight.{h,cpp}` — `ST7789LCDDisplay`
turned out to have no brightness hook to mirror at all; brightness on the
other two boards is a `UITask.cpp`-owned free function doing raw LEDC PWM
on `PIN_TFT_LEDA_CTL`, which the AW9364's pulse protocol can't use).
Consumes `lewisxhe/SensorLib`'s `AW9364LedDriver` directly (header-only,
already vendored via the M1-established SensorLib dependency) rather than
hand-rolling the pulse timing the milestone doc originally suggested.
Exposes `setBrightness(uint8_t pct)`/`getBrightness()` (0-100, matching the
Settings UI's existing convention) so Milestone 6 can wire it in with one
line. **M6 needs a new branch ahead of `UITask.cpp`'s existing
`PIN_TFT_LEDA_CTL` PWM branch** — once M4 defines that macro for the pager,
the existing LEDC-PWM code would also compile and fight the AW9364's pulse
protocol (a duty cycle is not a valid input to this chip). `NOTICE` updated:
added a `SensorLib` entry (missing since M1) and corrected the `TFT_eSPI`/
`Adafruit GFX` lines' backend descriptions (both were wrong about which
boards use which library). `DISPLAY_CLASS=ST7796LCDDisplay`. Gate: both
shipping envs build unchanged (neither's `build_src_filter` references
`helpers/ui/*.cpp` yet, so the new TU isn't even parsed by either compiler
today) — full compiler verification of the new code waits for M4.
- [x] ④ **Env**: `[env:tlora_pager_lr1121_companion_radio_touch]` in
`platformio.ini`, cloned from the T-Deck env with board/radio/display/GPS/
input deltas (see Decisions ①-④, ⑥-⑧ for exact values and reasoning). Added
lib_deps: `bodmer/TFT_eSPI @ ^2.5.43`, `adafruit/Adafruit TCA8418 @ ^1.0.2`,
`lewisxhe/SensorLib @ 0.3.3` (exact version trail-mate proves works, not
guessed) — **dropped** `adafruit/Adafruit ST7735 and ST7789 Library` from
the cloned list (T-Deck/Heltec's Adafruit display backend; unused here,
TFT_eSPI replaces it). No `XPowersLib` (M1 left the BQ25896 charger out of
scope). `default_envs` left unchanged (still just the two shipping boards).
Also fixed two structural bugs found only by actually compiling (see
Decision ⑧) and added one forced, minimal `UITask.cpp` `#elif
defined(TLORA_PAGER)` arm (display-class typedef only — see the Status
line). **Compile gate: all three envs build green** — verified.
- [x] ⑤ **Input drivers**: `src/helpers/input/{PagerKeyboard,PagerEncoder}.{h,cpp}`.
Gated `HAS_PAGER_KEYBOARD`/`HAS_PAGER_ENCODER`, already reached by every
env's `+<helpers/input/*.cpp>` filter — no `platformio.ini` change needed
for compilation, only the pin flags (`KB_INT=6`, `KB_BACKLIGHT=46`,
`ROTARY_A=40`, `ROTARY_B=41`, `ROTARY_C=7`, matching the repo's
explicit-`-D`-alongside-`.cpp`-fallback convention).
**`PagerKeyboard`**: T-Deck's own keyboard driver turned out to be the
wrong shape to mirror directly — its C3 co-processor resolves ASCII itself
over I2C, so `TDeckKeyboard.cpp` never sees a raw matrix event. The
TCA8418 (Adafruit_TCA8418 lib, already an M4 dependency) reports raw
row/col events instead, so the keymap + shift/sym/alt state machine lives
in `PagerKeyboard.cpp` itself. Reused trail-mate's `LilyGoKeyboard` keymap
tables verbatim (same physical PCB) — `keymap[4][10]`/`symbol_map[4][10]`,
Alt as a hold-to-symbol-layer modifier (no separate physical Symbol key on
this hardware), Backspace special-cased to `'\b'` — UX choices trail-mate
already field-validated, reused rather than re-derived. **The Shift/Caps
key's behavior changed 2026-07-13** from trail-mate's original
press-to-toggle case lock to conventional keyboard semantics: held alone
it's a momentary Shift (uppercases whatever letters are typed while held,
releases back to normal); held Alt + a Shift press instead chords. `kCapsPos`
renamed `kShiftPos` to match. **Chord effect changed again 2026-07-14**:
`PagerKeyboard.cpp` no longer decides what Alt+Shift *does* (it has no UI
visibility) — it only reports the chord via
`pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s
`updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text
field is actually being edited (same "ta" derivation as `handleHwKey()`'s
TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere
else — toggling Caps Lock with no field to see it change in was reported as
surprising/purposeless outside of typing.
See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented
edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a
stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the
intended gesture). Ring-buffer/SPSC/threading-contract shape
mirrors `TDeckKeyboard.cpp`'s (single poll context, UI-thread-safe
`readKey()`) even though the underlying hardware access differs completely.
Implemented as straightforward polling (`available()`/`getEvent()` drained
every `pagerKeyboardPoll()` call), not INT-pin-gated, despite the milestone
doc's "prefer INT-driven drain" suggestion — trail-mate's own INT handling
does extra `INT_STAT`/`GPIO_INT_STAT` register bookkeeping whose exact
clear-vs-latch semantics weren't independently verified, and an ISR-gated
poll that's wrong would present as "keyboard stops after first keypress" —
a regression only caught on hardware (M7). Polling is explicitly sanctioned
as a fallback by the milestone doc and carries no such risk; INT-driven
draining is a valid future optimization once verified on real hardware.
**`PagerEncoder`**: the milestone's "ISR edge-counting exactly like
`TDeckTrackball.cpp:27-47`" doesn't transfer literally — the T-Deck
trackball is 4 independent direction-pulse GPIOs (no direction logic
needed, each pin already means one direction), not a true A/B quadrature
pair, so it can't answer "which way did it turn." Implemented a standard
Gray-code quadrature transition table instead (both edges of both A and B
feed one ISR, table lookup yields +1/-1/0 per transition) — same
ISR-does-cheap-arithmetic-only shape and `noInterrupts()`-snapshot read
pattern as `TDeckTrackball.cpp`, just the right decode logic for a genuine
quadrature signal. Divides by `PAGER_ENCODER_STEPS_PER_DETENT` (default 4,
the common EC11-style ratio) to convert raw transitions to detents,
carrying the remainder forward across reads — **this divisor is an
unverified assumption, confirm on hardware in M7**.
**Gate:** all three envs build — verified.
- [x] ⑥ **UITask wiring**: `device_caps.h` got its `TLORA_PAGER` cap block (no
touch, no rotate, no large-screen, GPS 1, OTA 1, lock-screen 1; `CAP_SD`/
`CAP_FILESYSTEM` corrected to 0 — see below) plus widened `CAP_KEYBOARD`/
`CAP_KEYPAD_NAV` derivations to recognize `HAS_PAGER_KEYBOARD`/
`TLORA_PAGER`. `UITask.cpp` changes, all pager-gated: KEYPAD indev
registered by widening the Tanmatsu branch's `#if` to
`defined(HAS_TANMATSU) || defined(TLORA_PAGER)` (~35706) — pager reuses
`tanmatsuKeypadRead`'s plumbing but not `bsp_input_get_queue`, which stays
Tanmatsu-only; forced `s_ui_rotation = LV_DISP_ROT_270` and a new
`hor_res=480/ver_res=222` branch (~35626); a new `updatePagerEncoder()`
(delta → `navPushTap(NEXT/PREV)`, click → ENTER, long-press ≥1000ms →
ESC); main-loop drain wired for both `pagerKeyboardPoll()`/
`pagerKeyboardReadKey()` → `handleHwKey()` and the encoder function above;
`applyBrightness()`/`touchScreenBacklight()` got pager-first branches
calling `display.setBrightness()` instead of falling into the
`PIN_TFT_LEDA_CTL` PWM branch (closes risk 1f — PWM would never have
worked on the AW9364's discrete-pulse interface). Draw buffer already
sized off `hor_res` so needed no separate edit. 222-px vertical audit
deferred to on-device measurement (M8) rather than guessed constants — see
risk 8 below. Final sizes: pager RAM 23.8% (78088/327680 B), Flash 66.4%
(2696465/4063232 B); Heltec V4/T-Deck unchanged at 25.3%/73.5% (byte-
identical to pre-M6, confirming the gate on every edit). Three corrections
vs. the milestone doc's assumptions, all found by compiling rather than
assumed — see risks 1h/1i/1j:
- `CAP_SD`/`CAP_FILESYSTEM` looked right from the hardware (real microSD
slot) but the actual mount code is hardcoded to `HAS_TDECK_GT911`, not
truly `CAP_SD`-generic — set back to 0, real support is unscheduled
follow-up.
- `navMaybeRebuild()` (populates the LVGL focus group every screen) wasn't
reachable on the pager's own cap combination — added a
`#elif defined(TLORA_PAGER)` arm; without it the KEYPAD indev would have
had a permanently empty focus group.
- Several T-Deck-keyboard-gated helpers (`handleHwKey`, `isDismissKey`,
`tabForKey`, `navMenubarKeysSync`) live inside multiply "paused and
reopened" `#if defined(HAS_TDECK_KEYBOARD)` regions; widened each
reopen's gate individually (tracing real nesting depth, not just grep
hits) rather than touching the whole file, and kept genuinely
T-Deck-only sub-logic (the p/q/a dismiss-key mapping, which would eat
normal QWERTY typing on the pager) under its own narrower, unwidened gate.
- [x] ⑦ **Headless bring-up gate, UI portion** (needs hardware) — DONE
(2026-07-06/07 sessions, see `TLORA_PAGER_M7_HW_DEBUG_LOG.md`): flash +
serial recipes established; boot clean; SPIFFS mounts; display + boot logo +
full UI + keyboard nav + encoder verified on the glass (after 6 real bugs —
stale-NVS BLE bonds, screen-wake path, TFT_eSPI S3 SPI-port index, panel
INVON, two-SPI-hosts pin theft, and the floating XL9555-ch6 panel reset).
- [~] ⑦b **Radio/USB/SD bring-up gates** (needs hardware + second mesh node) —
**radio joins the live mesh: DONE**, adverts/DMs confirmed reliable both
directions against a second node after the TX-power fix (risk 10,
retested 2026-07-11) — still watch upstream LR1121 ACK issue
meshcore-dev/MeshCore#1376 for anything beyond basic reliability. USB
companion link passing the large device-profile frame (see HW-CDC note
above): still untested. **SD storage: DONE** — real mount, browse,
read/write/delete, and insert/remove detection all confirmed on hardware
(risk 1h). (DataStore/NVS-level SD use — routing contacts/channels to the
card — is a separate, still-deferred feature, not part of this bring-up
gate.) Remove the temporary `[DISP]` register readback in
`ST7796LCDDisplay::begin()` once the USB piece passes.
- [~] ⑧ **On-device UI pass** — ACTIVE: nav-coverage audit screen by screen (every interactive control reachable via focus group — the Tanmatsu work paved this), chat layout at 222 px, map pan via encoder/keys, fonts legibility at 480-wide, plus fixing any weird UI/keyboard behaviors surfaced by manual testing (human provides photos/repro steps).
- [ ] ⑨ **Release pipeline**: add the env:binname pair to `release.sh` `ENVS`, flasher manifest (`deploy/flasher/manifest-tlora-pager.json`), OTA env name via `FIRMWARE_OTA_ENV`. Separate PR.
- [x] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — **DONE, hardware-verified 2026-07-13** (`tlora_pager_sx1262_companion_radio_touch`, all 4 envs green; see `TLORA_PAGER_PORT_MILESTONES.md`'s Milestone 10 for the full writeup, including the `target.h` missing-include bug found + fixed). Radio gate (M7 step 3) confirmed on real hardware: user sent + received messages on public, a custom `#` channel, and DM.
## UI-changes inventory (what actually changes in `src/ui-touch/`)
| Area | Change | Size | Status |
|---|---|---|---|
| Indev registration (~35706) | Widened the Tanmatsu keypad-only branch's gate to `\|\| defined(TLORA_PAGER)`; no pointer indev registered | small | done |
| Input drain (main loop ~37350) | Pager branch: `pagerKeyboardPoll()`+`pagerKeyboardReadKey()`→`handleHwKey()`; `updatePagerEncoder()`→`navPushTap(NEXT/PREV/ENTER/ESC)` | small | done |
| `handleHwKey()` (~27990) and its multiply-reopened `HAS_TDECK_KEYBOARD` regions | Widened each reopen's gate individually to admit `HAS_PAGER_KEYBOARD`; kept the T-Deck-only p/q/a dismiss mapping under its own narrower gate | small | done |
| Resolution block (~35604–35650) | New branch: forced `LV_DISP_ROT_270`, `hor_res=480/ver_res=222` | small | done |
| Draw buffer (~1399) | Sized off `hor_res`, no separate edit needed | trivial | done (no-op) |
| Brightness (`applyBrightness`~28906, `touchScreenBacklight`~36511) | New pager-first branches calling `display.setBrightness()` instead of the PWM `PIN_TFT_LEDA_CTL` path | small | done |
| `navMaybeRebuild()` reachability | Added `#elif defined(TLORA_PAGER)` arm — was unreachable, would've left focus group permanently empty | small | done (bug fix) |
| Vertical budget | Audit `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H`, modal/chat height helpers for 222 px | **the real work** | deferred to M8 on-device (risk 8) |
| Focus-nav coverage | Screen-by-screen pass that every control is in `s_nav_group` | medium, on-device | deferred to M8 |
| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, GPS, OTA, lock-screen; SD/filesystem left 0 by design — real SD support added via widened individual call sites instead (see risk 1h) | trivial | done |
| Keyboard backlight (`pagerKeyboardSetBacklight()`) | `updatePagerKbBacklight()` tick + Settings → Keyboard cycle row (off/on/auto) | small | done (risk 9) |
| microSD (file manager + WAV picker) | `fmSdTryMount()`/`fmShowRoots()`/`fmIsAudio()` etc. widened to `\|\| defined(TLORA_PAGER)`; DataStore SD routing, chat-history cap, tile/wallpaper/backup SD use still deferred | medium | done, scoped (risk 1h) |
| Chat bubble Enter/Backspace (`handleHwKey()` ~28808) | Enter on a focused bubble opens the Ack/Mention/Copy/Info/Block menu (`navEnterBubble()`); Backspace jumps to the newest message when the scroll-to-bottom button is showing | small | done |
| Shift/Caps (`PagerKeyboard.cpp`) | Held Shift = momentary uppercase; held Alt+Shift = chord (see below for what it does) | small | done |
| Accent-variant popup keyboard nav | Fn+Space arms it; encoder walks variants; encoder click/Enter confirms; Backspace cancels (`s_accentnav_active`/`accentNavRestyle()`/`accentNavConfirm()`) | medium | done |
| @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done |
| Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done |
| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done |
| Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done |
| App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done |
| SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) |
Everything else (map, chat, contacts, channels, settings, companion protocol,
MQTT, OTA) is resolution-agnostic or already keyed off caps.
## Risks / open questions
1. **LR1121 ACK/TX reliability** — upstream issue meshcore-dev/MeshCore#1376 reports
ACK problems on the pager's LR1121 (confirmed still open). Track it; our wrapper
crib should include any upstream fix. Gate ⑦ tests this explicitly.
1b. **No upstream `CustomLR1121` crib source exists yet** (re-verified
2026-07-06 — see Decision ②'s caveat). Milestone ② needs to author the
wrapper by adapting the core fork's `CustomLR1110{,Wrapper}.h`, not by
copying an upstream file. Re-check upstream first in case it lands before
we get there — would save the work.
1c. **GPS `PIN_GPS_RX`/`PIN_GPS_TX` are named from the GPS module's
perspective in wadamesh's own core (`EnvironmentSensorManager.cpp` calls
`Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX)`, and `HardwareSerial::setPins()`
takes `(rxPin, txPin)` — so `PIN_GPS_TX` supplies the ESP's own **RX** pin,
`PIN_GPS_RX` supplies the ESP's own **TX** pin). trail-mate's/the canonical
arduino-esp32 `GPS_RX=4`/`GPS_TX=12` macros are named the OPPOSITE way — its
own `Serial1.begin(baud, cfg, GPS_RX, GPS_TX)` call uses
`HardwareSerial::begin()`'s `(rxPin, txPin)` order directly, so there
`GPS_RX` IS the ESP's own RX pin. **Net result: wadamesh's `PIN_GPS_RX` must
be set to `12` and `PIN_GPS_TX` to `4`** for M4 — the raw trail-mate values
swapped, not copied verbatim. Verified by reading both projects' actual
`Serial1.setPins()`/`begin()` call sites and the ESP32 core's
`HardwareSerial::setPins()`/`begin()` signatures directly, not by trusting
either project's macro names at face value. Baud confirmed at 38400 (same
MIA-M10Q as T-Deck Plus).
1d. **RTC auto-discovery would misread this board's real RTC.**
`AutoDiscoverRTCClock` (core, shared by all boards) only recognizes DS3231
(`0x68`), RV3028 (`0x52`), and PCF8563 (`0x51`) — its probe is a bare I2C ACK
check. This board's PCF85063A sits at that same `0x51` address but has a
different register layout (RTClib's `RTC_PCF8563` driver would misread its
registers), so calling `rtc_clock.begin(Wire)` would silently produce
garbage timestamps instead of a clean fallback. `target.cpp`'s `radio_init()`
deliberately skips that call — same time behavior as T-Deck/Heltec V4 (ESP32
software clock) rather than a false "RTC found" that's actually wrong. Real
PCF85063A support (SensorLib's `SensorPCF85063`) is unscheduled follow-up
work, not part of any milestone ①–⑩ yet.
1e. **`-D CGRAM_OFFSET=1` must land in Milestone ④'s pager env flags.**
This panel's 222px glass is narrower than the ST7796 controller's 320px
GRAM; TFT_eSPI's `ST7796_Rotation.h` only applies the required 49px
column/row offset when this flag is set (unlike `ST7789_Defines.h`, which
self-defines it). Without it, the pager build compiles clean but every
frame renders shifted/cropped by 49px with no error. `ST7796LCDDisplay.cpp`
has a `#error` guard that will catch the omission the moment M4 compiles
this file — but the actual fix belongs in `platformio.ini`, and it's easy
to miss since the build "succeeds."
1f. **M6 must add a pager branch to `UITask.cpp`'s brightness code ahead of
its existing `PIN_TFT_LEDA_CTL` PWM branch.** Milestone ③ reused that
macro name for the pager's AW9364 enable pin (naming consistency across
boards), but `UITask.cpp:~28868`'s `#if defined(PIN_TFT_LEDA_CTL) &&
(PIN_TFT_LEDA_CTL >= 0)` branch drives that pin with 20kHz LEDC PWM — which
the AW9364 does not accept (it needs discrete edge pulses per step, not a
duty cycle). Once M4 defines `PIN_TFT_LEDA_CTL=42` for the pager, that
existing branch will compile and run for it too unless M6 adds a
`#if defined(TLORA_PAGER)` branch first that calls
`display.setBrightness(pct)` instead (the hook `ST7796LCDDisplay` already
exposes for exactly this).
1g. **RX-boosted-gain silently doesn't apply for the pager — RESOLVED
2026-07-07 (commit `b3c6388`).** Was deferred, not fixed, in Milestone ④
(matches M3's shared-file-edit precedent): with `-D USE_LR1121=1` defined
(not `USE_SX1262`/`USE_SX1268`), three call sites defaulted/applied this
radio setting only for the SX126x macros even though `CustomLR1121Wrapper`
already implements `setRxBoostedGainMode`/`getRxBoostedGainMode`:
`src/MyMesh.cpp` ~2540 (default pref value on first boot), ~2556 and ~3355
(actually calling `radio_driver.setRxBoostedGainMode(...)`), and
`src/DataStore.cpp` ~264 (default pref value). Fixed by adding
`|| defined(USE_LR1121)` to all three conditions — safe/additive, zero
behavior change for T-Deck/Heltec V4, verified by all-three-envs build.
(A fourth, core-lib-only gate still exists in `CommonCLI.cpp`'s
`radio.rxgain` companion CLI command, checking `USE_SX1262`/`USE_SX1268`/
`USE_LR1110` — left alone in this pass since it's core-lib, not app code;
now that the three UI/prefs call sites actually apply the setting, adding
`USE_LR1121` there too would be safe follow-up, just unscheduled.)
2. **TCA8418 keymap** — raw matrix + our own shift/sym/alt state machine; the T-Deck
never needed this (its C3 resolves ASCII). Bounded: trail-mate's layout tables are
a working reference. Landed in M5 as `src/helpers/input/PagerKeyboard.cpp`,
polling-based (not INT-pin-gated — see worklist ⑤ for why).
2a. **Encoder detent scaling — RESOLVED on hardware (2026-07-07).** The
assumed 4-transitions-per-detent (`PAGER_ENCODER_STEPS_PER_DETENT`)
matches this part: user drove the menus by encoder and reported it feels
right ("encoder is looking good"), no fractional/multiple stepping.
**Direction was also wrong, found and fixed later the same day**: unlike
scaling, the CW/CCW sign was never actually validated against physical
rotation — `kQuadTable`'s convention didn't match this part's wiring, so
turning the way a user expects to move forward/down moved focus and
page-scroll backward/up instead. Fixed by negating once in
`PagerEncoder.cpp`'s `pagerEncoderReadDelta()` (the driver, not UI code)
so every consumer inherits the corrected sense from one place.
1h. **`CAP_SD`/`CAP_FILESYSTEM` are 0 for the pager despite real microSD
hardware — a latent gap in `device_caps.h`'s own abstraction, not a pager
bug.** **File manager browsing + WAV notification-sound picker RESOLVED
2026-07-10** (commits `8c7fb6a`/`1514e76`), scoped deliberately: `CAP_SD`
turned out to gate ~37 call sites (file manager, WAV picker, DataStore
contacts/channels routing, tile-cache SD fallback, wallpaper scanning,
settings backup, screenshots), so flipping the macro itself would have
silently turned on ~30 unvetted, T-Deck-only-tested features at once.
Instead `CAP_SD`/`CAP_FILESYSTEM` stay 0 by design, and only the ~7
specific call sites needed for browsing + WAV picking were widened with
`|| defined(TLORA_PAGER)` — see those commits for the full categorized
site list. Added `pagerSdCardPresent()` (XL9555 `PAGER_EXPAND_SD_DET`,
confirmed-inverted polarity) and a pager `sdSharedSPI()` (reuses
`TFT_eSPI::getSPIinstance()`, the same bus the radio already shares).
Format support is explicitly out of scope (no `f_mkfs`/`sd_diskio.h` pulled
in) — the pager's SD row is tap-to-open only. Hardware-verified: mount,
browse, read/write/delete, insert/remove detection, and picking a WAV from
SD as a notification sound all confirmed on real hardware. **Still not
done** (unscheduled follow-up, deliberately deferred): DataStore
contacts/channels routing to SD (`setSecondaryFS`, what Tanmatsu/T-Deck
do to dodge SPIFFS GC stalls), the `MAX_UI_MESSAGES_SD` chat-history cap
bump, map-tile SD fallback/offline tile packs, lock-screen wallpaper SD
scanning, and settings/telemetry backup export-import to SD.
1i. **`navMaybeRebuild()` was unreachable for the pager in the first M6
pass.** It's only called under `#if defined(HAS_TANMATSU) ... #elif
CAP_TRACKBALL ...`, neither of which the pager's cap combination matches
(`CAP_TRACKBALL=0`, not `HAS_TANMATSU`). Left alone, the KEYPAD indev
would register successfully but its focus group would stay permanently
empty — total, silent navigation dead-end, easy to miss since the build
still succeeds. Fixed with a `#elif defined(TLORA_PAGER)` arm alongside
the existing branches.
1j. **`isDismissKey()`'s T-Deck p/q/a mapping doesn't transfer to the
pager's full QWERTY** — that mapping exists only because the T-Deck's
sparse keyboard lacks a dedicated Esc key; reusing it verbatim for the
pager would make it impossible to type those three letters normally.
Returns `false` for the pager instead — its Esc equivalent is the rotary
encoder's long-press, handled separately in `updatePagerEncoder()`.
3. **222-px chat screen** — tightest layout wadamesh has shipped (current min is
240). Mitigations: no on-screen keyboard (physical QWERTY), slimmer status/tab
bars, landscape chat already exists (320×240 path).
4. **HW-CDC companion frames** (`ARDUINO_USB_MODE=1`) — known-regressed on the V4,
fine on the T-Deck. Test the big device-profile frame first thing on hardware.
5. **Encoder-only ergonomics** on long lists (contacts @ 2000 max) — NEXT/PREV focus
walk may need page-jump keys from the QWERTY (cheap: map to `navMoveDir`).
6. **Shared SPI contention** (display flush vs radio IRQ vs SD) — same topology the
T-Deck ships, so expected fine via SPI transactions + CS discipline; keep an eye
on SD-write + RX overlap during history flush. **Partially de-risked in M7:**
TFT_eSPI force-defines `SUPPORT_TRANSACTIONS` on ESP32 (its raw-register fast
path still takes the SPI HAL mutex), RadioLib does no SPI from ISRs, and mesh +
UI loops share one task — so there is NO radio/display race (disproven theory,
don't re-chase; see the debug log). CS discipline is now enforced at boot:
`TLoraPagerBoard::begin()` parks LORA_NSS/LORA_RST/SD_CS/NFC_CS OUTPUT-HIGH
before any bus traffic (LilyGoLib's `initShareSPIPins()` equivalent).
7. **NVS-preserving flash chain** applies here too — 4-component flash, never the
merged image (wipes saved Wi-Fi creds).
8. **222-px vertical layout not yet audited against real constants** —
`STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` and the modal/chat height helpers were
deliberately left untouched in M6 rather than guessing slimmer values
desk-side; the 480×222 branch compiles and boots (verified by the wordmark
centering math already being generic), but whether every screen actually
fits without clipping/overlap at 222 px tall can only be judged on real
hardware. Do this first in M8, before the broader nav-coverage pass.
9. **Keyboard backlight not wired to any timer/UI — RESOLVED 2026-07-10
(commit `c9033a3`).** `pagerKeyboardSetBacklight()` (M5) worked standalone
but nothing called it. Fixed with a new `updatePagerKbBacklight()` per-tick
apply step (simple on/off at full LEDC duty — no brightness curve, unlike
the T-Deck's dimmable slider) plus a "Keyboard backlight" cycle row
(off/on/auto) in Settings → Keyboard. Also fixed auto mode never actually
responding to input in the same commit: neither `handleHwKey()` nor
`updatePagerEncoder()` called `noteKbActivity()` (the keyboard-backlight
idle timer), only the separate `noteUserInput()` (screen idle timer) — both
now stamp it. Hardware-verified.
10. **TX power default — RESOLVED 2026-07-07 (commit `b3c6388`).** Was
conservatively set to `LORA_TX_POWER=10` with no way to raise it (unlike
the T-Deck, which boots at its chip's real ceiling), following the same
"conservative default, tune on hardware later" note as risk 1g. Found via
a real DM test against a second mesh node: adverts/DMs sent FROM the
pager were unreliable while inbound reception was fine — the far node's
own TX power was unaffected, an asymmetric-range signature pointing
straight at the pager's own TX being too weak (10dBm is ~16x less power
than 22dBm). Fixed to `LORA_TX_POWER=22`, matching the T-Deck's pattern.
22dBm confirmed as a real, safe ceiling for this chip below 1GHz — not a
guess carried over from the sibling boards — via RadioLib's pinned
`LR1120::checkOutputPower()` source (LR1121 inherits it: the HP PA
auto-selects above 14dBm, valid range -9..22) and cross-checked against
trail-mate's own working `TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22` for this
exact board. **Caveat**: a device already flashed before this fix keeps
its saved `tx_power_dbm=10` preference — the new compiled default only
applies to a fresh/factory-reset device; raise it manually in
Settings → Radio & Mesh on units that predate this commit.
**Retested against a second mesh node 2026-07-11: confirmed reliable**
(adverts/DMs both directions) — the fix holds, not just a theoretical
RadioLib-ceiling argument.
11. **Keyboard/encoder-driven scroll-into-view doesn't repaint on this
board — ACCEPTED as workaround-final 2026-07-13, root cause not
pursued further.** User's call: the Alt+turn workaround (shipped
2026-07-07, commit `df7f9bc`) is the practical fix for this hardware —
a genuine root-cause attempt already made things measurably worse and
was reverted (see below), so further root-causing is not planned unless
new evidence surfaces. `navFocusCb`'s `lv_obj_scroll_to_view(f, LV_ANIM_OFF)` (the
call every board's keyboard/trackball nav relies on to bring a newly-
focused off-screen field into view) updates LVGL's internal scroll
model correctly on the pager — confirmed via hardware serial logging
that focus reaches fields far down a tall settings page (their
blur-to-save toasts fire in order) and `lv_obj_get_scroll_y()` genuinely
changes — but the glass never repaints to show the new scroll position.
A small new overlay (the toast itself) still paints fine, since that's a
fresh object, not a repaint of already-rendered scrolled content. This
never surfaced on the T-Deck/Heltec V4 because touch-drag scrolling
there never exercises this code path at all (gesture scrolling
re-invalidates every animation tick, which happens to paper over the
same underlying gap a single one-shot `LV_ANIM_OFF` call doesn't cover)
— so it's a latent bug in shared nav code, not something pager-specific
in origin, just the first board with no touch fallback to mask it.
**Attempted fix that made it WORSE, reverted**: forcing an extra
`lv_obj_invalidate()` on the scrolled ancestor right after
`scroll_to_view` — this made individual focus highlights eventually
repaint, but very slowly (~5 encoder presses per visible update), and
still never actually scrolled the viewport. Do not re-attempt this exact
fix without new evidence. **Workaround shipped instead** (not a fix):
holding the pager's orange Alt key + turning the encoder now
free-scrolls the whole page via `navScrollFocused()`'s **animated**
`lv_obj_scroll_by` (`LV_ANIM_ON`), which empirically DOES repaint
correctly — consistent with the animation-tick theory above — then
snaps focus to whatever's nearest the top of the now-visible viewport
(`navRefocusFirstVisible()`, new). Plain NEXT/PREV navigation into an
off-screen field on a tall settings page (Radio & Mesh, Profile, etc.)
is still broken without using the Alt+turn workaround, and that's
accepted — the Alt+turn gesture is the intended way to reach an
off-screen field on a tall settings page going forward, not a stopgap.
Root-causing the one-shot-invalidate failure (or checking whether the
same gap exists on the touch-less Tanmatsu) is NOT planned work unless
something forces it back open.
12. **Radio & Mesh settings: blur auto-save re-ran a full flash write + live
radio SPI reconfigure on every field defocus — RESOLVED, pager-only fix
(2026-07-08).** User-reported "navigating feels laggy" on this screen
specifically, confirmed on hardware via a `uiCp("ui:nav")` diagnostic
checkpoint (added, measured zero contribution, then removed — the nav
focus-group rebuild was NOT the cause, ruling out risk 11's "maybe it's a
slow render pipeline" theory for THIS symptom). Root cause:
`saveRadioParamsCb` (`UITask.cpp`) is wired to `LV_EVENT_DEFOCUSED` on
every textarea in `buildRadioSettings()` (freq/bw/sf/cr/tx/airtime/region)
and unconditionally called `the_mesh.savePrefs()` +
`the_mesh.applyRadioFromPrefs()` on blur, even when nothing changed. On
touch boards a field only blurs on a deliberate tap-out after an edit —
rare. On the pager there's no touch fallback, so plain keyboard/encoder
"next field" navigation blurs a field on every step: arrowing through the
6-field row re-triggered a real flash write + LR1121 reinit up to six
times with zero edits made. Measured on hardware via `[STALL] ui:lvgl`
(the callback runs synchronously inside LVGL's event dispatch, so its
blocking I/O gets attributed to the `ui:lvgl` checkpoint even though it
isn't a rendering cost): 6 escalating stalls per navigation pass, up to
8.7s each. **Fix**: skip `setRadioParams()`/region persistence on a
silent (DEFOCUSED) blur when the parsed field values already match
`NodePrefs`, gated `#if defined(TLORA_PAGER)` so touch boards keep their
original always-save-on-blur behavior unchanged. Post-fix hardware
capture: one `[STALL] ui:lvgl 702ms` (one-time settings-modal build cost)
instead of the 6-stall escalation, user-confirmed "much better now".
13. **Unread-message LED notification (screen-off glanceable indicator) —
INVESTIGATED, DEFERRED, not implemented (2026-07-10).** User asked whether
this board could get a Tanmatsu-style "glance at the device anytime and
see there's an unread message" indicator while the screen is off. Confirmed
from multiple sources — this repo's `pins_arduino.h` ("no onboard status
LED"), the full XL9555 channel map and I²C device list in the hardware
facts above, the trail-mate reference port (its `board_facts.h` doesn't
even carry an `led_present` field), and freshly-fetched vendor sources
(LilyGoLib's `docs/hardware/lilygo-t-lora-pager.md` hardware doc and its
actual `LilyGo_LoRa_Pager.cpp`/`.h` firmware — the same file that already
caught the DISP_RST/ch6 bug, so trusted as ground truth here too) — **this
board has no onboard LED of any kind.** Tanmatsu has one: a discrete
RGB "envelope" LED on its CH32 coprocessor (`UITask.cpp:2435`,
`msgLedRefresh()`/`msgLedFlash()`) that breathes green while unread and
flashes white on arrival, independent of screen power. The pager's only
real substitute would be pulsing `KB_BACKLIGHT` (GPIO46, wired up this
session via `updatePagerKbBacklight()`), but that's the backlight for the
ENTIRE physical keyboard, not a single low-current indicator LED — a
continuous Tanmatsu-style "breathe while unread" pattern on it would be a
real, likely-not-small standby power cost for however long a message sits
unread (hours), unlike Tanmatsu's tiny dedicated LED. The bounded
alternative (a single brief flash on arrival, mirroring the T-Deck's
existing opt-in "Flash on new message", `UITask.cpp:10446`) has negligible
power cost but was explicitly rejected by the user as not solving the
actual want: a flash you have to be looking at the instant it happens
isn't a "come back later and glance at it" indicator, just a gimmick.
**Decision: do not implement either variant.** Revisit only if this board
ever gets a hardware revision with a real low-power indicator LED, or if a
software-only always-partially-on approach becomes acceptable (not
currently the case).
## References
- Upstream MeshCore (MIT): `meshcore-dev/MeshCore` — does **NOT** currently have
`variants/lilygo_tlora_pager/` or `CustomLR1121*` (re-verified 2026-07-06, see
Decision ②); only `CustomLR1110{,Wrapper}.h` exists in `src/helpers/radiolib/`,
same as our own core fork. Flasher precedent (unrelated to firmware source):
`flasher.meshcore.io/lilygo-t-lora-pager/`
- Local working port (pin map + LR1121 init + keymap + AW9364/BQ27220/XL9555 usage):
`~/dev/trail-mate/boards/tlora_pager/` (esp. `src/tlora_pager_board.cpp initLoRa()`,
`include/boards/tlora_pager/tlora_pager_board.h`) and
`~/dev/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h`
- Canonical pin map (ground truth, matches trail-mate exactly): [`espressif/arduino-esp32`
`variants/lilygo_tlora_pager/pins_arduino.h`](https://github.com/espressif/arduino-esp32/blob/master/variants/lilygo_tlora_pager/pins_arduino.h)
- Official hardware doc (chip list, I²C addresses, power-rail table, electrical
specs): [LilyGoLib `docs/hardware/lilygo-t-lora-pager.md`](https://github.com/Xinyuan-LilyGO/LilyGoLib/blob/master/docs/hardware/lilygo-t-lora-pager.md) —
see the caveat under "Hardware / platform facts" above about its Pins Map
table's internal inconsistencies.
- Hardware docs: LilyGo product page (T-LoRa Pager), CNX-Software 2025-08-12 writeup,
Meshtastic device page (`meshtastic.org/docs/hardware/devices/lilygo/tpager/`)
- In-repo templates: `variants/lilygo_tdeck/` (S3 + SPI radio + shared bus),
Tanmatsu keypad-nav path in `src/ui-touch/UITask.cpp`, `TANMATSU_PORT.md` (tracker
precedent)
- TFT_eSPI's ST7796 driver support (ground truth for the CGRAM_OFFSET/
colstart/rowstart mechanism, Decision ④): `.pio/libdeps/*/TFT_eSPI/TFT_Drivers/{ST7796_Rotation.h,ST7796_Defines.h}`,
cross-checked against `ST7789_Defines.h` and `setAddrWindow()` in
`TFT_eSPI.cpp` — pulled via the pinned `bodmer/TFT_eSPI @ ^2.5.43` lib_dep.
- AW9364 backlight driver actually consumed (not hand-rolled): `lewisxhe/SensorLib`'s
`AW9364LedDriver.hpp` (MIT, header-only) — reference copy at
`~/dev/trail-mate/.pio/libdeps/*/SensorLib/src/AW9364LedDriver.hpp`.
+239
View File
@@ -0,0 +1,239 @@
# T-LoRa Pager: Keyboard & Rotary Encoder Guide
The LilyGo T-LoRa Pager has **no touchscreen and no trackball** — every
screen, every setting, and every chat is driven entirely by the physical
QWERTY keyboard and the rotary encoder knob. If you're coming from the
T-Deck or Heltec V4 (touch-first), this is the one board where learning a
handful of gestures up front pays off immediately. This guide covers both
radio variants (LR1121 and SX1262) — the UI and input handling are identical
on both.
## The two inputs
- **Keyboard** — a physical QWERTY matrix with a Space bar, Backspace, Enter,
a Shift key, and an orange **Fn/Alt** key (bottom-left). No dedicated Esc,
arrow keys, or number row — those are covered by combos below.
- **Rotary encoder** — turn to move the on-screen focus highlight; click
(press the knob) to select. It's your only pointing device, so almost
every screen is navigable with turn + click alone.
Everything below only fires while you're **not** actively typing into a text
field — if a field is focused and you're typing into it, letters type
normally and these combos step out of the way.
## Rotary encoder
| Gesture | Action | Keyboard equivalent |
|---|---|---|
| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) |
| Short click | Select / confirm the focused item | **Enter** |
| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** |
| **Fn (Alt) + turn**, on a main tab | Jump directly between the 5 main tabs (Chats / Contacts / Home / Map / Settings) | none — encoder only |
| **Fn (Alt) + turn**, inside a settings page or chat | Scroll the page up/down | none — encoder only |
| Turn, with a dropdown open | Scroll through the dropdown's options | none — encoder only |
| Turn, with the accent picker open | Cycle through the accent variants (see below) | none — encoder only (Fn+Space to enter is shared) |
| Turn, with the @-mention list open | Cycle through matching contacts (see below) | none — encoder only |
| Turn, at the oldest/newest **loaded** message in an open chat | Loads more history in that direction and keeps going — see [Chat screen](#chat-screen) | none — encoder only |
The knob doubles as your only way to reach the bottom tab bar — since
there's no touch to tap an icon, **Fn+turn** while on any main tab is the
fastest way to switch sections.
## Chat screen
An open chat has no touch-only jump-to-oldest/jump-to-latest buttons on this
board — everything is reachable by turning and clicking, and the encoder
behaves a bit differently here than anywhere else in the app.
### Turning through messages
Message history is loaded lazily: only the messages currently near the
viewport actually exist as on-screen items, and older/newer ones are pulled in
as you approach them. Turning the encoder normally moves the focus highlight
one message at a time, and **at the edge of what's currently loaded, it keeps
going** — the turn instead scrolls the list to pull in the next batch of
history, then continues moving the highlight onto it. You'll only ever land
outside the message list (see below) once you've genuinely reached the very
oldest or very newest message in the whole conversation — never mid-history.
### Leaving the message list
Turning **past the oldest loaded message** (top edge), once there's truly
nothing more to load, wraps around to whatever the last focusable item on the
screen is — the channel-settings gear in an open **channel**, or the Send
button in a **DM** (a DM has no gear to reach). This is plain focus wrap, the
same as reaching the end of any other list in the app; there's no chat-specific
handling for this edge.
Turning **past the newest loaded message** (bottom edge) moves into the reply
row below the messages: quick-reply (△) → emoji → text box → Send. Turning
further past Send *is* chat-specific: in a channel it goes to the
channel-settings gear; in a DM (nothing to reach there) it returns you to the
newest message instead.
### The reply row
Below the messages, turning steps through: **quick-reply (△) → emoji →
text box → Send**, in that order, in both directions — nothing is skipped.
Turning past the △ chip (toward the messages) always jumps straight to the
newest message, regardless of channel or DM.
### Backspace and Fn (Alt) — leaving vs. catching up
These two keys are your shortcuts in and out of "reading mode" on this
screen, and they do different things depending on whether you've got unread
messages:
- **Fn (Alt) tapped alone** — jumps straight to the **newest** message and
moves focus into the text box, ready to type a reply. This is the
deliberate "I'm done reading, let me respond" gesture — it works regardless
of where your focus currently is in the chat.
- **Backspace (tap)** — jumps to whichever message needs your attention:
- If there are unread messages, it jumps to the first one — right below the
**"NEW ----"** divider — and selects it, so you can then turn forward
through your unread messages in order, oldest-of-the-unread first.
- If nothing is unread, it jumps to the newest message instead (same
destination as Fn+Alt, but leaves focus on the message itself rather than
the text box).
Backspace's override above only applies while you're inside an open chat and
*not* actively editing the text box — the usual rule from the top of this
guide; with a field focused, Backspace deletes a character as normal.
Fn (Alt) tap's override fires the same way whether or not the text box
already has focus, since landing there is the whole point of the gesture.
## Keyboard layout
Base layer (no modifier):
```
q w e r t y u i o p
a s d f g h j k l [Enter]
z x c v b n m
[Space]
```
Hold **Fn (Alt)** for numbers/symbols instead:
```
1 2 3 4 5 6 7 8 9 0
* / + - = : ' " @
_ $ ; ? ! , .
[Space]
```
### Shift and Caps Lock
- **Hold Shift + a letter**: that letter (or letters, for as long as you
hold Shift) types uppercase — released, typing goes back to lowercase.
Real momentary Shift, just like a normal keyboard.
- **Hold Fn (Alt), then press Shift**: while you're editing a text field,
toggles **Caps Lock** on/off — stays uppercase until you repeat the chord.
Anywhere else (not editing a field), the chord does nothing.
- Shift alone, tapped with nothing else, does nothing (as expected).
- **Hold Fn (Alt), then press Backspace**: jumps straight to the **Home**
screen — works everywhere, including while you're actively editing a field
(unlike Alt+Shift above, this one isn't context-dependent). Doesn't delete
a character or trigger the plain-Backspace gestures below.
### Special keys
| Key | While editing a text field | Otherwise |
|---|---|---|
| **Enter** | Send / submit / newline | Select the focused item — or, if a chat message bubble is focused, opens its action menu (Ack / Mention / Copy / Info / Block) |
| **Backspace** (tap) | Delete a character | In an open chat: jump to your first unread message, or the newest message if nothing's unread — see [Chat screen](#chat-screen) |
| **Backspace** (hold ~1 s) | — | Same as the encoder's long-press: **Back** |
| **Backspace** (hold ~1 s) *while the screen is locked* | — | Unlock the screen |
| **Space** (tap) | Types a space | — |
| **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — |
| **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) |
| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder. In an open chat: jumps to the newest message and focuses the text box instead — see [Chat screen](#chat-screen) |
The **BOOT** button (top of the device) instantly wakes the screen from
idle-dim. It does *not* unlock a screen you've manually locked with the
Space-hold gesture above — for that, hold Backspace.
## Accented characters
Typing a letter that has accent variants (all vowels, plus `n c s y t z l r`,
both cases) pops up a small row of alternatives above the field. To pick one
without touch:
1. **Fn (Alt) + Space** — jumps focus into the popup.
2. **Turn the encoder** — cycles through the variants.
3. **Enter** — confirms your choice and replaces the letter you just typed.
4. **Backspace** — dismisses the popup and keeps the plain letter.
## @-mentions
Typing `@` followed by a few letters of a contact's name pops up a matching
list in a channel/group chat. Unlike the accent picker above, this one grabs
the encoder **immediately** — no Fn+Space needed, since it only appears once
you've deliberately started typing a mention:
1. **Turn the encoder** — cycles through the matching contacts.
2. **Enter / encoder click** — confirms the highlighted one, replacing
`@partial` with `@FullName ` in your message.
3. **Backspace** — dismisses the list without touching what you typed.
## Sliders
Any focused slider (Control Center brightness, a Settings slider, the Map
zoom bar) can't be dragged without touch — instead:
- **Q** — decrease
- **E** — increase
Each press moves it proportionally (about 20 presses end-to-end) and
persists the new value immediately, the same as releasing a drag.
## Map screen panning
While the **Map** tab is active:
- **W** — pan north
- **A** — pan west
- **S** — pan south
- **D** — pan east
Each press shifts the view a quarter-screen and reloads tiles/markers as
needed.
## Keyboard backlight
**Settings → Keyboard → Keyboard backlight** cycles through three modes
(tap to advance):
- **Off** — always dark
- **On** — always lit
- **Auto** (default) — lights up on any keypress, turns off ~3 seconds after
the last one
Unlike the T-Deck, this is a plain on/off strip under the keys, not a
dimmable brightness curve.
## Quick reference
| Input | Action |
|---|---|
| Turn encoder (or Fn tap = NEXT only) | Move focus |
| Turn, at the loaded edge of a chat | Load more history, keep moving — only exits the list at the true oldest/newest message |
| Click encoder (or Enter) | Select / confirm |
| Hold encoder ~1s (or hold Backspace ~1s) | Back |
| Fn + turn (main tab) | Switch tabs |
| Fn + turn (page/chat) | Scroll |
| Fn tap alone | Next field (in a chat: jump to newest message + focus the text box) |
| Hold Shift + letter | Momentary uppercase |
| Fn + Shift (editing a field) | Toggle Caps Lock |
| Fn + Shift (not editing a field) | Nothing |
| Fn + Backspace (anywhere) | Jump to Home |
| Fn + Space | Enter accent picker |
| @ + letters (composer) | Auto-focuses the mention list — no Fn+Space needed |
| Enter | Select / send / message action menu |
| Backspace (tap) | Delete / in a chat: jump to first unread message, or newest if none |
| Backspace (hold 1s) | Back, or unlock if locked |
| Space (tap) | Space |
| Space (double-tap) | Switch keyboard language |
| Space (hold 1s) | Lock screen |
| Q / E (slider focused) | Decrease / increase |
| W / A / S / D (Map tab) | Pan map north/west/south/east |
+43
View File
@@ -0,0 +1,43 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=0",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "attaky_mesh_series"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "Attaky Core (ESP32-S3-WROOM-1U-N16R8, 16 MB FLASH, 8 MB OCTAL PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 8388608,
"maximum_size": 16777216,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 115200
},
"url": "https://attaky.com/",
"vendor": "attaky"
}
+43
View File
@@ -0,0 +1,43 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "heltec_v4"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "heltec_wifi_lora_32 v4-R8 (16 MB FLASH, 8 MB OCTAL PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 8388608,
"maximum_size": 16777216,
"use_1200bps_touch": true,
"wait_for_upload_port": true,
"require_upload_port": true,
"speed": 921600
},
"url": "https://heltec.org/",
"vendor": "heltec"
}
+41
View File
@@ -0,0 +1,41 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "app3M_fat9M_16MB.csv",
"memory_type": "qio_qspi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1",
"-DARDUINO_USB_CDC_ON_BOOT=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [["0x303A", "0x82D4"]],
"mcu": "esp32s3",
"variant": "lilygo_tlora_pager_pins",
"variants_dir": "variants"
},
"connectivity": ["wifi"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "LilyGo T-LoRa-Pager (16M Flash 8M QSPI PSRAM )",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.lilygo.cc",
"vendor": "LilyGo"
}
+43
View File
@@ -0,0 +1,43 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "dio",
"psram_type": "qspi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "RAK WisMesh Tap V2 (16 MB FLASH, 8 MB Octal PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 8388608,
"maximum_size": 16777216,
"use_1200bps_touch": false,
"wait_for_upload_port": false,
"require_upload_port": true,
"speed": 460800
},
"url": "https://store.rakwireless.com/products/wismesh-tap-v2",
"vendor": "RAKwireless"
}
+40
View File
@@ -0,0 +1,40 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"partitions": "default_16MB.csv",
"memory_type": "qio_opi"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=0",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"psram_type": "opi",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth", "lora", "gps"],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": ["esp-builtin"],
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "Elecrow ThinkNode M9 (16 MB FLASH, 8 MB PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 921600
},
"url": "https://www.elecrow.com/",
"vendor": "Elecrow"
}
+15 -2
View File
@@ -44,9 +44,13 @@ if os.path.exists(nf):
# 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.
# `name` MUST match the official MeshCore device name character-for-character, or Mesh America lists it
# as a NEW device instead of folding our firmware into the existing tile (TJ Downes, 2026-06). Canonical
# names verified from apps.meshamerica.com/proxy/flasher/config.json. The V4 entry is the Expansion Kit
# (Touch) variant only, per Kaj. `slug` still maps to our bin filenames on firmware.wadamesh.com.
BOARDS = [
{"name": "wadamesh — Heltec V4 TFT", "slug": "heltec-v4-tft"},
{"name": "wadamesh — LilyGo T-Deck", "slug": "tdeck"},
{"name": "Heltec v4 + Expansion Kit (Touch)", "slug": "heltec-v4-tft"},
{"name": "LilyGo T-Deck", "slug": "tdeck"},
]
def device(b):
@@ -74,7 +78,16 @@ def device(b):
}],
}
# Optional "what's this?" blurb Mesh America shows on a hover tooltip next to our provider badge
# (spec section 3, added by TJ 2026-06-30). Plain text only — rendered escaped, so no markup/links.
DESCRIPTION = (
"WADAMESH is a full touch-screen interface for MeshCore: on-device chat, contacts, a live map "
"with offline tiles, and complete radio settings. Same MeshCore protocol and phone-app companion "
"as stock MeshCore, just a richer on-device front-end."
)
catalog = {
"description": DESCRIPTION,
"maker": {"wadamesh": {"name": "wadamesh"}},
"device": [device(b) for b in BOARDS],
}
+11 -10
View File
@@ -1,4 +1,5 @@
{
"description": "WADAMESH is a full touch-screen interface for MeshCore: on-device chat, contacts, a live map with offline tiles, and complete radio settings. Same MeshCore protocol and phone-app companion as stock MeshCore, just a richer on-device front-end.",
"maker": {
"wadamesh": {
"name": "wadamesh"
@@ -8,7 +9,7 @@
{
"maker": "wadamesh",
"class": "wadamesh",
"name": "wadamesh — Heltec V4 TFT",
"name": "Heltec v4 + Expansion Kit (Touch)",
"type": "esp32",
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
"firmware": [
@@ -16,19 +17,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_21": {
"notes": "Stable and test update channels. beta_21 is the first stable build. On devices that update over Wi-Fi there is now a \"Get test builds (beta)\" switch on the About screen: leave it off to stay on stable releases, or turn it on to receive test builds earlier — it switches both the \"update available\" check and the firmware you install to the test channel. You can go back to stable at any time.\nMQTT bridge (highly experimental, off by default). An opt-in bridge that forwards the messages your node receives to an MQTT broker of your choice, so you can log your mesh or wire it into other tools. It is gated behind a privacy consent you have to tick, direct messages are excluded unless you deliberately enable them, and the payload can be sealed with an encryption key so only you can read it. Use a broker you control, never a public one. This is experimental and may change.\nPortugal (Narrow) region preset, so Portuguese users can pick the local defaults in one tap. (Issue #74, requested by Salgueiron.)\nSaved-contacts counter. The Contacts overflow menu now shows how many contacts you have saved out of the maximum it can hold (for example 42 / 2000). (Issue #72, asked by Buko84.)\nThe signal probe never travels past the first node now. The \"auto-discover\" signal pulse, and the probe behind the Signal popup, are sent strictly zero-hop, so a repeater can no longer re-flood them across the mesh. The probe only measures the link to whatever hears it first, which is what it was always meant to do.\nYour most relevant contacts now show when you have a lot of them. The on-screen contact list is capped for performance, but it was trimming the list before sorting it, so some contacts could drop out of view. It now sorts first, shows the closest matches, and adds a \"+N more — search to narrow the list\" line at the bottom. (Issue #73, reported by 1nerdherder.)\nThe blocked-users list opened from Contacts now shows the proper two-line title bar with a back button, instead of an unlabelled screen.\nThe Contacts overflow popup fits the screen again after the saved-contacts counter was added.\nEverything from beta_20 is included.",
"beta_46": {
"notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/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_21/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -40,7 +41,7 @@
{
"maker": "wadamesh",
"class": "wadamesh",
"name": "wadamesh — LilyGo T-Deck",
"name": "LilyGo T-Deck",
"type": "esp32",
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
"firmware": [
@@ -48,19 +49,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_21": {
"notes": "Stable and test update channels. beta_21 is the first stable build. On devices that update over Wi-Fi there is now a \"Get test builds (beta)\" switch on the About screen: leave it off to stay on stable releases, or turn it on to receive test builds earlier — it switches both the \"update available\" check and the firmware you install to the test channel. You can go back to stable at any time.\nMQTT bridge (highly experimental, off by default). An opt-in bridge that forwards the messages your node receives to an MQTT broker of your choice, so you can log your mesh or wire it into other tools. It is gated behind a privacy consent you have to tick, direct messages are excluded unless you deliberately enable them, and the payload can be sealed with an encryption key so only you can read it. Use a broker you control, never a public one. This is experimental and may change.\nPortugal (Narrow) region preset, so Portuguese users can pick the local defaults in one tap. (Issue #74, requested by Salgueiron.)\nSaved-contacts counter. The Contacts overflow menu now shows how many contacts you have saved out of the maximum it can hold (for example 42 / 2000). (Issue #72, asked by Buko84.)\nThe signal probe never travels past the first node now. The \"auto-discover\" signal pulse, and the probe behind the Signal popup, are sent strictly zero-hop, so a repeater can no longer re-flood them across the mesh. The probe only measures the link to whatever hears it first, which is what it was always meant to do.\nYour most relevant contacts now show when you have a lot of them. The on-screen contact list is capped for performance, but it was trimming the list before sorting it, so some contacts could drop out of view. It now sorts first, shows the closest matches, and adds a \"+N more — search to narrow the list\" line at the bottom. (Issue #73, reported by 1nerdherder.)\nThe blocked-users list opened from Contacts now shows the proper two-line title bar with a back button, instead of an unlabelled screen.\nThe Contacts overflow popup fits the screen again after the saved-contacts counter was added.\nEverything from beta_20 is included.",
"beta_46": {
"notes": "Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.\nDo Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)\nDo Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.\nContacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.\nHeltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.\nLilyGo T-Pager: the map no longer shows a \"storage error\" under the launcher — it caches tiles to the microSD card, like the T-Deck.\nHeltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+99 -2
View File
@@ -73,6 +73,11 @@
.mini{background:#16191d; border-radius:12px; padding:7px; border:1px solid #e9ece7}
.mini img{display:block; width:100%; height:auto; border-radius:6px}
.mini .cap{margin-top:6px}
.webgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(178px,1fr)); gap:18px; margin-top:22px}
figure.phone{margin:0; background:#16191d; border-radius:20px; padding:8px; border:1px solid #e9ece7; box-shadow:0 18px 44px -26px rgba(21,24,30,.5)}
figure.phone img{display:block; width:100%; height:auto; border-radius:13px}
figure.phone figcaption{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.66rem; color:var(--mut); margin-top:8px; letter-spacing:.04em}
.subh{font-size:1.06rem; font-weight:700; color:var(--ink); margin:30px 0 2px}
/* Settings-page detail cards: real screenshot + what the page does + its controls */
.sgrp{font-family:'JetBrains Mono',monospace; font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); margin:30px 0 0; padding-top:4px}
@@ -126,6 +131,8 @@
<a href="#settings">Settings</a>
<div class="grp">With your phone</div>
<a href="#companion">Companion mode</a>
<a href="#remote">Remote &amp; web app</a>
<a href="#web">Web browser</a>
<div class="grp">Reference</div>
<a href="#updates">Updates &amp; test builds</a>
<a href="#settings-pages">Settings pages</a>
@@ -141,6 +148,13 @@
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14 0"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><line x1="12" y1="20" x2="12" y2="20"/></svg> LoRa + Wi-Fi + Bluetooth</span>
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg> Keyboard + touch</span>
</div>
<div class="callout">
<span class="cico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4z"/><path d="M9 4v4M13 4v4"/></svg></span>
<div>
<b>Got a microSD slot? Keep a card inserted.</b>
<p>On devices with a microSD slot (the LilyGo T-Deck and Tanmatsu), wadamesh is best used with a card in place. It keeps map tiles, your contacts and your chat history on the card, which is roomier and far more reliable than the small internal flash, so a card prevents memory issues and stops that data being lost on reboot. The Heltec V4 has no card slot and manages fine on its internal storage.</p>
</div>
</div>
</div>
<section class="doc" id="home">
@@ -196,7 +210,26 @@
<p>An OpenStreetMap / OpenTopoMap view with your position and your contacts plotted as markers, plus link lines from you to each node. Tiles are fetched over Wi-Fi through the wadamesh proxy and cached to flash (or microSD), so areas you've viewed work offline.</p>
<p>It also has a line-of-sight analyzer: pick a contact and it uses SRTM elevation to show whether terrain blocks the path.</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png" alt="Map"></div><div class="cap">Map</div></div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png?v=2" alt="Map"></div><div class="cap">Map</div></div>
</div>
<div class="prose">
<h3>Bring your own tiles (offline maps)</h3>
<p>You don't need Wi-Fi to see the map. Copy a standard OpenStreetMap tile pack onto the T-Deck's microSD card and the map reads straight off the card, fully offline.</p>
<p><b>Where they go.</b> Use the usual slippy-map <code>z/x/y</code> folder layout, in either of these locations on the card:</p>
<ul>
<li><code>/maps/osm/&lt;z&gt;/&lt;x&gt;/&lt;y&gt;.png</code> &mdash; the Meshtastic / MeshCore standard layout (recommended, so packs are interchangeable between apps).</li>
<li><code>/tiles/&lt;z&gt;/&lt;x&gt;/&lt;y&gt;.png</code> &mdash; also read. This is the same folder the Wi-Fi download cache uses, so your own tiles and downloaded ones live side by side.</li>
</ul>
<p><b>Format.</b> 256&times;256 <code>.png</code> tiles (<code>.jpg</code> also works). <code>z</code> is the zoom level and <code>x</code>/<code>y</code> the tile column/row at that zoom &mdash; the same scheme OSM's own tile server and every XYZ tile downloader use, so most ready-made packs drop in unchanged.</p>
<p><b>Turn it on.</b> Open the map's options (the gear button on the map) and switch on <b>Tiles from SD card</b>; the status line confirms <code>Map tiles: microSD</code>. Turn it back off to fetch live tiles over Wi-Fi again.</p>
<p><b>Making a pack.</b> Any XYZ / slippy-map tile downloader works &mdash; point it at OpenStreetMap for your area and a sensible zoom range, then copy the resulting <code>z/x/y</code> folders onto the card. Each extra zoom level roughly quadruples the tile count, so grab only the levels you'll actually use.</p>
<div class="callout">
<span class="cico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>
<div>
<b>Board differences</b>
<p>The "Tiles from SD card" toggle is a T-Deck feature. The Heltec V4 has no microSD slot, so it caches downloaded tiles to a small internal partition (no offline packs there). On the Tanmatsu, tiles &mdash; your own or downloaded &mdash; live at <code>/tiles/&lt;z&gt;/&lt;x&gt;/&lt;y&gt;.jpg</code> on the microSD card; drop JPEG tiles there and they show alongside the downloaded ones.</p>
</div>
</div>
</div>
</section>
@@ -240,6 +273,60 @@
</div>
</section>
<section class="doc" id="remote">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6"/><line x1="2" y1="20" x2="2.01" y2="20"/></svg></span> Remote &amp; web app</h2>
<div class="row">
<div class="prose">
<p>Over Wi-Fi, a WADAMESH device can put its screen &mdash; or a whole browser app &mdash; on your phone or laptop. There is nothing to install: the device serves a web page you open in any browser on the same network. Turn it on from <b>Home &rarr; Remote</b> (or the Remote screen in Settings), pick a mode, and open the address it shows.</p>
<h3>Three ways to connect</h3>
<ul>
<li><b>Remote app</b> (recommended) &mdash; a full control panel, native to the browser: <b>Chats</b>, <b>Contacts</b>, a <b>Terminal</b> and <b>Settings</b>, with live message notifications. Run the device entirely from your phone.</li>
<li><b>Screen mirror (VNC)</b> &mdash; streams the device's live screen to the browser and sends your taps and key presses back, so you see and drive exactly what is on the device, in real time.</li>
<li><b>Remote UI</b> &mdash; renders the interface off-screen at desktop size (a wide landscape layout) and streams that instead of the small panel, for a roomier view on a big screen.</li>
</ul>
<p>Screen mirror and Remote UI both show the same interface you already know from the device &mdash; mirrored, or drawn larger. The <b>Remote app</b> is different: a browser-native panel built for a phone, shown below.</p>
<h3>Turning it on</h3>
<p>Connect the device to Wi-Fi first, then open <b>Home &rarr; Remote</b>. The screen shows the address to type into your browser (like <code>http://192.168.1.42</code>) and a switch for each mode. Only one streaming mode runs at a time, and the phone and the device must be on the same Wi-Fi network.</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/app_remote.png" alt="Remote access screen on the device"></div><div class="cap">Remote &mdash; turn it on, then open the address</div></div>
</div>
<div class="subh">The remote app in a browser</div>
<div class="prose" style="max-width:70ch"><p>The remote app is phone-first. It carries the same status icons as the device (signal, Wi-Fi, Bluetooth, battery), pops up a notification when a message arrives, and refreshes an open chat on its own as replies come in &mdash; so you rarely need to touch the device itself.</p></div>
<div class="webgrid">
<figure class="phone"><img src="docs-img/web_chats.png" alt="Chats tab in the web app"><figcaption>Chats &mdash; DMs, channels, unread badges</figcaption></figure>
<figure class="phone"><img src="docs-img/web_chat.png" alt="A conversation in the web app"><figcaption>A conversation, with delivery ticks</figcaption></figure>
<figure class="phone"><img src="docs-img/web_contacts.png" alt="Contacts tab in the web app"><figcaption>Contacts &mdash; search, sort, filter</figcaption></figure>
<figure class="phone"><img src="docs-img/web_contact_sheet.png" alt="Contact actions in the web app"><figcaption>Tap a contact for actions</figcaption></figure>
<figure class="phone"><img src="docs-img/web_discovered.png" alt="Discovered nodes in the web app"><figcaption>Discovered nodes &mdash; add with a tap</figcaption></figure>
<figure class="phone"><img src="docs-img/web_settings.png" alt="Settings in the web app"><figcaption>Settings &mdash; including live radio</figcaption></figure>
<figure class="phone"><img src="docs-img/web_terminal.png" alt="Terminal in the web app"><figcaption>Terminal &mdash; the MeshCore CLI</figcaption></figure>
</div>
</section>
<section class="doc" id="web">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span> Web browser</h2>
<div class="row">
<div class="prose">
<p>WADAMESH can read the web on its own screen. Open the <b>Web</b> app from the home launcher, type an address, and the device fetches the page and shows it as plain, readable text &mdash; articles, wikis, docs, simple sites. There is no proxy in the middle and nothing is tracked: it pulls the page straight from the site and strips it down to the words.</p>
<ul>
<li><b>Text only</b> &mdash; no JavaScript, no images, no ads. Pages load small and read clean.</li>
<li><b>Tappable links</b> &mdash; follow a link to the next page, with <b>back</b>, <b>forward</b> and <b>refresh</b> in the toolbar.</li>
<li><b>Direct &amp; private</b> &mdash; the device connects to the site itself over Wi-Fi (HTTPS); no server of ours sees what you read.</li>
</ul>
<p>Reading the web on-device takes more memory than the smallest board carries, so the Web app is on the <b>T-Deck</b> and <b>Tanmatsu</b>. On the 2&nbsp;MB Heltec&nbsp;V4 it is hidden &mdash; that board can't complete the secure handshake &mdash; but you can still bounce any link to your phone (below).</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/app_web.png" alt="The on-device Web app"></div><div class="cap">Web &mdash; open an address, read it as text</div></div>
</div>
<div class="subh">Links in chat</div>
<div class="prose" style="max-width:70ch"><p>A link that arrives in a message is now tappable. Tap it for a small menu: <b>Open in web</b> loads the page in the on-device browser (T-Deck and Tanmatsu), and <b>Create QR</b> puts a scannable QR code on screen so you can open the link on your phone. Create QR works on <em>every</em> board, including the Heltec&nbsp;V4 &mdash; so even where the browser isn't available, a link is one tap from your phone.</p></div>
<div class="gallery">
<div class="mini"><img src="docs-img/app_web_links.png" alt="Tapping a link in chat opens a menu"><div class="cap">Tap a link &mdash; Open in web, or Create QR</div></div>
<div class="mini"><img src="docs-img/app_web_qr.png" alt="A QR code for a link from chat"><div class="cap">Create QR &mdash; scan to open on your phone</div></div>
</div>
</section>
<section class="doc" id="updates">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span> Updates &amp; test builds</h2>
<div class="row">
@@ -260,7 +347,7 @@
<section class="doc" id="settings-pages">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg></span> Settings pages</h2>
<div class="prose" style="max-width:68ch"><p>WADAMESH splits its settings into eighteen pages. Here is what each one does and the controls you will find on it. Open any page from the Settings tab; a back chevron in the title bar returns you to the grid.</p></div>
<div class="prose" style="max-width:68ch"><p>WADAMESH splits its settings into nineteen pages. Here is what each one does and the controls you will find on it. Open any page from the Settings tab; a back chevron in the title bar returns you to the grid.</p></div>
<div class="sgrp">Identity &amp; radio</div>
<div class="scards">
@@ -331,6 +418,16 @@
<li>Reads "no GPS module" on devices without one</li>
</ul>
</div>
<div class="scard">
<div class="thumb"><img src="docs-img/set_mqtt_bridge.png" alt="MQTT bridge settings"></div>
<h3>MQTT bridge <span class="tag">experimental</span></h3>
<div class="lede">Forward received messages to your own MQTT broker.</div>
<ul>
<li>Publishes the <b>text, sender and time</b> of messages your node hears to an MQTT broker</li>
<li>Broker <b>host, port and topic</b>, plus an <b>encryption key</b></li>
<li>Stays off until you <b>accept the privacy note</b> &mdash; anyone who can read the broker sees the messages, so use one you control, never a public broker</li>
</ul>
</div>
</div>
<div class="sgrp">Time &amp; power</div>
+117 -4
View File
@@ -42,7 +42,15 @@
font-family:'JetBrains Mono',monospace; background:#fff; transition:border-color .15s, color .15s}
.kicker:hover{border-color:var(--teal); color:var(--ink)}
.freeline{margin:0 auto 24px; color:var(--mut); font-size:.92rem}
.fab-rail{position:fixed; top:16px; z-index:45; display:flex; flex-direction:column; gap:12px}
.fab-rail{position:fixed; top:var(--fab-top,16px); z-index:45; display:flex; flex-direction:column; gap:12px}
/* dismissible donate banner */
.donate-bar{position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:center; gap:7px 16px; flex-wrap:wrap;
padding:9px 44px; background:var(--ink); color:#fff; font-size:.9rem; line-height:1.4; text-align:center}
.donate-bar a{color:#fff; text-decoration:underline; text-underline-offset:2px}
.donate-bar .donate-cta{background:var(--teal); color:#fff; text-decoration:none; padding:5px 14px; border-radius:100px; font-weight:650; white-space:nowrap}
.donate-bar .donate-cta:hover{background:var(--cyan)}
.donate-bar .x{position:absolute; right:12px; top:50%; transform:translateY(-50%); background:none; border:0; color:#fff; font-size:1.5rem; line-height:1; cursor:pointer; opacity:.75; padding:0 4px}
.donate-bar .x:hover{opacity:1}
.fab-rail.left{left:16px} .fab-rail.right{right:16px}
.fab{width:44px; height:44px; border-radius:50%; display:grid; place-items:center; cursor:pointer;
background:#fff; border:1px solid var(--line); color:#46525c; box-shadow:0 10px 26px -14px rgba(21,24,30,.45);
@@ -141,11 +149,33 @@
flex-direction:column; gap:10px; align-items:flex-start; box-shadow:0 8px 26px -22px rgba(21,24,30,.5)}
.board h4{font-size:1.02rem}
.board p{color:var(--mut); font-size:.86rem}
/* Per-board support-level badge + hover popover (same visual language as the hero pills). */
.sup{position:relative; display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px;
font-size:.72rem; font-weight:700; letter-spacing:.2px; cursor:default; width:max-content}
.sup .dot{width:7px; height:7px; border-radius:50%; flex:none}
.sup.full{background:#e7f6ec; color:#1c7c3f; border:1px solid #bfe6cc}
.sup.full .dot{background:#27a35a}
.sup.part{background:#fdf3e4; color:#9a6410; border:1px solid #f0dcb4}
.sup.part .dot{background:#e3a127}
.sup .pop{position:absolute; top:calc(100% + 11px); left:0; transform:translateY(5px);
width:min(272px,80vw); background:#fff; border:1px solid var(--line); border-radius:12px; padding:13px 15px;
box-shadow:0 18px 44px -18px rgba(21,24,30,.32); text-align:left; font-size:.84rem; font-weight:400;
letter-spacing:0; line-height:1.5; color:var(--mut); opacity:0; visibility:hidden;
transition:opacity .16s ease, transform .16s ease; z-index:20; cursor:default}
.sup .pop b{display:block; color:var(--ink); margin-bottom:4px; font-size:.92rem}
.sup .pop::before{content:""; position:absolute; bottom:100%; left:18px; border:7px solid transparent; border-bottom-color:#fff}
.sup:hover .pop,.sup:focus .pop,.sup:focus-within .pop{opacity:1; visibility:visible; transform:translateY(0)}
esp-web-install-button{--esp-tools-button-color:var(--teal);--esp-tools-button-text-color:#fff}
.dl{display:inline-flex;align-items:center;gap:8px;background:var(--teal);color:#fff;border-radius:9px;
padding:9px 15px;font-weight:650;font-size:.9rem}
.dl:hover{background:var(--teal-ink)}
.dl svg{width:15px;height:15px}
.dl2{display:inline-block;margin-top:-2px;text-decoration:none;line-height:1.3}
.dl2 .dl2-row{display:inline-flex;align-items:center;gap:6px;font-size:.85rem;color:var(--teal);font-weight:700;white-space:nowrap}
.dl2:hover .dl2-row{text-decoration:underline}
.dl2 svg{width:14px;height:14px;flex:none}
.dl2 .dlver{display:block;font-size:.72rem;color:var(--mut);font-weight:600;margin-top:2px;padding-left:20px;letter-spacing:.03em}
.dl2 .dlver:empty{display:none}
.hint{max-width:760px;margin:18px auto 0;color:var(--mut);font-size:.86rem;text-align:center}
/* "install a previous version" picker */
.verpick{max-width:760px; margin:16px auto 0; border:1px solid var(--line); border-radius:12px; background:var(--bg2); text-align:left}
@@ -210,6 +240,11 @@
</style>
</head>
<body>
<div class="donate-bar" id="donateBar">
<span>Like the project and want to donate? Help out the <b>MeshCore</b> founders instead. <a href="https://blog.meshcore.io/2026/07/04/help-us-save-meshcore" target="_blank" rel="noopener">Learn more</a></span>
<a class="donate-cta" href="https://givealittle.co.nz/cause/help-us-save-meshcore" target="_blank" rel="noopener">Donate</a>
<button class="x" id="donateX" type="button" aria-label="Dismiss">&times;</button>
</div>
<div id="vanta-bg"></div>
<!-- floating quick links -->
@@ -334,12 +369,51 @@
</div>
<div class="boards">
<div class="board"><h4>Heltec V4 + TFT</h4><p>ESP32-S3 with CHSC6x touch.</p>
<esp-web-install-button id="inst-heltec" manifest="https://firmware.wadamesh.com/latest/manifest-heltec-v4-tft.json"></esp-web-install-button></div>
<span class="sup full" tabindex="0"><span class="dot"></span>Fully supported<span class="pop"><b>Fully supported</b>Everything wadamesh offers works here — this is one of the two primary development boards, tested on every release.</span></span>
<esp-web-install-button id="inst-heltec" manifest="https://firmware.wadamesh.com/latest/manifest-heltec-v4-tft.json"></esp-web-install-button>
<a class="dl2" data-dl-board="heltec-v4-tft" download href="https://firmware.wadamesh.com/latest/wadamesh-heltec-v4-tft-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a></div>
<div class="board"><h4>LilyGo T-Deck / Plus</h4><p>ESP32-S3 keyboard handheld.</p>
<esp-web-install-button id="inst-tdeck" manifest="https://firmware.wadamesh.com/latest/manifest-tdeck.json"></esp-web-install-button></div>
<span class="sup full" tabindex="0"><span class="dot"></span>Fully supported<span class="pop"><b>Fully supported</b>Everything wadamesh offers works here — this is one of the two primary development boards, tested on every release.</span></span>
<esp-web-install-button id="inst-tdeck" manifest="https://firmware.wadamesh.com/latest/manifest-tdeck.json"></esp-web-install-button>
<a class="dl2" data-dl-board="tdeck" download href="https://firmware.wadamesh.com/latest/wadamesh-tdeck-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a></div>
<div class="board"><h4>ThinkNode M9</h4><p>ESP32-S3 QWERTY handheld (LR1110).</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. Not every feature has been ported or hardware-verified on this board yet.</span></span>
<esp-web-install-button id="inst-m9" manifest="https://firmware.wadamesh.com/latest-beta/manifest-thinknode-m9.json"></esp-web-install-button>
<a class="dl2" data-dl-board="thinknode-m9" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-thinknode-m9-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board"><h4>RAK WisMesh Tap V2</h4><p>ESP32-S3 touch node.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. Not every feature has been ported or hardware-verified on this board yet.</span></span>
<esp-web-install-button id="inst-rak" manifest="https://firmware.wadamesh.com/latest-beta/manifest-rak-tap-v2.json"></esp-web-install-button>
<a class="dl2" data-dl-board="rak-tap-v2" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-rak-tap-v2-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board"><h4>Heltec V4-R8 <span style="font-weight:600;color:var(--mut)">(experimental)</span></h4><p>ESP32-S3 &middot; 8&nbsp;MB PSRAM &middot; SD card.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. This revision is still being hardware-verified — flash with care and report back.</span></span>
<esp-web-install-button id="inst-v4r8" manifest="https://firmware.wadamesh.com/latest-beta/manifest-heltec-v4-r8-tft.json"></esp-web-install-button>
<a class="dl2" data-dl-board="heltec-v4-r8-tft" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-heltec-v4-r8-tft-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board, not yet hardware-tested &mdash; beta only. Please flash with care and report back.</p></div>
<div class="board"><h4>Attaky Core <span style="font-weight:600;color:var(--mut)">(experimental)</span></h4><p>ESP32-S3 &middot; 16&nbsp;MB &middot; 8&nbsp;MB PSRAM.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. This community-contributed board (PR #158) isn't hardware-verified by us yet — flash with care and report back.</span></span>
<esp-web-install-button id="inst-attaky" manifest="https://firmware.wadamesh.com/latest-beta/manifest-attaky.json"></esp-web-install-button>
<a class="dl2" data-dl-board="attaky" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-attaky-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board, not yet hardware-tested &mdash; beta only. Please flash with care and report back.</p></div>
<div class="board"><h4>Tanmatsu</h4><p>ESP32-P4 keyboard handheld.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link over Wi-Fi/USB. Some features are still settling on this board (Bluetooth pairing has known gaps).</span></span>
<p style="color:var(--mut);font-size:.84rem;margin:0;line-height:1.5">Already have one? No USB flashing needed — install WADAMESH straight from the <b>Tanmatsu app store</b> on the device.</p>
<a class="dl" href="https://shop.nicolaielectronics.nl/" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>Get a Tanmatsu</a></div>
<div class="board"><h4>LilyGo T-Display P4</h4><p>ESP32-P4 AMOLED touch handheld.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>Early port — it's known that not everything works yet, but the core does: LoRa mesh communication, chat, map, GPS and the phone-app link over Wi-Fi or USB. Bluetooth pairing isn't available on this board (a hardware limitation of its stock radio firmware).</span></span>
<esp-web-install-button id="inst-tdp4" manifest="https://firmware.wadamesh.com/latest-beta/manifest-tdisplay-p4.json"></esp-web-install-button>
<a class="dl2" data-dl-board="tdisplay-p4" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-tdisplay-p4-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board"><h4>LilyGo T-LoRa Pager</h4><p>ESP32-S3 QWERTY + rotary encoder.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>New community port (thanks @codemonkeybr). The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link, driven entirely by the keyboard and rotary encoder. Pick the build that matches your radio chip.</span></span>
<p style="color:var(--mut);font-size:.8rem;margin:0">Radio <b>LR1121</b>:</p>
<esp-web-install-button id="inst-pager-lr1121" manifest="https://firmware.wadamesh.com/latest-beta/manifest-tlora-pager-lr1121.json"></esp-web-install-button>
<a class="dl2" data-dl-board="tlora-pager-lr1121" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-tlora-pager-lr1121-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p style="color:var(--mut);font-size:.8rem;margin:8px 0 0">Radio <b>SX1262</b>:</p>
<esp-web-install-button id="inst-pager-sx1262" manifest="https://firmware.wadamesh.com/latest-beta/manifest-tlora-pager-sx1262.json"></esp-web-install-button>
<a class="dl2" data-dl-board="tlora-pager-sx1262" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-tlora-pager-sx1262-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
</div>
<details class="verpick">
<summary>Install a previous version</summary>
@@ -358,6 +432,11 @@
<div class="guide-head"><h3>Install with bmorcelli's Launcher</h3></div>
<p class="p"><a href="https://bmorcelli.github.io/Launcher/" target="_blank" rel="noopener">Launcher</a> is a multi-app boot menu for ESP32 devices — keep it, and run WADAMESH as one of your apps.</p>
<p class="p" style="margin-top:10px"><b>This path is for the LilyGo&nbsp;T-Deck only.</b> On the Heltec&nbsp;V4, install <a href="#standalone" onclick="choose('standalone');return false">Standalone</a> instead.</p>
<div class="chtoggle" role="group" aria-label="Release channel" style="display:inline-flex;margin:14px 0 0;border:1px solid var(--line);border-radius:10px;overflow:hidden">
<button type="button" data-wm-ch="stable" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;background:#fff;color:var(--mut);cursor:pointer">Stable</button>
<button type="button" data-wm-ch="beta" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;border-left:1px solid var(--line);background:#fff;color:var(--mut);cursor:pointer">Beta · testing</button>
</div>
<p style="margin:8px auto 0;max-width:560px;font-size:.84rem;color:var(--mut)"><b>Stable</b> is the tested default; <b>Beta</b> gets new builds earlier. The download below follows this choice.</p>
<div class="steps" style="margin-top:22px">
<div class="step"><div class="num">1</div><div><h4>Install Launcher first</h4><p>Flash bmorcelli's Launcher to your device, following its own installer — <a href="https://bmorcelli.github.io/Launcher/" target="_blank" rel="noopener">bmorcelli.github.io/Launcher</a>.</p></div></div>
<div class="step"><div class="num">2</div><div><h4>Add the WADAMESH app</h4><p>Grab the WADAMESH <b>app image</b> for your board below, then install it from Launcher (via its web UI / OTA, or copy the <code>.bin</code> to your SD card).</p></div></div>
@@ -365,7 +444,8 @@
</div>
<div class="boards" style="grid-template-columns:1fr; max-width:380px">
<div class="board"><h4>LilyGo T-Deck / Plus</h4><p>app image (no bootloader)</p>
<a class="dl" id="dl-tdeck" href="https://firmware.wadamesh.com/latest/wadamesh-tdeck.bin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</a></div>
<a class="dl" id="dl-tdeck" download href="https://firmware.wadamesh.com/latest/wadamesh-tdeck.bin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</a>
<p style="margin:11px 0 0;font-size:.84rem;color:var(--mut);line-height:1.5">You're downloading version <b data-wn-tag style="color:var(--teal)">…</b><span data-wm-chlabel></span>.<br>The file saves as <code data-dl-fname style="font-size:.82em">wadamesh-tdeck.bin</code> — rename it if you keep several.</p></div>
</div>
<p class="hint">New to Launcher? It's the easiest way to try WADAMESH without giving up your other firmware — and you can fully switch to Standalone any time.</p>
</div>
@@ -480,6 +560,16 @@ We use no tracking, analytics, or advertising cookies. Our CDN and security prov
try { if (!localStorage.getItem('wm-cookie-ack')) cbar.classList.add('show'); } catch(_) {}
document.getElementById('cookieok').addEventListener('click', () => { cbar.classList.remove('show'); try { localStorage.setItem('wm-cookie-ack','1'); } catch(_) {} });
// ---- donate banner: dismissible; shifts the corner FABs down while shown ----
(function(){
const bar = document.getElementById('donateBar'); if (!bar) return;
function setFabTop(){ document.documentElement.style.setProperty('--fab-top', (getComputedStyle(bar).display === 'none' ? 16 : bar.offsetHeight + 12) + 'px'); }
try { if (localStorage.getItem('wm-donate-dismiss') === '1') bar.style.display = 'none'; } catch(_) {}
setFabTop();
addEventListener('resize', setFabTop);
document.getElementById('donateX').addEventListener('click', () => { bar.style.display = 'none'; setFabTop(); try { localStorage.setItem('wm-donate-dismiss','1'); } catch(_) {} });
})();
// Live release version + "what's new" — read from the firmware host so the
// flasher always reflects the current build (the release pipeline updates
// version.json; no site redeploy needed per release).
@@ -487,6 +577,10 @@ We use no tracking, analytics, or advertising cookies. Our CDN and security prov
// on the firmware host — latest/ (stable) <-> latest-beta/ (beta) — and the install
// manifests, "what's new", the Launcher .bin link and the version picker follow.
const WM = (window.WM = { feed:'latest', arch:'TOUCH' });
// ThinkNode M9 + RAK Tap V2 exist on the BETA feed only until the first stable
// promote that includes them: their install buttons are pinned to latest-beta in
// the HTML and deliberately NOT switched by wmSetChannel. When a stable ships
// with their bins, add them here + to wmSetChannel and drop the pinned notes.
const WM_MAN = { heltec:'manifest-heltec-v4-tft.json', tdeck:'manifest-tdeck.json' };
window.wmManifest = board => 'https://firmware.wadamesh.com/' + WM.feed + '/' + WM_MAN[board];
function wmWhatsNew(){
@@ -512,18 +606,37 @@ We use no tracking, analytics, or advertising cookies. Our CDN and security prov
if (bT) bT.setAttribute('manifest', wmManifest('tdeck'));
const dl = document.getElementById('dl-tdeck');
if (dl) dl.href = 'https://firmware.wadamesh.com/' + WM.feed + '/wadamesh-tdeck.bin';
document.querySelectorAll('[data-wm-chlabel]').forEach(e => e.textContent = ch === 'beta' ? ' · beta (testing)' : ' · stable');
document.querySelectorAll('[data-wm-ch]').forEach(b => { const on = b.getAttribute('data-wm-ch') === ch;
b.style.background = on ? 'var(--teal)' : '#fff'; b.style.color = on ? '#fff' : 'var(--mut)'; });
document.querySelectorAll('[data-wm-betahint]').forEach(e => e.hidden = ch !== 'beta');
const vs = document.getElementById('verSel');
if (vs && vs.options.length) { vs.selectedIndex = 0; vs.dispatchEvent(new Event('change')); }
wmUpdateDownloads();
wmWhatsNew();
};
// Per-board "download the .bin" links on the standalone cards: follow the channel
// toggle (beta-only boards stay pinned to latest-beta) and show the exact version
// each feed serves — both channel tags are fetched once so every link is accurate.
const WM_VER = {};
window.wmUpdateDownloads = function(){
document.querySelectorAll('[data-dl-board]').forEach(a => {
const feed = a.hasAttribute('data-dl-betaonly') ? 'latest-beta' : WM.feed;
a.href = 'https://firmware.wadamesh.com/' + feed + '/wadamesh-' + a.getAttribute('data-dl-board') + '-merged.bin';
const v = a.querySelector('[data-dl-ver]'); if (v) v.textContent = WM_VER[feed] || '';
});
};
['latest','latest-beta'].forEach(feed => fetch('https://firmware.wadamesh.com/' + feed + '/version.json', {cache:'no-store'})
.then(r => r.ok ? r.json() : null).then(v => { if (v && v.tag) { WM_VER[feed] = v.tag; wmUpdateDownloads(); } }).catch(()=>{}));
document.querySelectorAll('[data-wm-ch]').forEach(b => b.addEventListener('click', () => wmSetChannel(b.getAttribute('data-wm-ch'))));
wmSetChannel('stable');
// ---- community videos — add an entry here to add a video ----
const VIDEOS = [
// newest first
{ id: 'e63uKp9r65U', title: 'LilyGo T-Deck Plus + WADAMESH: The Firmware That Finally Makes It Worth It', by: 'Signal Trek' },
{ id: '1I7Oe1SqFlk', title: 'WadaMesh Update! Supercharge Your T-Deck', by: 'KDHD Stuff' },
{ id: 's-8pRsC0TYA', title: 'WadaMesh: The Feature That Makes T-Deck Plus Stand Out', by: 'BFGNeil' },
{ id: 'V19od8jbFRc', title: 'WadaMesh: The Best T-Deck Firmware So Far', by: 'KDHD Stuff' },
// { id: '<youtube-id>', title: '<title>', by: '<channel>' },
];
+10 -4
View File
@@ -3,6 +3,10 @@
#define LV_COLOR_DEPTH 16
#define LV_COLOR_16_SWAP 0
// NOTE: LV_USE_LARGE_COORD is intentionally OFF — enabling it caused a boot-time
// LoadProhibited in LVGL on the T-Deck. Chat scroll heights >8191 px are handled
// in UITask via int32 layout offsets + compressed LVGL scroll coords.
// Route LVGL's general allocator through PSRAM-preferred wrappers (defined in
// LvglPsramAlloc.cpp). The board has 8 MB of PSRAM and the ~60 KB of widget
// state LVGL grows during buildUiTree would otherwise come out of the 320 KB
@@ -48,10 +52,11 @@
#define LV_FONT_MONTSERRAT_12 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_16 1
/* Larger Montserrat sizes for the Tanmatsu's crisp "UI size" (Large/Huge) — render the UI bigger
* at native resolution instead of upscaling a low-res frame. Gated to the Tanmatsu so the
* flash-tighter S3 touch builds don't pay for fonts they never use. */
#if defined(HAS_TANMATSU)
/* Larger Montserrat sizes for the large-screen boards' crisp "UI size" (Large/Huge) — render the
* UI bigger at native resolution instead of upscaling a low-res frame. Gated to the CAP_LARGE_SCREEN
* P4 boards (Tanmatsu + T-Display P4) so the flash-tighter S3 touch builds don't pay for fonts they
* never use. (device_caps.h isn't included here, so match on the board macros directly.) */
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
#define LV_FONT_MONTSERRAT_18 1
#define LV_FONT_MONTSERRAT_20 1
#define LV_FONT_MONTSERRAT_24 1
@@ -108,6 +113,7 @@
* render full-colour emoji inline with text. The emoji set is pre-baked to
* RGB565+alpha lv_img_dsc_t C-arrays (emoji_data.c) and wired as the tail of the
* font fallback chain in UITask (montserrat -> extras -> emoji). */
#define LV_USE_FONT_COMPRESSED 1 /* decode RLE-compressed extras/accent fonts (Tanmatsu size budget) */
#define LV_USE_IMGFONT 1
/* Bi-directional text + Arabic/Persian contextual shaping. Lets Arabic (and
+1097 -2
View File
File diff suppressed because it is too large. Load diff
+8 -5
View File
@@ -1,13 +1,16 @@
# beta_25 (TEST build — one user-facing note per non-blank, non-# line; # lines are section comments)
# beta_25 (STABLE — rolls up the beta_22..25 test builds on top of the previous stable, beta_21.
# One user-facing note per non-blank, non-# line; # lines are section comments.)
# --- Fixed ---
Contacts you discover now show up in your contact list right away — even if the device was on another screen, or the node was added by the phone app over Bluetooth. No more changing the sort to make them appear. Thanks to 1nerdherder for the report.
Manual telemetry and status requests now work on the first tap instead of needing a second. Thanks to Dan Vybiral (@Vybo) for the fix.
The signal probe reads a live signal to your nearest repeater again. It sends the standard mesh node-discovery request that repeaters actually answer (the same one the Ultra and KiekR apps use), so the signal reading updates. Thanks to Tarmo for decoding the exact packet.
Bluetooth companion reliability, especially messages and repeater / room-server admin. The device no longer floods the Bluetooth link with a per-packet log (USB and Wi-Fi companions still get it), so incoming messages reach the app and admin actions like Manage Regions stop timing out. (Issues #46 and #54.)
Contacts show up right away. A node you discover, even while you're on another screen or one the phone app adds over Bluetooth, now appears in your contact list immediately instead of only after you change the sort. Thanks to 1nerdherder for the report. (Issue #73.)
Manual telemetry and status requests now work on the first tap instead of needing a second. Thanks to Dan Vybiral (@Vybo) for the fix. (Issue #81.)
On the emoji picker and quick-chat replies, pressing Esc or the red X now closes just the popup instead of the whole chat screen.
Lock screen: you can now unlock the device after the screen wakes from an incoming message.
# --- New ---
New "Advertise on boot" option in the Advertise app (off by default): turn it on to send one advert when the device powers on, so nearby nodes refresh your details after a reflash.
New "Advertise on boot" option in the Advertise app (off by default): turn it on to send one advert when the device powers on, so nearby nodes refresh your details after a reflash. (Issue #76.)
# --- Tanmatsu ---
Tanmatsu: Wi-Fi map tiles now download and display, cached to the microSD card.
@@ -15,4 +18,4 @@ Tanmatsu: contacts and message history now survive a reboot (moved to the microS
Tanmatsu: fixed the crash that stopped the app from booting; popups now fit the screen and scroll, and keyboard navigation stays inside an open popup.
# --- Also ---
This is a test-channel build (opt in with "Get test builds" on the About screen). Everything from the stable beta_21 and the earlier test builds is included.
This is the new stable build, promoted from the beta_25 test channel. It includes everything from the beta_22, beta_23, beta_24 and beta_25 test builds.
+8
View File
@@ -0,0 +1,8 @@
# beta_26 (TEST) — storage-reliability fix on top of the current stable (beta_25).
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.
# --- Improved ---
On devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). That frees the small internal flash and puts the frequently-rewritten files on the more reliable card. Your existing contacts move to the card automatically the first time; your identity and settings stay in internal flash, so the node is unchanged.
+15
View File
@@ -0,0 +1,15 @@
# beta_27 (STABLE — rolls up the beta_26 + beta_27 test builds on top of the previous stable, beta_25.
# One user-facing note per non-blank, non-# line; # lines are section comments.)
# --- Fixed ---
Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.
Devices installed under the Launcher no longer show the on-device "Install update" button. That update path only works on a standalone flash; under the Launcher it failed with "won't fit: Bad Size Given". The "update available" note stays, but you update through the Launcher instead. Thanks to Keith (KM1USR) for the report.
# --- New ---
Keyboard backlight brightness control on the T-Deck: a live slider plus Off / 25 / 50 / 75 / Max presets in Settings > Keyboard. It dims smoothly (fine-grained at the low end), applies to the on and auto modes and the new-message flash, and survives a reboot. Thanks to Yazutsu (PR #85).
# --- Improved ---
On devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). Your existing contacts move over automatically on first boot; your identity and settings stay in internal flash, so the node is unchanged. Keep the card inserted afterwards - the contact list lives on it, and an older firmware won't look there if you ever downgrade.
# --- Also ---
This is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.
+13
View File
@@ -0,0 +1,13 @@
# beta_28 (TEST) — room-server session fix + the wyvern.red feedback batch, on top of stable beta_27.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Room servers no longer go one-way "frozen". The device now keeps its room login session alive in the background and automatically logs back in when the session drops, so posts keep being delivered and new room messages keep arriving. There is also a new "Log in again" option in the room chat's menu, and a clear warning when the room only granted you read-only access (posts would previously just vanish). Thanks to EricMW61 for the report (issue #89).
The screen now truly switches off in standby. Before, only the backlight went dark while the panel kept displaying the last image - the actual cause of the LCD ghosting / burn-in some of you noticed. The panel itself now sleeps, and on wake your screen is back instantly. Also: a lit lock screen now always dims on every board, a screen woken by a message re-darkens after 10 seconds, and the lock-screen clock drifts a few pixels to avoid ghosting. Thanks to wyvern.red for pinning this down.
The on-device "Install update" now also hides on mixed or older partition layouts where an update could crash the device halfway through (found via a user crash report - thanks!).
# --- New ---
Compact messages (IRC style): a new toggle in chat settings shows one dense line per message instead of bubbles, fitting two to three times more history on screen. Off by default. (wyvern.red's suggestion.)
Clearer delivery ticks: an up-arrow means sent but not yet confirmed, a double check means the other side confirmed it, and a failed message now says "tap to resend" - one tap resends it. (wyvern.red)
Keyboard navigation: the scroll keys (F/C) now zoom the map, and backspace in an empty message box exits typing mode so the navigation keys work again. (wyvern.red)
"Reset path" is now also in the chat's own menu next to Blocked users, so you can force a fresh route without going through Contacts.
+17
View File
@@ -0,0 +1,17 @@
# beta_29 (TEST) — clock hardening for room servers, BLE repeats-heard fix, deeper SD chat history, one-screen popups. On top of beta_28.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Room logins can no longer be silently locked out by the device clock. Room servers quietly reject anything older than the newest timestamp they have seen from you, so a clock that stepped backwards (a reboot without GPS or phone around, or a sync correcting a fast clock) black-holed every Join with zero feedback. The device now remembers the newest timestamp it has used across reboots so it can never fall behind itself, a Join that gets no reply shows a clear error after 20 seconds (including what the device thinks the time is) instead of spinning forever, and the device warns you when its clock and the room server's clock disagree. Thanks to EricMW61 for the follow-up testing on issue #89.
"Repeats heard" works again over Bluetooth. The companion app counts repeats from the per-packet RX log, which beta_23 stopped sending over BLE to fix congestion. The firmware now recognises packets that are repeats of your own messages and lets exactly those through to BLE, so the counter works and the congestion fix stays. Thanks to daryldakine for the clean bisect (issue #94).
Messages sent from the phone app through the device now also appear on the device itself, as your own outgoing message in that channel. This worked for direct messages but never for channels.
Deleting a chat or channel now sticks. Deleted messages could survive in storage and resurrect the chat later, and removing a channel could miss the underlying channel entry so the thread came right back.
Settings pages no longer jump down when opened on keyboard-navigation devices, and the red update dot can no longer steal the selection from the About button.
The update check no longer shows "couldn't reach the update server" for six hours after one failed attempt. It now waits for Wi-Fi to actually have an address and retries within a minute.
The Tanmatsu About page now shows the real firmware version instead of a stale hardcoded one.
# --- New ---
Much deeper chat history on devices with an SD card: up to 5000 messages instead of 500, with date separators (Today, Yesterday, day names) between messages from different days. Note for testers: if you later downgrade below beta_29, the older build will not read the larger history file and will start with an empty history.
Chat and channel rows in the messages tab now use the same compact style as the Contacts tab, fitting more conversations on screen.
The chat or channel settings sheet (long-press or the gear) now fits on one screen, laid out like the contact menu, instead of scrolling.
Compact chat mode is easier to read: messages alternate between two background tones.
+11
View File
@@ -0,0 +1,11 @@
# beta_30 (TEST) — the big-mesh sluggishness fix + a much faster Spectrum analyzer. On top of beta_29.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The periodic multi-second freezes on busy meshes are gone. Saving the contact list to storage took up to 3 seconds at ~570 contacts and ran on the UI thread every couple of minutes, which is exactly the trackball and touch hesitation people felt since beta_25. The save now writes the same data in a few large chunks instead of thousands of tiny ones, cutting it to a fraction. Thanks to guinnessbear for the report and Yazutsu for the precise measurements that pinned this down (issue #82).
The Contacts tab no longer rebuilds itself every 60 seconds just to refresh the "heard 2h ago" labels - they update in place now. That rebuild cost over a second on large contact lists and could yank the list while you were scrolling.
A burst of adverts can no longer queue several back-to-back Contacts rebuilds; they are now coalesced.
# --- Improved ---
The Spectrum analyzer feels live now: the waterfall gets a new line about four times per second instead of once per sweep, the trace advances across the band as it sweeps (like a bench analyzer), and a full sweep is about 20 percent faster.
The Spectrum trace also scales itself to the signal now: the chart tracks the live minimum and maximum, jumping out instantly for a strong signal (no more clipping flat at the top) and easing back once it is gone.
+9
View File
@@ -0,0 +1,9 @@
# beta_31 (TEST) — the new chats tab: avatars, message previews, custom channel icons. On top of beta_30.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
The chats tab has a fresh look: each conversation now shows a round avatar in that chat's signature colour (the same colour its bubbles use) with the first two letters of the name, the last message previewed under the name, the time top right and the unread count below it. Compact chat mode keeps the dense single-line rows instead, so both styles are available.
Channels can have a custom icon: open the channel's settings (long-press or the gear) and tap "Chat icon" to pick any emoji as its avatar. Long-press the same entry to go back to the automatic letters. The choice is remembered per channel.
# --- Fixed ---
Long chat names no longer wrap onto the message preview underneath.
+9
View File
@@ -0,0 +1,9 @@
# beta_32 (TEST) — the Wi-Fi freeze fix: a stale phone/web connection can no longer stall the device. On top of beta_31.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The 1-2 second freezes every ~10 seconds some users saw with Wi-Fi on are gone. When a phone app or web client was connected over Wi-Fi and that connection went stale (phone asleep, walked out of range), every received mesh packet made the device wait on the dead connection before the UI could continue. The device now only writes to a connection when it can accept data immediately, and a connection that stays unresponsive for 10 seconds straight is dropped so the client can reconnect cleanly.
Saving chat history could briefly hitch the UI on busy meshes: the history file now writes in a few large chunks instead of thousands of tiny ones (the same treatment the contact list got in beta_30), and background saves are spaced further apart while messages stream in.
# --- New ---
The About page now shows recent loop stalls (anything over 0.2 seconds, newest first), so freeze reports can be confirmed or ruled out at a glance.
+7
View File
@@ -0,0 +1,7 @@
# beta_33 (TEST) — the MQTT freeze fix + the About page no longer stalls itself. On top of beta_32.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The freezes with MQTT enabled are gone. Connecting to the broker (DNS lookup, TCP connect, login) could block the device for 2-10 seconds and retried every 15 seconds while the broker was unreachable, all on the UI thread. Connection attempts now run fully in the background, and writes to a broker that stops responding are skipped instead of waited on (the same treatment beta_32 gave phone/web connections). Tip: if your bridge keeps reconnecting, check the broker address and credentials - a healthy connection does not retry.
The About page caused a ~0.4 second hitch every second while it was open: computing the firmware image size checksums the whole ~3 MB app in flash and ran twice per refresh. It is now computed once per boot, so About (and its stall list) no longer disturbs the very measurements it shows.
Favoriting a contact when the favorites list is full (16) now says "Favorites full (max 16)" instead of the incorrect "Removed from favorites".
+11
View File
@@ -0,0 +1,11 @@
# beta_34 (TEST) - buffered receive: the missed-messages fix, ON by default on this test build. On top of beta_33.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
This build changes how the device collects packets from the LoRa radio, and the new way is ON by default so the test channel can prove it. Background: the radio chip holds only one received packet at a time, and until now the firmware only collected it when the main loop got around to it, the same loop that draws the screen. If the interface kept the loop busy while two packets arrived back to back, the first one was silently overwritten. That is the "my device missed a message another device right next to it heard" problem some of you reported, and why it affected this firmware but not stock MeshCore, which has no screen to draw. A small dedicated background task now collects every packet within about a millisecond of it arriving, no matter what the interface is doing.
Because the receive path is the heart of the device, this ships with a safety hatch: if you see anything odd with receiving, sending, or the Spectrum analyzer, turn off "Buffered receive (experimental)" in Settings > Radio & Mesh (applies instantly, no reboot) and you are back on the old path. Please report it either way, that is exactly what the test channel is for.
The About page now shows LoRa receive counters (heard / read / late-lost). "late-lost" counts packets the radio heard but the firmware failed to collect in time. With buffered receive on it should stay at zero. If you turn the toggle off on a busy mesh you may watch it creep up, which is the old bug happening live.
# --- Fixed ---
Messages sent by wadamesh no longer carry a few stray bytes at the end that stock MeshCore devices and some companion apps displayed as garbage characters after the text (issue #13).
A GPS module reporting a bogus date can no longer drag the device clock back to the year 1902 (core clock guard).
+11
View File
@@ -0,0 +1,11 @@
# beta_35 (TEST) - V4 breathing room: freezes fixed, 30 KB of internal RAM back, crash-safe settings. On top of beta_34.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Heltec V4: multi-second freezes right after receiving messages are gone, including the screen refusing to wake from dark mode. Saving chat history to the internal flash could stall the device for up to 6 seconds (the V4 has no SD card, and internal flash pauses for housekeeping); that save now runs on a background task, so the screen and touch stay responsive no matter what storage is doing.
Turning Bluetooth on while Wi-Fi is running no longer reboots the device when memory is tight: the switch now checks free memory first and tells you "Not enough free memory for Bluetooth. Turn Wi-Fi off first." instead of crashing.
The same protection applies the other way around: turning Wi-Fi on with Bluetooth active used to claim "Wi-Fi on" while the radio silently never started. It now either really starts or tells you why it cannot.
Settings could vanish for a single boot after an unlucky reboot (the device came up with a default name once, then recovered). Settings writes are now crash-safe: a reboot or power cut at any moment leaves at least one intact copy on storage, the loader self-heals from it, and a failed save now says "Couldn't save the name to storage" instead of pretending it worked.
# --- Improved ---
About 30 KB of the ESP32's scarce internal RAM has been freed by moving buffers to the external PSRAM and dropping unused USB components from the build. This is the headroom that makes Wi-Fi and Bluetooth run together comfortably again, especially on the Heltec V4.
+13
View File
@@ -0,0 +1,13 @@
# beta_36 (TEST) - Launcher SD storage really works now, Wi-Fi settings redesign, symbol passwords, lock-screen unread count. On top of beta_35.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The "Store data on SD" toggle is now actually honored after a reboot (fix contributed by scratchdiver, PR #123). On Launcher installs the setting silently reverted to internal storage every boot, which also meant those devices kept the slow internal-flash writes. If you run wadamesh under the Launcher and your mesh is busy, this one is for you: flip the toggle once and contacts and settings really move to the SD card.
Turning "Store data on SD" off works reliably now too, including on Launcher installs (follow-up to PR #123).
Passwords with special characters can now be entered everywhere: long-press any settings field, including the Wi-Fi password, for the Cut / Copy / Paste / All / Sym menu. Sym opens a picker with the characters the on-screen keyboard does not have (issue #122). Double-tap selects a word.
# --- New ---
The Wi-Fi network screens (join a network, hidden network, saved-network details) are full-screen pages now instead of small popups: same style as the other settings pages, title in the top bar, tap the bar to go back, and everything scrolls properly with the keyboard open.
The lock screen shows an envelope with the number of unread messages under the clock, so you can check for news without unlocking (idea from issue #93). It disappears when there is nothing unread.
For Launcher installs: a "Save update bin to SD" button on the About page downloads the latest firmware of your selected channel into a BINS folder on the SD card, named with the version and channel, ready to flash from the Launcher. The test-builds toggle is now visible on Launcher installs as well.
New region preset "Australia (Mid)": 915.075 MHz, 125 kHz, SF9, CR5 (requested in issue #121).
+15
View File
@@ -0,0 +1,15 @@
# beta_37 (TEST) - Tanmatsu ALT accents + language switching, GPS quick reply, T-Deck touch self-heal, SD history reliability. On top of beta_36.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
Tanmatsu: hold ALT and press a letter to open the accent picker (a with ALT gives an accent popup, just like long-press on the touch boards). Keep ALT held and press the letter again to cycle through the options; release ALT to insert the highlighted one. Requested by jochentj in issue #129.
Tanmatsu: double-tap the space bar to switch between your enabled keyboard languages, same as on the T-Deck.
Accented letters on the Tanmatsu now render at full size at the Large and Huge UI scales instead of noticeably smaller (issue #129). Made possible by moving all boards to compressed UI fonts, which also frees a chunk of firmware space.
Your GPS position is now the first row in the quick-reply picker, so you can send your current coordinates with one tap (contributed by Yazutsu, PR #125).
While in a chat or channel, the top bar shows the active keyboard language next to the clock (only when more than one layout is enabled), so you can see what double-tap-space switched to.
# --- Fixed ---
The keyboard-backlight "Off" preset now actually switches the backlight off and stays off, instead of leaving the previous mode running (contributed by Yazutsu, PR #88).
Chat history saves to the SD card are more robust: files are written atomically and a fallback save path covers the background worker not being armed yet, so a badly timed reboot can no longer truncate your message history (contributed by scratchdiver, PR #135).
T-Deck: if the touch controller glitches into a stuck "phantom finger" state (everything looked frozen, touch dead, trackball clicks eaten, and the screen never timed out), the driver now detects it and recovers within seconds. Previously only a full power cycle would clear it.
T-Deck: touch initialisation at boot now retries for a few seconds instead of giving up after a single attempt. A slow-to-wake touch controller could previously cost you touch AND keyboard for the whole session.
+17
View File
@@ -0,0 +1,17 @@
# beta_38 (TEST) - Full-history chat scrolling, message delete, two new boards, storage integrity fixes. On top of beta_37.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
Chat scrolling is fully virtualized (contributed by scratchdiver, PR #130): a chat now scrolls through its ENTIRE stored history, up to 5000 messages on SD-card devices, smoothly in both bubble and compact mode, instead of only the most recent rows. Several LVGL crash fixes came along with it.
Long-press any message for a new Delete option that removes just that message. The message menu now lays its buttons out two per row so it always fits on one screen.
The chat and channel settings sheet gains "Delete history": wipes the whole conversation after a confirmation, while the channel membership or contact stays.
The in-chat jump buttons are now slim arrows tucked against the right edge of the screen: fully visible while you scroll, fading to half opacity a second after you stop.
ThinkNode M9 hardware support is complete (contributed by ded, PR #138): GPS, microSD, buzzer, backlight control, lock screen, long-press context menus, and a d-pad-driven setup wizard. His fixes for focus scrolling in nested pages and the lock-screen paint order benefit every keyboard-nav board.
New board in the source tree: RAK WisMesh Tap V2 (contributed by Ethac.chen, PR #136), with a 30+ fps display driver. Prebuilt images follow once the web-flasher integration lands.
Tanmatsu: the app now builds size-optimized, roughly 300 KB smaller, leaving real headroom under the launcher's app-size limit for future features.
# --- Fixed ---
Messages no longer vanish when you reboot the device from the phone app (reported by Leon P). The history writer batches saves up to 30 seconds apart, and the app's reboot command skipped the final save that every on-device reboot path does - so the newest messages, read or unread, were lost. The reboot command now saves first.
T-Deck: turning on "Store data on SD" now migrates your existing profile, settings and identity to the card FIRST. Upgrading to beta_36/37 with the toggle already on could adopt an empty card and boot as a brand-new node while your real profile sat orphaned on internal flash.
T-Deck: for anyone already hit by that: Set -> General -> "Copy internal data to SD" restores the profile from internal flash onto the card (confirmation, then automatic reboot). Until you run it, toggling "Store data on SD" OFF and rebooting also brings the old profile back.
Tanmatsu: profile name, settings and the node identity now persist reliably across reboots. They lived on the internal flash partition whose metadata layer is unreliable on this board; they now live on the SD card with everything else, and existing data is rescued automatically on first boot.
+11
View File
@@ -0,0 +1,11 @@
# beta_39 (TEST) - Translations, deletes that stick and are fast, emoji fixes. On top of beta_38.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
Big translation pass: hundreds of previously-English strings across menus, settings, dialogs and the setup flow are now translated into all 11 non-English languages (Dutch, German, French, Spanish, Italian, Russian, Ukrainian, Bulgarian, Serbian, Greek, Brazilian Portuguese). Menu rows that carry an icon now translate too, instead of silently falling back to English. Switch language under Settings.
ThinkNode M9 and RAK WisMesh Tap V2 can now be installed straight from the web flasher.
# --- Fixed ---
Deleting a message, or clearing a whole chat, now sticks: it survives a reboot or a hardware reset instead of coming back (reported by Leon P and MrWeavis). The delete used to sit in a save that was batched up to 30 seconds later, so a reset in that window undid it.
Deleting is also much faster now. The chat history is written compactly - only the messages you actually have, instead of the full 5000-slot ring every time - so a delete, a history wipe, or a reboot no longer pauses the screen while it saves. Normal message saving is lighter too.
Emoji picker: the food and object emoji that showed as empty boxes (umbrella, coffee, pizza, cake, rocket, camera, phone, laptop, calendar, antenna, e-mail, house, car, watch) now render properly - they were listed but never baked into the font.
+32
View File
@@ -0,0 +1,32 @@
# beta_40 (STABLE) - the first stable since beta_35. Rolls up everything from beta_36 through beta_40: reliable SD storage, message delete + full-history scrolling, a big translation pass, two new boards, and a pile of storage/perf/stability fixes.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
Big translation pass: hundreds of previously-English strings across menus, settings, dialogs and the setup flow are now translated into all 11 non-English languages (Dutch, German, French, Spanish, Italian, Russian, Ukrainian, Bulgarian, Serbian, Greek, Brazilian Portuguese). Menu rows that carry an icon translate too. Switch language under Settings.
Chat scrolling is fully virtualized (contributed by scratchdiver, PR #130): a chat now scrolls through its ENTIRE stored history, up to 5000 messages on SD-card devices, smoothly in both bubble and compact mode, instead of only the most recent rows.
Long-press any message for a Delete option that removes just that message; the message menu lays its buttons two per row so it fits on one screen.
The chat and channel settings sheet gains "Delete history": wipes the whole conversation after a confirmation, while the channel membership or contact stays.
The in-chat jump buttons are slim arrows tucked against the right edge: fully visible while you scroll, fading to half opacity a second after you stop.
ThinkNode M9 hardware support is complete (contributed by ded, PR #138): GPS, microSD, buzzer, backlight control, lock screen, long-press context menus, and a d-pad-driven setup wizard. It can be installed straight from the web flasher.
New board: RAK WisMesh Tap V2 (contributed by Ethac.chen, PR #136), with a 30+ fps display driver, also installable from the web flasher.
The Wi-Fi network screens (join a network, hidden network, saved-network details) are full-screen pages now instead of small popups, matching the other settings pages and scrolling properly with the keyboard open.
The lock screen shows an envelope with the number of unread messages under the clock, so you can check for news without unlocking (idea from issue #93).
Passwords with special characters can be entered everywhere: long-press any settings field, including the Wi-Fi password, for a Cut / Copy / Paste / All / Sym menu. Sym opens a picker with the characters the on-screen keyboard lacks (issue #122). Double-tap selects a word.
For Launcher installs: a "Save update bin to SD" button on the About page downloads the latest firmware of your selected channel into a BINS folder on the SD card, ready to flash from the Launcher. The test-builds toggle is visible on Launcher installs too.
New region preset "Australia (Mid)": 915.075 MHz, 125 kHz, SF9, CR5 (requested in issue #121).
Your GPS position is now the first row in the quick-reply picker, so you can send your current coordinates with one tap (contributed by Yazutsu, PR #125).
Tanmatsu: hold ALT and press a letter to open the accent picker; keep ALT held and press again to cycle, release to insert (requested by jochentj, issue #129). Double-tap the space bar to switch between your enabled keyboard languages, same as the T-Deck.
While in a chat, the top bar shows the active keyboard language next to the clock when more than one layout is enabled.
# --- Fixed ---
The "Store data on SD" toggle is now actually honored after a reboot (fix contributed by scratchdiver, PR #123). On Launcher installs it silently reverted to internal storage every boot, which also kept the slow internal-flash writes. Turning it off works reliably now too.
Turning on "Store data on SD" migrates your existing profile, settings and identity to the card FIRST, so upgrading no longer risks adopting an empty card and booting as a brand-new node. If you were already hit by that, Settings -> General -> "Copy internal data to SD" restores the profile from internal flash.
Card-less devices (Heltec V4, and any board without an SD card) no longer freeze for several seconds at a time. A busy mesh re-writes the whole contacts file to internal flash on every advert refresh, and that flash garbage-collection stalled the whole device; contact saves are now coalesced so only real changes (a new or removed node) are written promptly.
Deleting a message, or clearing a whole chat, now sticks: it survives a reboot or a hardware reset instead of coming back (reported by Leon P and MrWeavis). Deleting is also much faster - chat history is written compactly, only the messages you actually have, so a delete, a history wipe, or a reboot no longer pauses the screen while it saves.
Messages no longer vanish when you reboot the device from the phone app (reported by Leon P): the app's reboot command now saves chat history first, like every on-device reboot path already did.
Chat history saves to the SD card are more robust: files are written atomically with a fallback save path, so a badly timed reboot can no longer truncate your message history (contributed by scratchdiver, PR #135).
Longer translated labels (French, German, etc.) that used to clip or wrap now shrink to fit: the message action menu, confirmation dialogs, and the app-drawer tiles all keep their text on one line. The signal card's "tap for details" hint no longer overlaps in other languages.
Emoji picker: the food and object emoji that showed as empty boxes (umbrella, coffee, pizza, cake, rocket, camera, phone, laptop, calendar, antenna, e-mail, house, car, watch) now render properly.
Tanmatsu: profile name, settings and the node identity persist reliably across reboots (moved off the unreliable internal-flash partition onto the SD card, with existing data rescued on first boot). Accented letters render at full size at the Large and Huge UI scales. The About page shows the correct firmware version now.
T-Deck: if the touch controller glitches into a stuck "phantom finger" state (touch dead, trackball clicks eaten, screen never timing out), the driver detects it and recovers within seconds instead of needing a full power cycle. Touch initialisation at boot also retries for a few seconds instead of giving up after one attempt.
The keyboard-backlight "Off" preset now actually switches the backlight off and stays off (contributed by Yazutsu, PR #88).
+10
View File
@@ -0,0 +1,10 @@
# beta_41 (TEST) - experimental web control: view and drive the wadamesh UI from a phone or desktop browser over Wi-Fi. Two new apps, VNC and REMOTE.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
New VNC app (in the app drawer, between Advertise and Signal): it mirrors the live device screen into a web browser over Wi-Fi and lets you drive it. Taps and typing in the browser control the device, and anything that changes on the device shows up in the browser. Turn it on, then open http://<device-ip>:8765/ from any browser on the same network. The app page itself is scrollable.
New REMOTE app: a headless mode that renders the whole UI at a larger, browser-friendly resolution and serves it over Wi-Fi, while the device's own screen drops to a placeholder showing the address to open. Meant for driving a device as a web client, and as groundwork for screenless hardware. Turning it on or off reboots the device. On the web page a Rotate button flips between landscape (desktop) and portrait (phone); on the device, double-tap the space bar to leave remote mode.
Both features are served by the device itself over plain Wi-Fi - no phone app, no account, no cloud, just a browser on the same network.
# --- Note ---
This is an experimental first cut, and an honest heads-up from Kaj: for me the remote UI still feels too slow, and I will most likely move toward a dedicated, lighter remote client instead of streaming the entire live UI. It is in this test build mainly because community feedback on it would be genuinely useful - if you try it, let us know how it runs for you and what you would actually want from a web or remote client.
+9
View File
@@ -0,0 +1,9 @@
# beta_42 (TEST) - fixes for the beta_41 REMOTE mode: it works on the Heltec V4 now, and keyboard-less boards can leave it.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
REMOTE mode now works on the Heltec V4 TFT (and other 2 MB-PSRAM boards) instead of showing a black screen in the browser. Its off-screen renderer needed more free memory than those boards had, so nothing was streamed; the buffers are sized to fit now, and the render is a bit smaller and faster as a result. VNC was never affected and already worked.
Keyboard-less touch boards (Heltec V4, RAK WisMesh Tap) can leave REMOTE mode again: touch and hold the device screen for about 3 seconds. Those boards have no SPACE key, which used to be the only way out on the device.
# --- New ---
The web page has an "Exit remote" button (with a confirmation) that leaves REMOTE mode from any browser, on any board.
+24
View File
@@ -0,0 +1,24 @@
# beta_43 (TEST) - the browser control panel grows into a full remote app (Chats, Contacts, Discovered, Settings), plus a Heltec V4 map-tile fix.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: web control panel ---
The browser control panel is now a full remote app - you can run the device from your phone or laptop without ever picking up the T-Deck. Proper Chats, Contacts, Terminal and Settings tabs.
Contacts tab: search, sort and filter your contacts, see each one's distance, coordinates and last-heard age, and tap a contact for the same actions you get on the device (favourite, block, reset path, remove).
Discovered nodes show up in the browser too, each with a one-tap "Add" to save it as a contact - so you can build your whole contact list from the web app.
Chats tab: pop-up notifications (with an optional beep) when a message arrives, and the open chat refreshes on its own as replies come in - no manual reload.
Acknowledgements and repeats-heard now show for messages you send from the web app, the same as on the device.
A Settings page for the everyday options that make sense remotely, including live radio changes.
A quick-command button in the Terminal that drops common commands straight into the input box.
# --- New: device ---
Map options has a "Tile debug overlay" toggle (off by default) that shows a live read-out of the map-tile pipeline, for diagnosing tile problems.
# --- Fixed ---
Heltec V4: map tiles downloaded but only the top of the map rendered and the rest stayed black. The 2 MB-PSRAM V4 was running out of contiguous memory for the tile grid; tiles now reuse a small fixed pool and only load what is on screen, so the whole map fills in smoothly again.
Heltec V4 in remote/VNC mode: typing from a physical keyboard in the browser now works (it previously only worked on the T-Deck).
The remote-access screen on the device is clearer: the IP address is shown at the top, the explanation is shorter and more accurate, the toggles sit above it, and the address box only appears once remote access is on.
The Terminal "help" command now lists every command, on both the device and the web terminal (it was missing a lot).
# --- Changed ---
The web app header now shows the same signal, Wi-Fi, Bluetooth and battery icons as the device status bar, and the title reads WADAMESH.
The contact and chat sort/filter controls open a menu to pick an option instead of cycling through them on each tap.
+15
View File
@@ -0,0 +1,15 @@
# beta_44 (TEST) - an on-device text web browser, tappable links in chat, and a French-keyboard spacebar fix.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: on-device web browser ---
New "Web" app: open a URL and read the page as plain text - articles, wikis, docs, plain sites - right on the device. No proxy, no tracking, no JavaScript or images; just the words. Links are tappable, and there are back, forward and refresh buttons.
The Web app needs the memory only the 8 MB boards have (T-Deck, Tanmatsu), so it is hidden on the 2 MB Heltec V4 - that board can't complete an on-device HTTPS handshake.
# --- New: links in chat ---
Links in a chat are now tappable. Tap one for a small menu: "Open in web" loads the page in the on-device browser, and "Create QR" shows a scannable QR code so you can open the link on your phone.
Create QR works on every board, including the Heltec V4 - so even where the browser isn't available you can still bounce a link to your phone.
# --- Fixed ---
French (AZERTY) on-screen keyboard: the spacebar is back in the normal letter view (along with the OK and keyboard-switch keys). You no longer have to switch to the symbols page just to type a space.
Heltec V4: web page fetching and map tiles are more resilient when memory is tight.
Home signal & traffic box: the "tap for details" cue is back at the top of the box.
+29
View File
@@ -0,0 +1,29 @@
# beta_45 (STABLE) — everything since the last stable (beta_40): browser-based remote control, an on-device web browser, three new boards, and a batch of fixes.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: control wadamesh from a browser (over Wi-Fi — no app, account or cloud) ---
New web control panel: run the device from a phone or laptop browser over Wi-Fi, with full Chats, Contacts, Discovered, Terminal and Settings tabs — served by the device itself. Turn on the VNC app, then open http://<device-ip>:8765/ from any browser on the same network.
VNC app: mirrors the live device screen into the browser and lets you drive it — taps and typing in the browser control the device, and anything that changes on the device shows up in the browser.
REMOTE app: a headless mode that renders the whole UI at a larger, browser-friendly resolution over Wi-Fi while the device screen shows the address to open; a Rotate button flips between desktop (landscape) and phone (portrait) layouts.
Web Contacts: search, sort and filter contacts, see each one's distance, coordinates and last-heard age, and get the same per-contact actions as on the device (favourite, block, reset path, remove). Discovered nodes appear too, each with a one-tap "Add".
Web Chats: pop-up notifications (with an optional beep) when a message arrives, the open chat auto-refreshes as replies land, and acknowledgements + repeats-heard show for messages you send from the browser.
# --- New: on-device web browser + links in chat ---
New "Web" app (8 MB boards — T-Deck, Tanmatsu): open a URL and read the page as plain readable text — no proxy, tracking, JavaScript or images — with tappable links and back/forward/refresh.
Links in a chat are now tappable: "Open in web" loads the page in the on-device browser, and "Create QR" shows a scannable QR to open the link on your phone (works on every board, including the Heltec V4).
# --- New boards ---
LilyGo T-LoRa Pager (ESP32-S3): a QWERTY-keyboard handheld with a rotary encoder and no touchscreen, fully driven from the keyboard + encoder; both radio variants (LR1121 and SX1262) supported. Contributed by @codemonkeybr (PR #149) — thank you!
LilyGo T-Display P4 (ESP32-P4): an AMOLED touchscreen handheld. Early port ("partially supported") — LoRa mesh, chat, map, GPS and the phone-app companion over Wi-Fi/USB work; Bluetooth pairing isn't available on this board (stock radio-coprocessor limitation — pair over Wi-Fi or USB).
Heltec WiFi LoRa 32 V4-R8 (8 MB PSRAM) with Expansion Kit V2 (microSD): "partially supported" while hardware testing finishes.
# --- Fixed ---
Heltec V4: map tiles now fill the whole map instead of only the top — the 2 MB-PSRAM V4 was running out of contiguous memory for the tile grid, so tiles now reuse a small fixed pool and only load what's on screen.
Heltec V4-R8: fixed random hard-freezes that needed a reset (the touch controller was wedging the shared I2C bus).
Tanmatsu and other keyboard-only boards: you can now select and edit the Wi-Fi name and password fields in Wi-Fi settings.
T-Display P4: the clock sets itself over Wi-Fi even where NTP is blocked, and won't get stuck on a bad date from the hardware clock.
French (AZERTY) on-screen keyboard: the spacebar is back in the normal letter view.
REMOTE mode now works on the Heltec V4 TFT (and other 2 MB-PSRAM boards); keyboard-less boards can leave REMOTE by holding the screen ~3 s, and the web page has an "Exit remote" button.
# --- Also ---
The flasher site shows a support badge on every board (fully vs partially supported); hover it to see what works.
+12
View File
@@ -0,0 +1,12 @@
# beta_46 — control-center redesign, Do Not Disturb, contact safety + board fixes
# New
Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.
Do Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)
Do Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.
# Fixed
Contacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.
Heltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.
LilyGo T-Pager: the map no longer shows a "storage error" under the launcher — it caches tiles to the microSD card, like the T-Deck.
Heltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.
+17
View File
@@ -0,0 +1,17 @@
# beta_47 — Discover app (find + map nearby nodes, wardriving), a new Attaky board, pager + P4 additions
# One user-facing note per non-blank, non-# line; # lines are section comments.
# New
New Discover app: actively sweep for nearby nodes and list everything that answers — repeaters, companions and room servers — ranked by signal, with live RSSI/SNR, a "direct" badge for anything in RF range, and a last-heard age. It's in the app drawer (the ⟳ tile, next to Map).
Tap any node in Discover to add it straight to your contacts — it keeps the right type (repeater, companion or room).
Wardriving: with a GPS fix, Discover logs every node it hears against your position to a CSV on the SD card as you move, and plots your RF coverage on the map as a signal-coloured track (green = strong, red = weak) — a standalone, on-device coverage mapper, no phone needed.
# New boards
Attaky Core (ESP32-S3, 16 MB flash / 8 MB PSRAM): a new board, contributed by @attakygit (#158). Experimental — on the beta channel while it finishes hardware verification.
LilyGo T-Display P4 (TFT-LCD screen variant): early support for the LCD screen model (the AMOLED variant was already supported). Experimental — if you have the LCD screen, grab the wadamesh-tdisplay-p4-lcd build from the release assets.
# Fixed
LilyGo T-LoRa Pager: the rotary encoder now keeps its focus inside the chat message list, and the composer navigation keys are remapped so moving around and typing a message no longer fight each other. (community contribution by @codemonkeybr, #162)
# Boards
T-Deck · Heltec V4 TFT · Heltec V4-R8 · ThinkNode M9 · RAK TAP v2 · T-LoRa Pager (LR1121 / SX1262) · Attaky Core · T-Display P4.
+17
View File
@@ -26,6 +26,23 @@ SINGLE = [
(0x26BD, "26bd"), # soccer ball âš½
(0x1F3C8, "1f3c8"), # american football ðŸ
(0x1F5FF, "1f5ff"), # moai 🗿
# objects/food listed in the picker (k_emoji_items) but never baked -> they
# rendered as the notdef box on-device (the beta_38 "emoji tofu"). Bake them.
(0x2614, "2614"), # umbrella with rain
(0x2615, "2615"), # hot beverage
(0x1F355, "1f355"), # pizza
(0x1F382, "1f382"), # birthday cake
(0x1F680, "1f680"), # rocket
(0x1F4F7, "1f4f7"), # camera
(0x1F4F1, "1f4f1"), # mobile phone
(0x1F4BB, "1f4bb"), # laptop
(0x1F310, "1f310"), # globe with meridians (Web app icon)
(0x1F4C5, "1f4c5"), # calendar
(0x1F4E1, "1f4e1"), # satellite antenna
(0x1F4E7, "1f4e7"), # e-mail
(0x1F3E0, "1f3e0"), # house
(0x1F697, "1f697"), # car
(0x231A, "231a"), # watch (sat between car + bulb in the picker, still tofu)
]
# ZWJ sequences baked as ONE combined image, keyed on the lead codepoint, with the
# trailing visible symbol(s) mapped to the zero-width glyph so the whole UTF-8
+14 -5
View File
@@ -32,17 +32,26 @@ os.makedirs(outdir, exist_ok=True)
with open(os.path.join(outdir, "version.json"), "w") as f:
json.dump({"tag": tag, "channel": channel, "notes": notes}, f, indent=2)
# manifest filename -> (display name, merged-bin filename, chipFamily).
# chipFamily is ESP32-S3 for every board except the P4-class T-Display P4.
BOARDS = {
"manifest-tdeck.json": ("wadamesh — LilyGo T-Deck", "wadamesh-tdeck-merged.bin"),
"manifest-heltec-v4-tft.json": ("wadamesh — Heltec V4 TFT", "wadamesh-heltec-v4-tft-merged.bin"),
"manifest-tdeck.json": ("wadamesh — LilyGo T-Deck", "wadamesh-tdeck-merged.bin", "ESP32-S3"),
"manifest-heltec-v4-tft.json": ("wadamesh — Heltec V4 TFT", "wadamesh-heltec-v4-tft-merged.bin", "ESP32-S3"),
"manifest-thinknode-m9.json": ("wadamesh — ThinkNode M9", "wadamesh-thinknode-m9-merged.bin", "ESP32-S3"),
"manifest-rak-tap-v2.json": ("wadamesh — RAK WisMesh Tap V2", "wadamesh-rak-tap-v2-merged.bin", "ESP32-S3"),
"manifest-heltec-v4-r8-tft.json": ("wadamesh — Heltec V4-R8 (experimental)", "wadamesh-heltec-v4-r8-tft-merged.bin", "ESP32-S3"),
"manifest-tlora-pager-lr1121.json": ("wadamesh — LilyGo T-LoRa Pager (LR1121)", "wadamesh-tlora-pager-lr1121-merged.bin", "ESP32-S3"),
"manifest-tlora-pager-sx1262.json": ("wadamesh — LilyGo T-LoRa Pager (SX1262)", "wadamesh-tlora-pager-sx1262-merged.bin", "ESP32-S3"),
"manifest-attaky.json": ("wadamesh — Attaky Core (experimental)", "wadamesh-attaky-merged.bin", "ESP32-S3"),
"manifest-tdisplay-p4.json": ("wadamesh — LilyGo T-Display P4", "wadamesh-tdisplay-p4-merged.bin", "ESP32-P4"),
}
for fn, (name, binf) in BOARDS.items():
for fn, (name, binf, chip) in BOARDS.items():
manifest = {
"name": name + ("" if channel == "stable" else " (beta)"),
"version": tag,
"new_install_prompt_erase": True,
"builds": [{
"chipFamily": "ESP32-S3",
"chipFamily": chip,
# Point at the IMMUTABLE per-tag bin, NOT the rolling feed bin. The feed
# *.bin URLs are stable filenames cached 4h and overwritten in place, so
# for up to 4h after a release the flasher could hand out the PREVIOUS
@@ -55,4 +64,4 @@ for fn, (name, binf) in BOARDS.items():
with open(os.path.join(outdir, fn), "w") as f:
json.dump(manifest, f, indent=2)
print(f"wrote version.json + 2 manifests for {tag} ({channel}) -> {outdir} (notes: {len(notes)})")
print(f"wrote version.json + {len(BOARDS)} manifests for {tag} ({channel}) -> {outdir} (notes: {len(notes)})")
+52
View File
@@ -0,0 +1,52 @@
# Patches the vendored RadioLib so LR11x0::config() tolerates old LR1110
# transceiver firmware that predates DriveDiosInSleepMode (opcode 0x012A,
# added in the 0x0308 firmware release). RadioLib >= 7.x sends it
# unconditionally during begin(); older chip firmware answers CMD_PERR,
# which turns an otherwise healthy radio.begin() into -706
# (RADIOLIB_ERR_SPI_CMD_INVALID). Preproduction Elecrow ThinkNode M9 units
# ship with such firmware (Meshtastic works on them only because it pins an
# older RadioLib that doesn't send the command).
#
# The command is an optional nicety (stops DIO glitches in sleep mode), so
# skipping it on old firmware is safe.
#
# Idempotent. Each PlatformIO env has its own libdeps copy, so this only
# affects envs that list this script in extra_scripts (the M9 env).
# NOTE: on a completely fresh checkout the first build may run before
# RadioLib is downloaded - the script then warns and the patch lands on the
# next build. Re-run `pio run` once if you see the warning.
Import("env")
import os
MARKER = "wadamesh-lr1110-oldfw-patch"
OLD = """ state = this->driveDiosInSleepMode(true);
RADIOLIB_ASSERT(state);"""
NEW = """ state = this->driveDiosInSleepMode(true);
// wadamesh-lr1110-oldfw-patch: LR1110 transceiver FW older than 0x0308
// doesn't implement DriveDiosInSleepMode (0x012A) and answers CMD_PERR,
// which would abort init (-706) on an otherwise healthy radio. The command
// only stops DIO glitches in sleep mode - safe to skip on old firmware.
if(state == RADIOLIB_ERR_SPI_CMD_INVALID) {
RADIOLIB_DEBUG_BASIC_PRINTLN("DriveDiosInSleepMode unsupported (old LR11x0 FW), skipping");
state = RADIOLIB_ERR_NONE;
}
RADIOLIB_ASSERT(state);"""
path = os.path.join(env.subst("$PROJECT_LIBDEPS_DIR"), env.subst("$PIOENV"),
"RadioLib", "src", "modules", "LR11x0", "LR11x0.cpp")
if not os.path.isfile(path):
print("[patch_radiolib_lr11x0] WARNING: %s not found (libdeps not fetched yet?)" % path)
print("[patch_radiolib_lr11x0] WARNING: RadioLib NOT patched - re-run the build once libdeps exist")
else:
with open(path) as f:
src = f.read()
if MARKER in src:
print("[patch_radiolib_lr11x0] already patched")
elif OLD in src:
with open(path, "w") as f:
f.write(src.replace(OLD, NEW, 1))
print("[patch_radiolib_lr11x0] patched LR11x0::config() for old-FW tolerance")
else:
print("[patch_radiolib_lr11x0] WARNING: pattern not found - RadioLib version drift?")
print("[patch_radiolib_lr11x0] WARNING: check LR11x0::config() by hand, NOT patched")
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Representative demo data for the wadamesh web-control-panel doc harness.
Field names + shapes here MUST match the firmware's webPush* emitters in
src/ui-touch/UITask.cpp. See serve.py for the @-command -> payload mapping.
Timestamps are seconds since the Unix epoch. Everything is expressed relative to
`NOW` (filled in at serve time) so "last heard" ages stay believable regardless
of when the screenshots are taken. Coordinates are *1e6 fixed-point integers
(la/lo), matching c.gps_lat / c.gps_lon on the device.
The self node sits in Amsterdam; located contacts are scattered a few km around
it so the Distance sort/filter produce nice values.
"""
# Self node position (Amsterdam center) as 1e6 fixed-point, echoed in status.sla/slo.
SELF_LAT = 52_372_800 # 52.3728
SELF_LON = 4_893_400 # 4.8934
# Node identity / radio config shown in the Settings modal + header.
NODE_NAME = "Kaj T-Deck"
RADIO = {
"freq": 869.525, # MHz
"bw": 250.0, # kHz
"sf": 11,
"cr": 5,
"tx": 22, # dBm
}
# ---------------------------------------------------------------------------
# Contacts (webPushContacts: fields i,t,rp,rm,fav,ign,dir,la,lo,adv,n)
# i = contact index (used by @m/@oc/@ic/@fv/@bk/@pr/@xc)
# t = UI thread index if a chat exists, else -1
# rp = 1 if repeater, rm = 1 if room server
# fav = favourite, ign = blocked, dir = direct (out_path_len==0)
# la/lo = gps *1e6 (0 if unknown), adv = last_advert_timestamp (epoch s)
# n = name
# `_dlat/_dlon` below are offsets in degrees from the self node; converted to the
# absolute 1e6 la/lo at build time. Contacts with no GPS use la=lo=0.
# ---------------------------------------------------------------------------
_CONTACTS = [
# name thread type fav ign dir age(s) dlat dlon
dict(n="Sanne", t=0, kind="", fav=1, ign=0, dir=1, age=95, dlat=0.021, dlon=-0.015),
dict(n="Diego", t=1, kind="", fav=0, ign=0, dir=0, age=1500, dlat=-0.034, dlon=0.052),
dict(n="Marta", t=2, kind="", fav=0, ign=0, dir=1, age=320, dlat=0.0, dlon=0.0),
dict(n="Hilltop Repeater", t=-1, kind="rp", fav=0, ign=0, dir=0, age=210, dlat=0.145, dlon=0.088),
dict(n="Harbor Room", t=3, kind="rm", fav=0, ign=0, dir=0, age=640, dlat=-0.011, dlon=0.031),
dict(n="Noah", t=-1, kind="", fav=0, ign=0, dir=0, age=18000, dlat=0.0, dlon=0.0),
dict(n="Femke", t=-1, kind="", fav=0, ign=0, dir=1, age=86400*3, dlat=-0.062, dlon=-0.044),
dict(n="Old Node", t=-1, kind="", fav=0, ign=1, dir=0, age=86400*9, dlat=0.0, dlon=0.0),
]
# Group channels (webPushContacts channels[]: i=slot, t=thread, n=name).
_CHANNELS = [
dict(slot=0, t=-1, n="Public"),
dict(slot=1, t=-1, n="Emergency"),
]
# ---------------------------------------------------------------------------
# Messages per thread (webPushMessages: msgs[] fields o,ts,ds,pl,sn,rs,rp,s,x)
# o = outgoing (1) / incoming (0)
# ts = epoch s
# ds = delivery state for outgoing: 0 none,1 sent,2 delivered,3 failed
# pl = path length (hops); 255 shows as "Direct" in msg info
# sn = SNR dB (incoming), rs = RSSI dBm (incoming)
# rp = repeats heard (outgoing)
# s = sender name (shown only in channel threads), x = text
# Ordered oldest..newest here; serve.py emits newest-first (as the firmware does)
# and the browser reverses again for display.
# ---------------------------------------------------------------------------
_MESSAGES = {
# Sanne (direct peer) — thread 0
0: [
dict(o=0, ago=5400, x="Morning! You around for the hike this weekend?", pl=0, sn=8, rs=-64),
dict(o=1, ago=5300, x="Yeah! Saturday works. Meshing the whole way up.", ds=2, pl=0, rp=1),
dict(o=0, ago=5200, x="Perfect. I'll bring the spare T-Deck as a relay.", pl=0, sn=7, rs=-66),
dict(o=1, ago=280, x="Just tested range from the ridge — 6 km line of sight.", ds=2, pl=0, rp=2),
dict(o=0, ago=95, x="Nice, that covers the valley. See you at 8.", pl=0, sn=9, rs=-61),
],
# Diego (2-hop peer) — thread 1
1: [
dict(o=0, ago=2000, x="Did the firmware update land on your node?", pl=2, sn=2, rs=-98),
dict(o=1, ago=1900, x="Flashing now. beta looks solid so far.", ds=2, pl=2, rp=3),
dict(o=0, ago=1600, x="Great. Ping me once the repeater picks you up.", pl=2, sn=1, rs=-101),
dict(o=1, ago=1500, x="Will do 👍", ds=1, pl=2, rp=0),
],
# Marta (direct) — thread 2
2: [
dict(o=1, ago=900, x="Map tiles are caching fine on the new build.", ds=2, pl=0, rp=1),
dict(o=0, ago=700, x="Awesome — offline maps at the campsite then.", pl=0, sn=6, rs=-70),
dict(o=1, ago=360, x="Exactly. Sending you my GPS pin now.", ds=3, pl=0, rp=0),
dict(o=0, ago=320, x="Didn't come through, try again?", pl=0, sn=5, rs=-72),
],
# Harbor Room (room server channel) — thread 3, has unread
3: [
dict(o=0, ago=1200, s="Diego", x="Anyone near the north dock?", pl=1, sn=3, rs=-92),
dict(o=0, ago=1100, s="Sanne", x="I'm 300 m out, what's up?", pl=1, sn=7, rs=-68),
dict(o=1, ago=1000, s=NODE_NAME, x="Repeater on the crane looks healthy from here.", ds=2, pl=1, rp=2),
dict(o=0, ago=700, s="Femke", x="Copy. Battery at 82% on the solar node.", pl=2, sn=1, rs=-104),
dict(o=0, ago=640, s="Diego", x="Meeting at the boathouse in 10.", pl=1, sn=4, rs=-88),
],
}
# ---------------------------------------------------------------------------
# Threads list (webPushThreads: threads[] fields i,ch,u,ts,n,last)
# Derived from _MESSAGES so "last" + "ts" always match the open chat.
# u = unread count (only a couple non-zero), ch=1 for channel/room threads.
# ---------------------------------------------------------------------------
_THREAD_META = {
0: dict(ch=0, u=0, n="Sanne"),
1: dict(ch=0, u=1, n="Diego"),
2: dict(ch=0, u=0, n="Marta"),
3: dict(ch=1, u=2, n="Harbor Room"),
}
# ---------------------------------------------------------------------------
# Discovered (heard-not-added) nodes (webPushDiscovered: nodes[] i,rp,rm,pl,adv,la,lo,n)
# ---------------------------------------------------------------------------
_DISCOVERED = [
dict(n="Lars", kind="", pl=2, age=140, dlat=0.012, dlon=0.077),
dict(n="Ridge Repeater", kind="rp", pl=1, age=380, dlat=0.191, dlon=-0.02),
dict(n="Cafe Beacon", kind="", pl=3, age=900, dlat=-0.008, dlon=0.006),
dict(n="Yuki", kind="", pl=2, age=2600, dlat=0.0, dlon=0.0),
]
def _abs_coord(base, delta_deg):
"""Contact coord (1e6 fixed-point) from a degree offset; 0 stays 0 (unknown)."""
if delta_deg == 0:
return 0
return int(round(base + delta_deg * 1_000_000))
def contacts_payload(now):
out = []
for i, c in enumerate(_CONTACTS):
la = _abs_coord(SELF_LAT, c["dlat"])
lo = _abs_coord(SELF_LON, c["dlon"])
out.append({
"i": i, "t": c["t"],
"rp": 1 if c["kind"] == "rp" else 0,
"rm": 1 if c["kind"] == "rm" else 0,
"fav": c["fav"], "ign": c["ign"], "dir": c["dir"],
"la": la, "lo": lo,
"adv": now - c["age"],
"n": c["n"],
})
channels = [{"i": ch["slot"], "t": ch["t"], "n": ch["n"]} for ch in _CHANNELS]
return {"t": "c", "dc": len(_DISCOVERED), "contacts": out, "channels": channels}
def _thread_last(tidx):
msgs = _MESSAGES.get(tidx, [])
return msgs[-1] if msgs else None
def threads_payload(now):
out = []
for tidx, meta in _THREAD_META.items():
last = _thread_last(tidx)
ts = now - last["ago"] if last else 0
text = last["x"] if last else ""
out.append({
"i": tidx, "ch": meta["ch"], "u": meta["u"],
"ts": ts, "n": meta["n"], "last": text,
})
return {"t": "t", "threads": out}
def messages_payload(now, tidx):
meta = _THREAD_META.get(tidx)
msgs = _MESSAGES.get(tidx, [])
ch = meta["ch"] if meta else 0
name = meta["n"] if meta else "Chat"
# firmware emits newest-first; the browser reverses for display
arr = []
for m in reversed(msgs):
arr.append({
"o": m["o"],
"ts": now - m["ago"],
"ds": m.get("ds", 0),
"pl": m.get("pl", 0),
"sn": m.get("sn", 0),
"rs": m.get("rs", 0),
"rp": m.get("rp", 0),
"s": m.get("s", ""),
"x": m["x"],
})
return {"t": "m", "i": tidx, "ch": ch, "n": name, "msgs": arr}
def discovered_payload(now):
out = []
for i, d in enumerate(_DISCOVERED):
out.append({
"i": i,
"rp": 1 if d["kind"] == "rp" else 0,
"rm": 1 if d["kind"] == "rm" else 0,
"pl": d["pl"],
"adv": now - d["age"],
"la": _abs_coord(SELF_LAT, d["dlat"]),
"lo": _abs_coord(SELF_LON, d["dlon"]),
"n": d["n"],
})
return {"t": "dc", "nodes": out}
def contact_info_payload(now, cidx):
"""webPushContactInfo: fields i,rp,rm,fav,ign,pl,adv,la,lo,k,n."""
if cidx < 0 or cidx >= len(_CONTACTS):
return {"t": "ci", "i": cidx, "rp": 0, "rm": 0, "fav": 0, "ign": 0,
"pl": -1, "adv": 0, "la": 0, "lo": 0, "k": "000000000000", "n": "Contact"}
c = _CONTACTS[cidx]
la = _abs_coord(SELF_LAT, c["dlat"])
lo = _abs_coord(SELF_LON, c["dlon"])
# a stable pseudo pubkey-prefix (12 hex chars) so the Info card's ID looks real
k = "%02x%02x%02x%02x%02x%02x" % tuple((0x2a + cidx * 7 + j * 31) & 0xff for j in range(6))
return {
"t": "ci", "i": cidx,
"rp": 1 if c["kind"] == "rp" else 0,
"rm": 1 if c["kind"] == "rm" else 0,
"fav": c["fav"], "ign": c["ign"],
"pl": 0 if c["dir"] else (2 if c["kind"] != "rp" else 3),
"adv": now - c["age"],
"la": la, "lo": lo, "k": k, "n": c["n"],
}
def status_payload(now):
"""webPushStatus: batt,chg,wifi,rssi,ble,clk,snr,unr,mi,sla,slo,nm."""
unread = sum(m["u"] for m in _THREAD_META.values())
return {
"t": "st",
"batt": 78, "chg": 0,
"wifi": 1, "rssi": -58,
"ble": 1,
"clk": now,
"snr": 8, # good last-heard signal -> 4 bars
"unr": unread,
"mi": 0, # km, not miles
"sla": SELF_LAT, "slo": SELF_LON,
"nm": NODE_NAME,
}
def settings_payload(now):
"""webPushSettings: freq,bw,sf,cr,tx,wifi,ble,blecap,tcp,name."""
return {
"t": "sg",
"freq": RADIO["freq"], "bw": RADIO["bw"],
"sf": RADIO["sf"], "cr": RADIO["cr"], "tx": RADIO["tx"],
"wifi": 1, "ble": 1, "blecap": 1, "tcp": 1,
"name": NODE_NAME,
}
# ---------------------------------------------------------------------------
# Terminal (0x00 text) canned replies for the most common CLI commands, so the
# Terminal tab shows believable output in screenshots. Anything not listed gets
# a generic acknowledgement.
# ---------------------------------------------------------------------------
def terminal_reply(now, cmd):
import datetime
c = cmd.strip()
cl = c.lower()
clk = datetime.datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S")
if cl in ("help", "?"):
return (
"commands:\n"
" help ? this list\n"
" ver firmware version\n"
" clock RTC time\n"
" status node status\n"
" get radio params\n"
" advert send an advert\n"
" set <k> <v> name/freq/bw/sf/cr/tx\n"
" wifi|ble|tcp ... connectivity\n"
" list contacts channels\n"
" to <name> / send <text> / public <text>\n"
" reboot bootloader\n"
)
if cl == "ver":
return "wadamesh " + FIRMWARE_VERSION + " (demo)\n"
if cl == "clock":
return clk + "\n"
if cl == "status":
return ("node: %s\nbattery: 78%% (discharging)\nwifi: connected -58 dBm\n"
"ble: on tcp: on\nmesh: 4 bars contacts: %d\n"
% (NODE_NAME, len(_CONTACTS)))
if cl == "get":
return ("freq: %.3f MHz\nbw: %.0f kHz\nsf: %d\ncr: %d\ntx: %d dBm\n"
% (RADIO["freq"], RADIO["bw"], RADIO["sf"], RADIO["cr"], RADIO["tx"]))
if cl == "list" or cl == "list contacts" or cl == "contacts":
lines = ["contacts:"]
for i, c2 in enumerate(_CONTACTS):
tag = " [RPT]" if c2["kind"] == "rp" else (" [ROOM]" if c2["kind"] == "rm" else "")
lines.append(" %2d %s%s" % (i, c2["n"], tag))
return "\n".join(lines) + "\n"
if cl == "channels":
return "channels:\n" + "\n".join(" %d %s" % (c2["slot"], c2["n"]) for c2 in _CHANNELS) + "\n"
if cl == "advert" or cl == "advert.zerohop":
return "advert sent.\n"
if cl.startswith("wifi"):
return "wifi: connected ssid=meshnet -58 dBm 10.0.0.42\n"
if cl.startswith("ble"):
return "ble: advertising as \"" + NODE_NAME + "\"\n"
if cl.startswith("tcp"):
return "tcp: listening on :5000 (phone app)\n"
if cl.startswith("set "):
return "ok\n"
if cl in ("reboot", "bootloader"):
return "(demo) ignored: " + c + "\n"
return "ok\n"
# firmware version string surfaced by `ver` (kept generic; edit if you want an
# exact tag in the screenshot).
FIRMWARE_VERSION = "v1.x-touch"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Extract the wadamesh web control-panel SPA from the firmware C source.
The page is the C string literal `WS_HTML_TERMINAL_PAGE[]` in
src/helpers/esp32/WebSocketCompanionServer.cpp
built from many adjacent "..." fragments. This re-joins the fragments,
un-escapes the C escapes, strips the leading HTTP/1.1 response headers, and
writes a standalone scripts/doc/web-demo/index.html.
Re-run whenever the page changes:
python3 scripts/doc/web-demo/extract.py
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
SRC = os.path.join(REPO, "src", "helpers", "esp32", "WebSocketCompanionServer.cpp")
OUT = os.path.join(HERE, "index.html")
DECL = "static const char WS_HTML_TERMINAL_PAGE[] ="
# C escape sequences that appear in the literal. Order matters: handle the
# backslash-escapes we know about; leave any others (\uXXXX etc. don't occur here)
# untouched. We decode via a single regex pass so a literal "\\n" (escaped
# backslash then n) is NOT turned into a newline.
_C_ESCAPES = {
"\\n": "\n",
"\\r": "\r",
"\\t": "\t",
'\\"': '"',
"\\\\": "\\",
"\\'": "'",
"\\a": "\a",
"\\b": "\b",
"\\f": "\f",
"\\v": "\v",
"\\0": "\0",
}
_ESC_RE = re.compile(r"\\(?:[abfnrtv0'\"\\]|x[0-9A-Fa-f]{2})")
def _unescape(s):
def repl(m):
tok = m.group(0)
if tok.startswith("\\x"):
return chr(int(tok[2:], 16))
return _C_ESCAPES.get(tok, tok)
return _ESC_RE.sub(repl, s)
def _extract_string_fragments(block):
"""Yield the decoded content of every "..." fragment in `block`, in order.
Parses char-by-char so escaped quotes (\") inside a fragment don't end it,
and text between fragments (whitespace / newlines in the C source) is ignored.
"""
out = []
i, n = 0, len(block)
in_str = False
frag = []
while i < n:
ch = block[i]
if not in_str:
if ch == '"':
in_str = True
frag = []
elif ch == ";":
break # end of the C statement
i += 1
continue
# inside a string literal
if ch == "\\" and i + 1 < n:
frag.append(block[i:i + 2])
i += 2
continue
if ch == '"':
out.append(_unescape("".join(frag)))
in_str = False
i += 1
continue
frag.append(ch)
i += 1
return "".join(out)
def extract(src_path=SRC):
with open(src_path, "r", encoding="utf-8") as f:
text = f.read()
start = text.find(DECL)
if start < 0:
raise SystemExit("could not find %s in %s" % (DECL, src_path))
start += len(DECL)
# The statement ends at the first ';' that terminates the literal. Because
# fragments can contain ';' we cannot naively split; instead let the fragment
# parser stop at the first ';' found OUTSIDE a string.
joined = _extract_string_fragments(text[start:])
# Strip the HTTP response head: everything up to and including the blank line
# (\r\n\r\n) that precedes the HTML body.
marker = "\r\n\r\n"
idx = joined.find(marker)
if idx >= 0 and joined[:idx].startswith("HTTP/"):
joined = joined[idx + len(marker):]
else:
# fall back: chop to the doctype if the header shape ever changes
d = joined.find("<!DOCTYPE")
if d > 0:
joined = joined[d:]
return joined
def main():
html = extract()
with open(OUT, "w", encoding="utf-8") as f:
f.write(html)
print("wrote %s (%d bytes)" % (OUT, len(html)))
# sanity: the reconstructed page must have the key anchors
for needle in ("<!DOCTYPE html>", "WADAMESH", "/term", "id=w", "showTab"):
if needle not in html:
print(" WARNING: expected %r not found in output" % needle, file=sys.stderr)
if __name__ == "__main__":
main()
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html><html><head><meta charset=utf-8>
<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1'>
<title>wadamesh</title>
<style>
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#0b0b0c;color:#d7dbdd;font-family:-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px;-webkit-text-size-adjust:100%}
#w{display:flex;flex-direction:column;height:100%}
#hd{padding:8px 10px;border-bottom:1px solid #1c1c1f;display:flex;align-items:center;gap:8px}#hdl{flex:1}
#hd b{color:#19d6c2;font-weight:700;letter-spacing:2px}#st{font-size:12px}
#tabs{display:flex;border-bottom:1px solid #1c1c1f}
#tabs button{flex:1;background:none;border:none;color:#7f868c;padding:11px 4px;font:inherit;font-size:13px;border-bottom:2px solid transparent;cursor:pointer}
#tabs button.on{color:#19d6c2;border-bottom-color:#19d6c2}
.pane{flex:1;min-height:0;display:flex;flex-direction:column}
.list{flex:1;overflow-y:auto}
.row{display:flex;padding:11px 13px;border-bottom:1px solid #141416;cursor:pointer;gap:10px;align-items:center}
.row:active{background:#141416}.rmain{flex:1;min-width:0}
.rname{font-weight:600;color:#e8e8ea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rsub{font-size:12px;color:#7f868c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
.rtime{font-size:11px;color:#63696e;white-space:nowrap}
.badge{background:#19d6c2;color:#04201d;border-radius:9px;padding:0 6px;font-size:11px;margin-left:6px;font-weight:700}
.sec{padding:9px 13px 4px;font-size:11px;color:#63696e;letter-spacing:1px}
.empty{padding:26px 13px;color:#63696e;text-align:center}
#cview{flex:1;min-height:0;flex-direction:column}
#cvh{display:flex;align-items:center;gap:6px;padding:8px 8px;border-bottom:1px solid #1c1c1f}
#cvback{background:none;border:none;color:#19d6c2;font-size:24px;line-height:1;cursor:pointer;padding:0 6px}
#cvname{font-weight:600}
#cvmsgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px}
.b{max-width:80%;padding:6px 9px;border-radius:11px;word-break:break-word}
.b.in{align-self:flex-start;background:#17181b}.b.out{align-self:flex-end;background:#123f3a}
.bname{font-size:11px;color:#19d6c2;margin-bottom:2px}.btext{white-space:pre-wrap}
.bmeta{font-size:10px;color:#8a9095;text-align:right;margin-top:2px}
.cb{display:flex;border-top:1px solid #1c1c1f;padding:8px;gap:8px}
.cb input{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:18px;padding:9px 14px;font:inherit;outline:none}
.cb input:focus{border-color:#19d6c2}
.cb button{background:#19d6c2;color:#04201d;border:none;border-radius:18px;padding:0 16px;font-weight:700;cursor:pointer}
#o{flex:1;overflow-y:auto;padding:10px 12px;white-space:pre-wrap;word-break:break-word;line-height:1.4;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px}
#tbar{display:flex;border-top:1px solid #1c1c1f;padding:8px 10px;gap:8px;align-items:center}
#p{color:#19d6c2;font-weight:700}
#i{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:8px 10px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;outline:none}
#i:focus{border-color:#19d6c2}
.e{color:#7f868c}.c{color:#e8b84b}.ok{color:#19d6c2}.err{color:#ff6b6b}
#hdl{display:flex;align-items:center;gap:6px;min-width:0}#hnm{color:#9aa0a6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#dot{font-size:11px;color:#63696e}#hdr{display:flex;align-items:center;gap:8px;font-size:12px;color:#9aa0a6;white-space:nowrap}
#hdr svg{vertical-align:middle;display:block}#hdr>span{display:inline-flex;align-items:center}.bpct{font-size:11px;margin-left:3px;color:#9aa0a6}
.bars{letter-spacing:-1px;color:#19d6c2}.bars i{color:#3a3f44;font-style:normal}
.bgrn{color:#19d6c2}.byel{color:#e8b84b}.bred{color:#ff6b6b}
#ctools{display:flex;gap:6px;padding:7px 8px;border-bottom:1px solid #1c1c1f}
#csearch{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:14px;padding:6px 12px;font:inherit;font-size:13px;outline:none;min-width:0}
#ctools button{background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:14px;padding:0 10px;font:inherit;font-size:12px;white-space:nowrap;cursor:pointer}
.tag{font-size:10px;padding:1px 5px;border-radius:6px;margin-left:6px;vertical-align:middle}.trep{background:#243b52;color:#7fc0ff}.troom{background:#3b2a52;color:#c69cff}.tfav{color:#e8b84b}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:19;display:none}#scrim.on{display:block}
#sheet{position:fixed;left:0;right:0;bottom:0;background:#151619;border-top:1px solid #2a2a2e;border-radius:14px 14px 0 0;transform:translateY(101%);transition:transform .15s;z-index:20;padding:6px 0 max(10px,env(safe-area-inset-bottom));max-height:74vh;overflow-y:auto}
#qcmd{background:#17181b;color:#19d6c2;border:1px solid #2a2a2e;border-radius:6px;padding:0 11px;font-size:16px;line-height:34px;cursor:pointer}
#sheet.on{transform:translateY(0)}#sheet h4{margin:8px 16px 4px;font-size:13px;color:#9aa0a6}
.si{display:block;width:100%;text-align:left;background:none;border:none;color:#d7dbdd;padding:13px 16px;font:inherit;font-size:15px;cursor:pointer;border-top:1px solid #202225}.si:active{background:#1d1f22}.si.dng{color:#ff6b6b}
#modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:21;padding:20px}#modal.on{display:flex}
#mbox{background:#151619;border:1px solid #2a2a2e;border-radius:12px;max-width:340px;width:100%;padding:16px;max-height:82%;overflow:auto}#mbox h3{margin:0 0 10px;color:#19d6c2;font-size:15px;word-break:break-word}
#mbox .kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #202225}#mbox .kv b{color:#9aa0a6;font-weight:400}#mbox .kv span{text-align:right;word-break:break-word}
#mclose{margin-top:12px;width:100%;background:#19d6c2;color:#04201d;border:none;border-radius:8px;padding:10px;font-weight:700;cursor:pointer}
#gear{background:none;border:none;color:#9aa0a6;font-size:17px;cursor:pointer;padding:0 2px;line-height:1}
.sset label{display:block;font-size:11px;color:#63696e;letter-spacing:1px;margin:12px 0 4px}
.sset input[type=text],.sset input[type=number]{background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:7px 9px;font:inherit;outline:none;width:100%}
.srow{display:flex;gap:8px;align-items:center;margin:4px 0}.srow input{flex:1;min-width:0}
.sset .sbtn{background:#19d6c2;color:#04201d;border:none;border-radius:6px;padding:0 12px;font-weight:700;line-height:32px;cursor:pointer;white-space:nowrap}
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.sgrid .f label{margin:2px 0}.sgrid .f{min-width:0}
.stog{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #202225}.stog b{font-weight:400;color:#d7dbdd}
.sw{position:relative;width:40px;height:22px;background:#2a2a2e;border-radius:11px;cursor:pointer;transition:background .15s;flex:none}.sw.on{background:#19d6c2}.sw i{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#e8e8ea;transition:left .15s}.sw.on i{left:20px}
.srow2{display:flex;gap:8px;margin-top:14px}.srow2 button{flex:1;background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:8px;padding:10px;font:inherit;cursor:pointer}.srow2 button.dng{color:#ff6b6b;border-color:#5a2a2a}
#toast{position:fixed;left:8px;right:8px;top:8px;background:#17342f;border:1px solid #19d6c2;border-radius:10px;padding:9px 12px;z-index:30;box-shadow:0 4px 16px rgba(0,0,0,.45);transform:translateY(-150%);transition:transform .2s;cursor:pointer}
#toast.on{transform:translateY(0)}#toast b{color:#19d6c2;display:block;font-size:13px;margin-bottom:1px}
#toast span{font-size:12px;color:#cfeee9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
</style></head><body>
<div id=w>
<div id=hd><div id=hdl><span id=dot>&bull;</span><b>WADAMESH</b><span id=hnm></span></div>
<div id=hdr><span id=hsig></span><span id=hwifi></span><span id=hble></span><span id=hbatt></span><span id=hclk></span></div><button id=gear title=Settings>&#9881;</button></div>
<div id=tabs><button data-t=chats class=on>Chats</button><button data-t=contacts>Contacts</button><button data-t=term>Terminal</button></div>
<div id=pchats class=pane>
<div id=tlist class=list></div>
<div id=cview style=display:none>
<div id=cvh><button id=cvback>&lsaquo;</button><span id=cvname></span></div>
<div id=cvmsgs></div>
<div class=cb><input id=cvi placeholder='Message' autocomplete=off><button id=cvsend>Send</button></div>
</div>
</div>
<div id=pcontacts class=pane style=display:none>
<div id=ctools><input id=csearch placeholder='Search' autocomplete=off autocapitalize=off spellcheck=false><button id=csort>Sort</button><button id=cfilt>All</button></div>
<div id=clist class=list></div>
</div>
<div id=pterm class=pane style=display:none>
<div id=o></div>
<div id=tbar><span id=p>&gt;</span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>&#9889;</button></div>
</div>
</div>
<div id=scrim></div>
<div id=sheet></div>
<div id=modal><div id=mbox></div></div>
<div id=toast></div>
<script>
var WS,dec=new TextDecoder(),curTab='chats',chat=null,threads=[],contacts=[],channels=[];
var csort=0,cfilter=0,csearchq='',self={la:0,lo:0},lpAt=0,useMi=0,discCount=0;
var SORTL=['Recent','A-Z','Distance'],FILTL=['All','Repeaters','Peers','Favorites','Located','Direct'];
function age(ts){if(!ts||ts<1000000000)return'';var s=Math.floor(Date.now()/1000)-ts;if(s<0)s=0;if(s<60)return'now';if(s<3600)return Math.floor(s/60)+'m';if(s<86400)return Math.floor(s/3600)+'h';if(s<604800)return Math.floor(s/86400)+'d';return Math.floor(s/604800)+'w'}
function fmtDist(km){if(km>=1e17)return'';if(useMi){var mi=km*0.621371;return (mi<10?mi.toFixed(1):Math.round(mi))+' mi'}return (km<10?km.toFixed(1):Math.round(km))+' km'}
function E(i){return document.getElementById(i)}
function each(l,f){Array.prototype.forEach.call(l,f)}
function esc(s){return (s==null?'':''+s).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function fmt(t){if(!t||t<1000000000)return'';var d=new Date(t*1000);return('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
function fmtDate(t){return (t>1000000000)?new Date(t*1000).toLocaleString():'--'}
function stat(t,k){var el=E('dot');el.style.color=(k=='ok')?'#19d6c2':((k=='err')?'#ff6b6b':'#63696e');el.title=t}
function wsSend(s){if(WS&&WS.readyState==1){WS.send(s);return true}return false}
function cById(i){for(var k=0;k<contacts.length;k++)if(contacts[k].i==i)return contacts[k];return null}
function showTab(t){curTab=t;['chats','contacts','term'].forEach(function(x){E('p'+x).style.display=(x==t)?'':'none'});
each(document.querySelectorAll('#tabs button'),function(b){b.className=(b.getAttribute('data-t')==t)?'on':''});
if(t=='chats'){if(!chat){closeChat();wsSend('@t')}}else if(t=='contacts'){wsSend('@c')}else if(t=='term'){E('i').focus()}}
function svgSig(l){var b='';for(var i=0;i<4;i++){var h=3+i*3;b+='<rect x='+(i*4)+' y='+(12-h)+' width=3 height='+h+' rx=1 fill='+(i<l?'#19d6c2':'#3a3f44')+' />'}return '<svg width=15 height=12 viewBox="0 0 15 12">'+b+'</svg>'}
function svgWifi(on){var c=on?'#19d6c2':'#3a3f44';return '<svg width=16 height=12 viewBox="0 0 16 12" fill=none stroke='+c+' stroke-width=1.5 stroke-linecap=round><path d="M2.2 4.6a8 8 0 0 1 11.6 0" /><path d="M4.6 7.1a4.6 4.6 0 0 1 6.8 0" /><circle cx=8 cy=9.6 r=0.9 fill='+c+' stroke=none /></svg>'}
function svgBt(){return '<svg width=10 height=14 viewBox="0 0 10 14" fill=none stroke=#7fb0ff stroke-width=1.3 stroke-linejoin=round stroke-linecap=round><path d="M2 4l6 6-3 2.5V1.5l3 2.5-6 6" /></svg>'}
function svgBatt(p,chg){var col=p<=20?'#ff6b6b':(p<=50?'#e8b84b':'#19d6c2');var w=Math.max(1,Math.round(p/100*16));var bolt=chg?'<path d="M12 3l-3 4h2l-1 3 3-4h-2z" fill=#e8b84b />':'';return '<svg width=27 height=13 viewBox="0 0 27 13"><rect x=0.5 y=2 width=21 height=9 rx=2 fill=none stroke=#8a9095 /><rect x=22 y=4.5 width=2.2 height=4 rx=1 fill=#8a9095 /><rect x=2 y=3.5 width='+w+' height=6 rx=1 fill='+col+' />'+bolt+'</svg>'}
function renderStatus(d){E('hnm').textContent=d.nm||'';self.la=d.sla||0;self.lo=d.slo||0;useMi=d.mi?1:0;
var lvl=(d.snr<=-127)?0:(d.snr>=5?4:(d.snr>=0?3:(d.snr>=-7?2:1)));
E('hsig').innerHTML=svgSig(lvl);E('hwifi').innerHTML=d.wifi?svgWifi(1):'';E('hble').innerHTML=d.ble?svgBt():'';
E('hbatt').innerHTML=(d.batt<0)?'':(svgBatt(d.batt,d.chg)+'<span class=bpct>'+d.batt+'%</span>');
E('hclk').textContent=fmt(d.clk);
var cb=document.querySelector('#tabs [data-t=chats]');if(cb)cb.textContent=d.unr>0?('Chats ('+d.unr+')'):'Chats'}
function closeOv(){E('sheet').classList.remove('on');E('modal').classList.remove('on');E('scrim').classList.remove('on')}
function sheet(title,items){var h='<h4>'+esc(title)+'</h4>';items.forEach(function(it,i){h+='<button class="si '+(it.cls||'')+'" data-i='+i+'>'+esc(it.label)+'</button>'});
E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var it=items[+b.getAttribute('data-i')];closeOv();if(it.fn)it.fn()}});
E('sheet').classList.add('on');E('scrim').classList.add('on')}
function modal(title,html){E('mbox').innerHTML='<h3>'+esc(title)+'</h3>'+html+'<button id=mclose>Close</button>';E('modal').classList.add('on');E('scrim').classList.add('on');E('mclose').onclick=closeOv}
function copy(t){try{navigator.clipboard.writeText(t)}catch(_){var a=document.createElement('textarea');a.value=t;document.body.appendChild(a);a.select();try{document.execCommand('copy')}catch(e){}a.remove()}}
function longPress(el,fn){var tmr=null;function s(){tmr=setTimeout(function(){tmr=null;lpAt=Date.now();fn()},480)}function c(){if(tmr){clearTimeout(tmr);tmr=null}}
el.addEventListener('touchstart',s,{passive:true});el.addEventListener('touchmove',c,{passive:true});el.addEventListener('touchend',c);
el.addEventListener('mousedown',s);el.addEventListener('mouseup',c);el.addEventListener('mouseleave',c);el.addEventListener('contextmenu',function(e){e.preventDefault();lpAt=Date.now();fn()})}
function clicked(fn){return function(){if(Date.now()-lpAt<650)return;fn()}}
function contactSheet(c){sheet(c.n||'Contact',[
{label:'Message',fn:function(){if(c.t>=0)wsSend('@m '+c.t);else wsSend('@oc '+c.i)}},
{label:'Info',fn:function(){wsSend('@ic '+c.i)}},
{label:c.fav?'Unfavorite':'Favorite',fn:function(){wsSend('@fv '+c.i)}},
{label:'Reset path',fn:function(){wsSend('@pr '+c.i)}},
{label:c.ign?'Unblock':'Block',fn:function(){wsSend('@bk '+c.i)}},
{label:'Delete',cls:'dng',fn:function(){if(confirm('Delete '+(c.n||'contact')+'?'))wsSend('@xc '+c.i)}}])}
function chatSheet(t){sheet(t.n||'Chat',[
{label:'Mark as read',fn:function(){wsSend('@r '+t.i)}},
{label:'Clear history',fn:function(){if(confirm('Clear history?'))wsSend('@d '+t.i)}},
{label:'Delete chat',cls:'dng',fn:function(){if(confirm('Delete this chat?'))wsSend('@xt '+t.i)}}])}
function msgSheet(m){sheet('Message',[{label:'Copy',fn:function(){copy(m.x)}},{label:'Info',fn:function(){msgInfo(m)}}])}
function msgInfo(m){var pl=(m.pl==255)?'Direct':(m.pl+' hop'+(m.pl==1?'':'s'));
var h='<div class=kv><b>Direction</b><span>'+(m.o?'Sent':'Received')+'</span></div><div class=kv><b>Time</b><span>'+(fmt(m.ts)||'--')+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div>';
if(!m.o)h+='<div class=kv><b>SNR</b><span>'+m.sn+' dB</span></div><div class=kv><b>RSSI</b><span>'+m.rs+' dBm</span></div>';
if(m.o)h+='<div class=kv><b>Delivery</b><span>'+(({0:'--',1:'Sent',2:'Delivered',3:'Failed'})[m.ds]||'--')+'</span></div>';
if(m.o&&m.rp>0)h+='<div class=kv><b>Repeats heard</b><span>'+m.rp+'</span></div>';
modal('Message info',h)}
function showContactInfo(d){var type=d.rp?'Repeater':(d.rm?'Room server':'Contact');var pl=(d.pl<0)?'Unknown':(d.pl==0?'Direct':(d.pl+' hop'+(d.pl==1?'':'s')));
var h='<div class=kv><b>Type</b><span>'+type+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div><div class=kv><b>Favorite</b><span>'+(d.fav?'Yes':'No')+'</span></div><div class=kv><b>Blocked</b><span>'+(d.ign?'Yes':'No')+'</span></div>';
if(d.adv>1000000000)h+='<div class=kv><b>Last heard</b><span>'+fmtDate(d.adv)+'</span></div>';
if(d.la||d.lo){h+='<div class=kv><b>Location</b><span>'+(d.la/1e6).toFixed(5)+', '+(d.lo/1e6).toFixed(5)+'</span></div>';var ds=fmtDist(cdist(d));if(ds)h+='<div class=kv><b>Distance</b><span>'+ds+'</span></div>'}
h+='<div class=kv><b>ID</b><span>'+esc(d.k)+'</span></div>';modal(d.n||'Contact',h)}
var prevUnread={},toastTmr=null,AC=null;
function onThreads(nt){
nt.forEach(function(t){var pu=prevUnread[t.i];if(pu!=null&&t.u>pu&&!(chat&&chat.i==t.i))notify(t.i,(t.ch?'# ':'')+t.n,t.last||'New message')});
prevUnread={};nt.forEach(function(t){prevUnread[t.i]=t.u});threads=nt;renderThreads()}
function notify(ti,title,body){showToast(ti,title,body);
try{if('Notification' in window&&Notification.permission=='granted'&&document.hidden)new Notification(title,{body:body})}catch(e){}
try{beep()}catch(e){}}
function showToast(ti,title,body){var t=E('toast');t.innerHTML='<b></b><span></span>';t.querySelector('b').textContent=title;t.querySelector('span').textContent=body;
t.onclick=function(){t.classList.remove('on');wsSend('@m '+ti)};t.classList.add('on');
if(toastTmr)clearTimeout(toastTmr);toastTmr=setTimeout(function(){t.classList.remove('on')},4200)}
function beep(){AC=AC||new (window.AudioContext||window.webkitAudioContext)();var o=AC.createOscillator(),g=AC.createGain();o.connect(g);g.connect(AC.destination);o.frequency.value=880;g.gain.value=0.05;o.start();g.gain.exponentialRampToValueAtTime(0.0001,AC.currentTime+0.18);o.stop(AC.currentTime+0.2)}
document.addEventListener('click',function(){try{if('Notification' in window&&Notification.permission=='default')Notification.requestPermission()}catch(e){}},{once:true});
var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to <name>',c:'to '},{l:'send <text>',c:'send '},{l:'public <text>',c:'public '},{l:'exit',c:'exit'},
{h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},
{h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name <v>',c:'set name '},{l:'set freq <MHz>',c:'set freq '},{l:'set bw <kHz>',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx <dBm>',c:'set tx '},
{h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},
{h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];
function cmdPicker(){var h='<h4>Commands</h4>';QCMDS.forEach(function(x,i){if(x.h)h+='<div class=sec>'+esc(x.h)+'</div>';else h+='<button class=si data-i='+i+'>'+esc(x.l)+'</button>'});
E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});
E('sheet').classList.add('on');E('scrim').classList.add('on')}
var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];
function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}
function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}
tadd('\n> '+v+'\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\n','err')}
function onData(s){var d;try{d=JSON.parse(s)}catch(_){return}
if(d.t=='t'){onThreads(d.threads||[])}
else if(d.t=='c'){contacts=d.contacts||[];channels=d.channels||[];discCount=d.dc||0;renderContacts()}
else if(d.t=='m'){openChatView(d)}
else if(d.t=='st'){renderStatus(d)}
else if(d.t=='ci'){showContactInfo(d)}
else if(d.t=='dc'){showDiscovered(d.nodes||[])}
else if(d.t=='sg'){showSettings(d)}
else if(d.t=='rx'){wsSend('@t');if(chat&&chat.i>=0)wsSend('@m '+chat.i);if(curTab=='contacts')wsSend('@c')}}
function renderThreads(){var h='';if(!threads.length)h='<div class=empty>No chats yet.<br>Start one from Contacts.</div>';
threads.sort(function(a,b){return (b.ts||0)-(a.ts||0)});
threads.forEach(function(t){var bd=t.u>0?'<span class=badge>'+t.u+'</span>':'';
h+='<div class=row data-ti='+t.i+'><div class=rmain><div class=rname>'+(t.ch?'# ':'')+esc(t.n)+bd+'</div><div class=rsub>'+esc(t.last||'')+'</div></div><div class=rtime>'+fmt(t.ts)+'</div></div>'});
E('tlist').innerHTML=h;each(E('tlist').querySelectorAll('.row'),function(r){var ti=+r.getAttribute('data-ti');
r.onclick=clicked(function(){wsSend('@m '+ti)});var t=null;for(var k=0;k<threads.length;k++)if(threads[k].i==ti)t=threads[k];longPress(r,function(){if(t)chatSheet(t)})})}
function cdist(c){if(!(c.la||c.lo)||!(self.la||self.lo))return 1e18;var R=6371,rad=Math.PI/180,dLa=(c.la-self.la)/1e6*rad,dLo=(c.lo-self.lo)/1e6*rad,l1=self.la/1e6*rad,l2=c.la/1e6*rad;
var a=Math.sin(dLa/2)*Math.sin(dLa/2)+Math.cos(l1)*Math.cos(l2)*Math.sin(dLo/2)*Math.sin(dLo/2);return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}
function cVis(c){if(csearchq&&(c.n||'').toLowerCase().indexOf(csearchq)<0)return false;
if(cfilter==1)return c.rp;if(cfilter==2)return !c.rp;if(cfilter==3)return c.fav;if(cfilter==4)return !!(c.la||c.lo);if(cfilter==5)return c.dir;return true}
function renderContacts(){E('csort').textContent='Sort: '+SORTL[csort];E('cfilt').textContent=FILTL[cfilter];var h='';
if(cfilter==0&&!csearchq){h+='<div class=row id=discrow><div class=rmain><div class=rname>Discovered nodes'+(discCount?' <span class=badge>'+discCount+'</span>':'')+'</div><div class=rsub>heard nearby - tap to add</div></div><div class=rtime>\u203a</div></div>'}
if(cfilter==0&&!csearchq&&channels.length){h+='<div class=sec>CHANNELS</div>';channels.forEach(function(c){h+='<div class=row data-h='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname># '+esc(c.n)+'</div></div></div>'})}
var cs=contacts.filter(cVis);cs.sort(function(a,b){if(csort==1)return (a.n||'').localeCompare(b.n||'');if(csort==2)return cdist(a)-cdist(b);return (b.adv||0)-(a.adv||0)});
h+='<div class=sec>CONTACTS'+(cs.length?' ('+cs.length+')':'')+'</div>';if(!cs.length)h+='<div class=empty>No contacts.</div>';
cs.forEach(function(c){var tag=c.rp?'<span class="tag trep">RPT</span>':(c.rm?'<span class="tag troom">ROOM</span>':'');var fav=c.fav?'<span class="tag tfav">\u2605</span>':'';var blk=c.ign?'<span class=tag style=color:#ff6b6b>\u2298</span>':'';
var sub=[fmtDist(cdist(c)),age(c.adv)].filter(Boolean).join(' \u00b7 ');if(c.dir)sub=sub?(sub+' \u00b7 direct'):'direct';
h+='<div class=row data-c='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname>'+esc(c.n)+tag+fav+blk+'</div>'+(sub?'<div class=rsub>'+sub+'</div>':'')+'</div></div>'});
E('clist').innerHTML=h;var dr=E('discrow');if(dr)dr.onclick=function(){wsSend('@dc')};
each(E('clist').querySelectorAll('.row'),function(r){
if(r.hasAttribute('data-c')){var ci=+r.getAttribute('data-c');var open=function(){var c=cById(ci);if(c)contactSheet(c)};r.onclick=clicked(open);longPress(r,open)}
else if(r.hasAttribute('data-h')){r.onclick=clicked(function(){var t=+r.getAttribute('data-t');if(t>=0)wsSend('@m '+t);else wsSend('@oh '+r.getAttribute('data-h'))})}})}
function showDiscovered(nodes){if(!nodes.length){sheet('Discovered nodes',[{label:'No new nodes heard yet'}]);return}
var items=nodes.map(function(nd){var tag=nd.rp?' [RPT]':(nd.rm?' [ROOM]':'');var meta=[fmtDist(cdist(nd)),age(nd.adv)].filter(Boolean).join(' \u00b7 ');
return {label:(nd.n||'(unnamed)')+tag+(meta?' ('+meta+')':''),fn:function(){wsSend('@ad '+nd.i);showToast(-1,'Added contact',nd.n||'')}}});
sheet('Discovered - tap to add',items)}
function showSettings(d){var g=E;
var h='<div class=sset><label>NODE NAME</label><div class=srow><input type=text id=sfname><button class=sbtn id=sbname>Save</button></div>';
h+='<label>RADIO (applies live)</label><div class=sgrid>';
h+='<div class=f><label>Freq MHz</label><input type=number step=0.001 id=sffreq></div><div class=f><label>BW kHz</label><input type=number id=sfbw></div>';
h+='<div class=f><label>SF</label><input type=number id=sfsf></div><div class=f><label>CR</label><input type=number id=sfcr></div>';
h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label>&nbsp;</label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';
h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';
if(d.blecap)h+='<div class=stog><b>Bluetooth</b><div class=sw id=sble><i></i></div></div>';
h+='<div class=stog><b>TCP (phone app)</b><div class=sw id=stcp><i></i></div></div>';
h+='<div class=srow2><button id=sadvert>Send advert</button><button id=sreboot class=dng>Reboot</button></div></div>';
E('mbox').innerHTML='<h3>Settings</h3>'+h+'<button id=mclose>Close</button>';
g('sfname').value=d.name||'';g('sffreq').value=d.freq;g('sfbw').value=d.bw;g('sfsf').value=d.sf;g('sfcr').value=d.cr;g('sftx').value=d.tx;
function tg(id,on){var el=g(id);if(el){on?el.classList.add('on'):el.classList.remove('on')}}
tg('swifi',d.wifi);tg('sble',d.ble);tg('stcp',d.tcp);
g('sbname').onclick=function(){var v=g('sfname').value.trim();if(v){wsSend('set name '+v);showToast(-1,'Name saved',v)}};
g('sbradio').onclick=function(){wsSend('@sr '+(+g('sffreq').value)+' '+(+g('sfbw').value)+' '+(+g('sfsf').value)+' '+(+g('sfcr').value)+' '+(+g('sftx').value));showToast(-1,'Radio applied','')};
g('swifi').onclick=function(){var on=!g('swifi').classList.contains('on');tg('swifi',on);wsSend('wifi '+(on?'on':'off'))};
if(g('sble'))g('sble').onclick=function(){var on=!g('sble').classList.contains('on');tg('sble',on);wsSend('ble '+(on?'on':'off'))};
g('stcp').onclick=function(){var on=!g('stcp').classList.contains('on');tg('stcp',on);wsSend('tcp '+(on?'on':'off'))};
g('sadvert').onclick=function(){wsSend('advert');showToast(-1,'Advert sent','')};
g('sreboot').onclick=function(){if(confirm('Reboot the device now?'))wsSend('reboot')};
g('mclose').onclick=closeOv;E('modal').classList.add('on');E('scrim').classList.add('on')}
function openChatView(d){var box=E('cvmsgs');var wasBottom=(box.scrollHeight-box.scrollTop-box.clientHeight)<44;var same=chat&&d.i!=null&&d.i>=0&&chat.i==d.i;
chat={i:(d.i!=null?d.i:-1),ch:!!d.ch,n:d.n||'',c:(d.c!=null?d.c:null),h:(d.h!=null?d.h:null)};
showTab('chats');E('tlist').style.display='none';E('cview').style.display='flex';E('cvname').textContent=(chat.ch?'# ':'')+chat.n;
var arr=(d.msgs||[]).slice().reverse();var h='';arr.forEach(function(m,i){var cls=m.o?'b out':'b in';var meta=fmt(m.ts);
if(m.o){meta+=' '+(({1:'\u2713',2:'\u2713\u2713',3:'\u2717'})[m.ds]||'');if(m.rp>0)meta+=' \u21bb'+m.rp}
var nm=(!m.o&&chat.ch)?'<div class=bname>'+esc(m.s)+'</div>':'';
h+='<div class="'+cls+'" data-mi='+i+'>'+nm+'<div class=btext>'+esc(m.x)+'</div><div class=bmeta>'+meta+'</div></div>'});
E('cvmsgs').innerHTML=h;if(!same||wasBottom)E('cvmsgs').scrollTop=E('cvmsgs').scrollHeight;
each(E('cvmsgs').querySelectorAll('.b'),function(b){var m=arr[+b.getAttribute('data-mi')];longPress(b,function(){if(m)msgSheet(m)})})}
function closeChat(){chat=null;E('cview').style.display='none';E('tlist').style.display=''}
function chatSend(){var v=E('cvi').value.trim();if(!v||!chat)return;E('cvi').value='';
if(chat.i>=0)wsSend('@s '+chat.i+' '+v);else if(chat.c!=null)wsSend('@sc '+chat.c+' '+v);else if(chat.h!=null)wsSend('@sh '+chat.h+' '+v)}
function conn(){WS=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/term');WS.binaryType='arraybuffer';
WS.onopen=function(){stat('online','ok');wsSend('@st');if(curTab=='chats')wsSend('@t')};
WS.onclose=function(){stat('reconnecting...','err');setTimeout(conn,1500)};WS.onerror=function(){stat('error','err')};
WS.onmessage=function(e){var b=new Uint8Array(e.data);if(!b.length)return;var ty=b[0],p=dec.decode(b.subarray(1));if(ty==0)tadd(p);else if(ty==1)onData(p)}}
each(document.querySelectorAll('#tabs button'),function(b){b.onclick=function(){showTab(b.getAttribute('data-t'))}});
E('scrim').onclick=closeOv;E('modal').onclick=function(e){if(e.target==E('modal'))closeOv()};E('qcmd').onclick=cmdPicker;E('gear').onclick=function(){wsSend('@sg')};
E('csort').onclick=function(){sheet('Sort by',SORTL.map(function(s,i){return {label:(i==csort?'\u2713 ':'')+s,fn:function(){csort=i;renderContacts()}}}))};
E('cfilt').onclick=function(){sheet('Filter',FILTL.map(function(f,i){return {label:(i==cfilter?'\u2713 ':'')+f,fn:function(){cfilter=i;renderContacts()}}}))};
E('csearch').addEventListener('input',function(){csearchq=E('csearch').value.trim().toLowerCase();renderContacts()});
E('cvback').onclick=function(){closeChat();wsSend('@t')};E('cvsend').onclick=chatSend;
E('cvi').addEventListener('keydown',function(e){if(e.key=='Enter'){chatSend();e.preventDefault()}});
E('i').addEventListener('keydown',function(e){if(e.key=='Enter'){tsend();e.preventDefault()}
else if(e.key=='ArrowUp'){if(hi>0){hi--;E('i').value=hist[hi];e.preventDefault()}}
else if(e.key=='ArrowDown'){if(hi<hist.length-1){hi++;E('i').value=hist[hi]}else{hi=hist.length;E('i').value=''}e.preventDefault()}
else if(e.key=='Tab'){var q=E('i').value.toLowerCase(),m=CMDS.filter(function(c){return c.indexOf(q)==0});
if(m.length==1)E('i').value=m[0];else if(m.length>1)tadd('\n'+m.join(' ').trim()+'\n','e');e.preventDefault()}});
tadd('wadamesh terminal - node config + messaging. help = all commands.\n\n','e');
conn();showTab('chats');
</script></body></html>
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
Offline demo server for the wadamesh web control panel.
Serves the extracted index.html at GET / and speaks the device's /term
WebSocket protocol with hard-coded demo data, so the real SPA renders exactly
as it does on the device — just with clean documentation data. No firmware, no
device, no third-party packages (WebSocket handshake + framing done by hand).
python3 scripts/doc/web-demo/serve.py [port] # default 8791
Then open http://127.0.0.1:<port>/ and screenshot. Runs in the foreground.
Protocol (mirrors WebSocketCompanionServer.cpp + handleWebDataCmd in UITask.cpp):
* Browser opens ws://host/term, sends short text commands.
* '@'-prefixed commands are the data API; the reply is ONE binary frame whose
payload is 0x01 followed by JSON.
* Any other command is a terminal CLI line; the reply is a binary frame whose
payload is 0x00 followed by text.
"""
import base64
import hashlib
import json
import os
import re
import socket
import struct
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import demo # noqa: E402 (local module)
INDEX = os.path.join(HERE, "index.html")
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 magic
# --------------------------------------------------------------------------- #
# Command -> demo payload dispatch. Mirrors handleWebDataCmd() exactly.
# Returns (kind, data): kind 'json' -> data is a dict (framed 0x01),
# kind 'text' -> data is a str (framed 0x00),
# kind None -> no reply (matches the firmware swallowing
# mutation @-commands that only refresh).
# --------------------------------------------------------------------------- #
def dispatch(cmd):
now = int(time.time())
if not cmd:
return (None, None)
# Terminal CLI line (not '@') -> text reply.
if cmd[0] != "@":
return ("text", demo.terminal_reply(now, cmd))
a = cmd[1:]
def is_word(prefix):
# matches handleWebDataCmd's "a[0]==.. && (next==0 || ' ')" tests
return a == prefix or a.startswith(prefix + " ")
def int_arg(after):
# atoi of the tail starting at index `after`
m = re.match(r"\s*(-?\d+)", a[after:])
return int(m.group(1)) if m else 0
if is_word("c"):
return ("json", demo.contacts_payload(now))
if is_word("t"):
return ("json", demo.threads_payload(now))
if is_word("dc"):
return ("json", demo.discovered_payload(now))
if is_word("sg"):
return ("json", demo.settings_payload(now))
if is_word("st"):
return ("json", demo.status_payload(now))
if a.startswith("m "):
return ("json", demo.messages_payload(now, int_arg(2)))
if a.startswith("oc "):
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
if a.startswith("oh "):
# open channel by slot: demo channels have no thread -> empty chat keyed to slot
slot = int_arg(3)
return ("json", _empty_channel_chat(slot))
if a.startswith("ic "):
return ("json", demo.contact_info_payload(now, int_arg(3)))
if a.startswith("sr "):
# apply radio -> echo settings back (firmware calls webPushSettings)
return ("json", demo.settings_payload(now))
# Send / mutate commands: the firmware pushes a refresh (messages/threads/
# contacts). For a static demo we re-emit the relevant view so the UI stays
# coherent if a screenshot script drives a send, but the canned data itself
# doesn't change.
if a.startswith("s "):
return ("json", demo.messages_payload(now, int_arg(2)))
if a.startswith("sc "):
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
if a.startswith("sh "):
return (None, None)
if a.startswith("ad "):
return ("json", demo.discovered_payload(now)) # refresh discovered
if a[:3] in ("fv ", "bk ", "pr ", "xc "):
return ("json", demo.contacts_payload(now)) # refresh contacts
if a.startswith("xt ") or a.startswith("d ") or a.startswith("r "):
return ("json", demo.threads_payload(now)) # refresh threads
# unknown '@' -> swallow (firmware returns true without replying)
return (None, None)
def _thread_for_contact(cidx):
# map a contact index to its thread index using demo data (or -1)
try:
return demo._CONTACTS[cidx]["t"]
except (IndexError, KeyError):
return -1
def _empty_channel_chat(slot):
name = "Channel"
for ch in demo._CHANNELS:
if ch["slot"] == slot:
name = ch["n"]
break
return {"t": "m", "i": -1, "h": slot, "ch": 1, "n": name, "msgs": []}
# --------------------------------------------------------------------------- #
# Minimal WebSocket framing (server -> client is never masked; client -> server
# is always masked per RFC 6455).
# --------------------------------------------------------------------------- #
def ws_encode(payload_bytes, opcode=0x2):
"""Build a single unmasked frame. opcode 0x2 = binary (what the SPA expects)."""
fin_op = 0x80 | opcode
n = len(payload_bytes)
if n < 126:
header = struct.pack("!BB", fin_op, n)
elif n < 65536:
header = struct.pack("!BBH", fin_op, 126, n)
else:
header = struct.pack("!BBQ", fin_op, 127, n)
return header + payload_bytes
def _recv_exact(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def ws_read_frame(sock):
"""Read one client frame. Returns (opcode, payload_bytes) or (None, None) on close."""
hdr = _recv_exact(sock, 2)
if not hdr:
return (None, None)
b0, b1 = hdr[0], hdr[1]
opcode = b0 & 0x0f
masked = b1 & 0x80
length = b1 & 0x7f
if length == 126:
ext = _recv_exact(sock, 2)
if ext is None:
return (None, None)
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = _recv_exact(sock, 8)
if ext is None:
return (None, None)
length = struct.unpack("!Q", ext)[0]
mask = b"\x00\x00\x00\x00"
if masked:
mask = _recv_exact(sock, 4)
if mask is None:
return (None, None)
payload = _recv_exact(sock, length) if length else b""
if payload is None:
return (None, None)
if masked:
payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
return (opcode, payload)
def frame_reply(kind, data):
if kind == "json":
return ws_encode(b"\x01" + json.dumps(data, ensure_ascii=False).encode("utf-8"))
if kind == "text":
return ws_encode(b"\x00" + data.encode("utf-8"))
return None
# --------------------------------------------------------------------------- #
# HTTP / WebSocket handling on a raw socket.
# --------------------------------------------------------------------------- #
def read_http_request(sock):
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
return None, None, None
data += chunk
if len(data) > 65536:
break
head, _, _rest = data.partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
if not lines:
return None, None, None
request_line = lines[0]
parts = request_line.split(" ")
method = parts[0] if parts else ""
path = parts[1] if len(parts) > 1 else "/"
headers = {}
for ln in lines[1:]:
if ":" in ln:
k, v = ln.split(":", 1)
headers[k.strip().lower()] = v.strip()
return method, path, headers
def serve_http_index(sock):
with open(INDEX, "rb") as f:
body = f.read()
resp = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/html; charset=utf-8\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n"
b"Cache-Control: no-store\r\n"
b"\r\n" + body
)
sock.sendall(resp)
def serve_http_404(sock):
body = b"not found"
sock.sendall(
b"HTTP/1.1 404 Not Found\r\nContent-Length: " + str(len(body)).encode()
+ b"\r\nConnection: close\r\n\r\n" + body
)
def do_ws_handshake(sock, headers):
key = headers.get("sec-websocket-key")
if not key:
serve_http_404(sock)
return False
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
resp = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: " + accept + "\r\n"
"\r\n"
)
sock.sendall(resp.encode())
return True
def handle_ws(sock):
# Periodic status pushes, like the device's 3 s refresh, on a background thread.
alive = {"v": True}
lock = threading.Lock()
def push(frame):
if frame is None:
return
with lock:
try:
sock.sendall(frame)
except OSError:
alive["v"] = False
def status_loop():
while alive["v"]:
time.sleep(3.0)
if not alive["v"]:
break
push(frame_reply("json", demo.status_payload(int(time.time()))))
t = threading.Thread(target=status_loop, daemon=True)
t.start()
try:
while alive["v"]:
opcode, payload = ws_read_frame(sock)
if opcode is None:
break
if opcode == 0x8: # close
break
if opcode == 0x9: # ping -> pong
push(ws_encode(payload, opcode=0xA))
continue
if opcode == 0xA: # pong
continue
if opcode not in (0x1, 0x2):
continue
# strip trailing CR/LF like the firmware does
text = payload.decode("utf-8", "replace").rstrip("\r\n")
if not text:
continue
kind, data = dispatch(text)
push(frame_reply(kind, data))
finally:
alive["v"] = False
try:
sock.close()
except OSError:
pass
def handle_client(sock, addr):
try:
method, path, headers = read_http_request(sock)
if method is None:
sock.close()
return
upgrade = headers.get("upgrade", "").lower() == "websocket"
# the SPA connects to /term; accept any path for the WS upgrade to be lenient
if upgrade:
if not do_ws_handshake(sock, headers):
return
handle_ws(sock)
return
if method == "GET" and path.split("?")[0] in ("/", "/index.html"):
serve_http_index(sock)
else:
serve_http_404(sock)
sock.close()
except (OSError, UnicodeDecodeError):
try:
sock.close()
except OSError:
pass
def main():
port = 8791
if len(sys.argv) > 1:
try:
port = int(sys.argv[1])
except ValueError:
print("usage: serve.py [port]", file=sys.stderr)
sys.exit(2)
if not os.path.exists(INDEX):
print("index.html missing — run: python3 %s/extract.py" % HERE, file=sys.stderr)
sys.exit(1)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(16)
url = "http://127.0.0.1:%d/" % port
print("wadamesh web-demo serving at %s" % url)
print(" (Ctrl-C to stop)")
try:
while True:
client, addr = srv.accept()
client.settimeout(30)
threading.Thread(target=handle_client, args=(client, addr), daemon=True).start()
except KeyboardInterrupt:
print("\nstopping.")
finally:
srv.close()
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
// Automated screenshots of the wadamesh web control panel demo, to doc-shots/web_*.png.
// Start the demo server first: python3 scripts/doc/web-demo/serve.py 8791
// Then: NODE_PATH=<playwright node_modules> node scripts/doc/web-demo/shoot.js [port] [outdir]
const { chromium } = require('playwright');
const PORT = process.argv[2] || '8791';
const OUT = process.argv[3] || 'doc-shots';
const URL = `http://127.0.0.1:${PORT}/`;
const path = require('path');
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 390, height: 780 },
deviceScaleFactor: 2, // crisp @2x for docs
});
const shot = async (name) => {
const p = path.join(OUT, `web_${name}.png`);
await page.screenshot({ path: p });
console.log(' wrote', p);
};
await page.goto(URL, { waitUntil: 'networkidle' });
await page.waitForSelector('#tlist .row', { timeout: 8000 });
await sleep(600);
// 1. Chats tab (default)
await shot('chats');
// 2. An open chat (first thread) with bubbles + delivery meta
await page.click('#tlist .row');
await page.waitForSelector('#cview', { state: 'visible', timeout: 5000 });
await page.waitForSelector('#cvmsgs .b', { timeout: 5000 });
await sleep(500);
await shot('chat');
await page.click('#cvback'); await sleep(300);
// 3. Contacts tab
await page.click('#tabs button[data-t=contacts]');
await page.waitForSelector('#clist .row', { timeout: 5000 });
await sleep(400);
await shot('contacts');
// 4. Contact action sheet (tap a contact row)
await page.click('#clist .row[data-c]');
await page.waitForSelector('#sheet.on', { timeout: 5000 });
await sleep(400);
await shot('contact_sheet');
await page.click('#scrim'); await sleep(400);
// 5. Discovered nodes sheet
await page.click('#discrow');
await page.waitForSelector('#sheet.on', { timeout: 5000 });
await sleep(400);
await shot('discovered');
await page.click('#scrim'); await sleep(400);
// 6. Terminal tab with some output
await page.click('#tabs button[data-t=term]');
await page.waitForSelector('#i', { timeout: 5000 });
for (const cmd of ['status', 'ver']) {
await page.fill('#i', cmd);
await page.press('#i', 'Enter');
await sleep(500);
}
await sleep(400);
await shot('terminal');
// 7. Settings modal (gear)
await page.click('#gear');
await page.waitForSelector('#modal.on', { timeout: 5000 });
await sleep(400);
await shot('settings');
await browser.close();
console.log('done.');
})().catch(e => { console.error('FAILED:', e); process.exit(1); });
+4 -2
View File
@@ -26,8 +26,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/out/firmware" # local mirror of the VPS firmware root
DEST="${WADAMESH_VPS:-}"; DEST_PATH="${WADAMESH_VPS_PATH:-/srv/wadamesh/firmware}"
# env:binname pairs — plain string form (works on macOS's bash 3.2; no associative arrays)
ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck"
# env:binname pairs — plain string form (works on macOS's bash 3.2; no associative arrays).
# All S3/PIO boards. The T-Display P4 is an ESP32-P4 IDF build (tdisplay_p4/) handled OUT of band
# — see the release skill / the P4 build+merge step — because this loop is PlatformIO-only.
ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2 heltec_v4_r8_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-r8-tft tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager-lr1121 tlora_pager_sx1262_companion_radio_touch:wadamesh-tlora-pager-sx1262 attaky_mesh_series_companion_radio_touch:wadamesh-attaky"
# Per-channel destination paths.
if [ "$MODE" = "stable" ]; then
+4
View File
@@ -54,6 +54,10 @@ echo "==> WadaMesh app-store publish: version $VERSION, revision $REVISION"
# ---- 1. build + verify the Tanmatsu app --------------------------------
if [ "$SKIP_BUILD" -eq 0 ]; then
# Bake the EXACT release tag into the P4 binary (About page + update check). We know
# it here; git describe inside build.sh would read a "-N-g<sha>" suffix (or the prior
# beta) whenever the beta_N tag isn't fetched locally.
[[ "$ARG" =~ ^beta_([0-9]+)$ ]] && export WADA_FW_TAG="$ARG"
echo "==> building Tanmatsu app (./build.sh build)..."
LOG="$(mktemp)"
( cd "$WADAMESH/tanmatsu" && ./build.sh build ) >"$LOG" 2>&1 || true # app_check_size exit!=0 is EXPECTED
+18 -4
View File
@@ -91,17 +91,23 @@ public:
* the only consumer that never sees app-originated sends — this lets it mirror one as a local
* outgoing bubble so it shows on-device too (issue #46). Default no-op for non-touch UIs. */
virtual void appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) {
(void)to_pub; (void)to_name; (void)text; (void)ack_hash;
uint32_t ack_hash, uint32_t sent_fp = 0) {
(void)to_pub; (void)to_name; (void)text; (void)ack_hash; (void)sent_fp;
}
/** Mirror an app-originated CHANNEL message into the on-device UI — the channel-send path, like the
* DM path above, never shows companion-originated sends on screen otherwise. text is NUL-terminated
* by the caller. Default no-op for non-touch UIs. */
virtual void appSentMsgToChannel(const char* channel_name, const char* text) {
(void)channel_name; (void)text;
virtual void appSentMsgToChannel(const char* channel_name, const char* text, uint32_t sent_fp = 0) {
(void)channel_name; (void)text; (void)sent_fp;
}
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void appendDiag(const char* message) { (void)message; }
/** Flush any UI-side persistent state (chat history, thread table) to storage
* NOW. Called before programmatic reboots (the companion app's reboot
* command) — the touch UI's history writes are lazy (up to ~30 s apart on the
* deep SD ring), so a reboot path that skips this drops the newest messages.
* Default: no-op for UIs without lazy persistent state. */
virtual void persistHistoryNow() {}
/** Notify UI of an advert reception. `is_new=true` means the contact is NOT in
* the persistent contacts[] table (filtered by auto-add rules or contacts
* full); `is_new=false` means the contact is in contacts[] and was just
@@ -136,6 +142,14 @@ public:
virtual void onAdminLoginResult(const ContactInfo& contact, bool success, uint8_t perms) {
(void)contact; (void)success; (void)perms;
}
/** The server's own clock, from the timestamp prefix every server RESPONSE
* carries (first 4 bytes). Fired on LOGIN_OK so the UI can warn when the
* device clock and the server clock disagree — a skewed device clock makes
* the server's replay guard silently drop our logins/posts/keep-alives
* (issue #89). Default no-op. */
virtual void onServerClock(const ContactInfo& contact, uint32_t server_epoch) {
(void)contact; (void)server_epoch;
}
/** A CLI command reply from a repeater / room server / sensor (the
* TXT_TYPE_CLI_DATA path). `text` is the decrypted command output text
* the server returned. Default no-op; the touch UI overrides to append
+222 -24
View File
@@ -3,6 +3,10 @@
#include "DataStore.h"
#if defined(ESP32)
#include <SD.h>
#include "helpers/esp32/WdtHeavyGuard.h" // suspend core-0 idle WDT during the (SPIFFS-GC-prone) contact write
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
#include <SD_MMC.h>
#endif
#if defined(EXTRAFS) || defined(QSPIFLASH)
@@ -70,6 +74,38 @@ void DataStore::begin() {
#else
// init 'blob store' support
_fs->mkdir("/bl");
#if defined(ESP32)
// One-time migration: when a secondary FS is active (an SD card is present)
// but contacts/channels still live on the primary FS from an earlier build,
// copy them onto the card ONCE and delete the primary copies. This preserves
// the user's contact list while getting the frequently-rewritten files off a
// small/near-full SPIFFS — whose garbage collection can otherwise stall the
// loop task for seconds during saveContacts and trip the task watchdog
// (the beta_25 reboot loop). Identity + prefs stay on _fs and never move.
// No-op once the card already holds the files, and a safe no-op on boards
// whose primary-FS metadata can't confirm the source exists (e.g. P4 FFat).
if (_fsExtra) {
static const char* const kMoveFiles[] = { "/contacts3", "/channels2" };
for (const char* nm : kMoveFiles) {
if (_fsExtra->exists(nm)) continue; // already on the card
if (!_fs->exists(nm)) continue; // nothing to move
File in = openRead(_fs, nm);
File out = openWrite(_fsExtra, nm);
bool ok = in && out;
if (ok) {
uint8_t buf[256];
int n;
while ((n = in.read(buf, sizeof(buf))) > 0) {
if (out.write(buf, (size_t)n) != (size_t)n) { ok = false; break; }
}
}
if (in) in.close();
if (out) out.close();
if (ok) _fs->remove(nm); // reclaim SPIFFS space
else if (_fsExtra->exists(nm)) _fsExtra->remove(nm); // don't leave a partial copy
}
}
#endif
#endif
}
@@ -207,19 +243,41 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
}
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
if (_fs->exists(_rp("/new_prefs"))) {
// Probe by OPENING, never exists(): on the Tanmatsu's internal FFat the FAT
// metadata layer lies (f_stat garbage — the tile cache hit the same thing),
// and WHICH lie you get shifts with the build (the -Og to -Os switch turned
// "loaded fine by luck" into "no prefs found -> default name every boot").
// open()+read is truthful there, and identical in behavior everywhere else.
auto probe = [this](const char* nm) -> bool {
File f = openRead(nm);
if (!f) return false;
uint8_t b;
const bool ok = f.read(&b, 1) == 1; // size() lies on the P4's FFat too — actually read
f.close();
return ok;
};
if (probe("/new_prefs")) {
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename
} else if (_fs->exists(_rp("/node_prefs"))) {
} else if (probe("/new_prefs.tmp")) {
// Main file gone but a staged copy exists: a reboot landed between the temp
// write and the swap (or the swap was torn). Recover from it — this is the
// "device booted with the default name once" failure mode.
MESH_DEBUG_PRINTLN("DataStore: /new_prefs missing, recovering from .tmp");
loadPrefsInt("/new_prefs.tmp", prefs, node_lat, node_lon);
savePrefs(prefs, node_lat, node_lon); // re-establish the main file
} else if (probe("/node_prefs")) {
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon);
savePrefs(prefs, node_lat, node_lon); // save to new filename
_fs->remove(_rp("/node_prefs")); // remove old
} else {
MESH_DEBUG_PRINTLN("DataStore: no prefs file found — using defaults");
}
}
namespace {
void default_rx_boosted_gain_pref(uint8_t& out) {
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(SX126X_RX_BOOSTED_GAIN)
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) || defined(SX126X_RX_BOOSTED_GAIN)
#ifdef SX126X_RX_BOOSTED_GAIN
out = (SX126X_RX_BOOSTED_GAIN != 0) ? 1 : 0;
#else
@@ -306,8 +364,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
}
}
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
File file = openWrite(_fs, "/new_prefs");
bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
// Write to a temp file first and swap it in afterwards: a reboot / power cut
// mid-write can then never destroy the only copy (the loader recovers from
// whichever file survived). SPIFFS has no atomic rename-over, so the swap is
// remove+rename — the loader handles the tiny between-steps window too.
File file = openWrite(_fs, "/new_prefs.tmp");
if (file) {
uint8_t pad[8];
memset(pad, 0, sizeof(pad));
@@ -344,13 +406,37 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 91
file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 92
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 93
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125
size_t last = file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125
file.close();
if (last != sizeof(_prefs.default_scope_key)) {
_fs->remove(_rp("/new_prefs.tmp")); // short write (storage full?) — keep the old main file
return false;
}
_fs->remove(_rp("/new_prefs"));
return _fs->rename(_rp("/new_prefs.tmp"), _rp("/new_prefs"));
}
return false;
}
void DataStore::loadContacts(DataStoreHost* host) {
#if defined(ESP32)
// Recover an atomic-save swap interrupted by power loss: if the live file is gone but the
// fully-written temp survives, adopt it. A temp alongside an intact live file is a stale
// leftover (save crashed after writing temp but before the swap) — keep live, drop temp.
{
FILESYSTEM* cfs = _getContactsChannelsFS();
char live[80], tmp[80];
if (_root[0]) { snprintf(live, sizeof live, "%s/contacts3", _root);
snprintf(tmp, sizeof tmp, "%s/contacts3.tmp", _root); }
else { strncpy(live, "/contacts3", sizeof live);
strncpy(tmp, "/contacts3.tmp", sizeof tmp); }
if (cfs->exists(tmp)) {
if (!cfs->exists(live)) cfs->rename(tmp, live); // interrupted swap -> recover the temp
else cfs->remove(tmp); // stale temp -> the live file wins
}
}
#endif
File file = openRead(_getContactsChannelsFS(), "/contacts3");
if (file) {
bool full = false;
@@ -382,36 +468,118 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
}
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
#if defined(ESP32)
// A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC
// pass that disables the flash cache and starves core 0's idle task, tripping
// the task watchdog (the beta_25 reboot loop, confirmed by coredump: loopTask
// stuck in spiffs_gc_clean under this exact call). Suspend that watchdog for
// the write so a bounded-but-slow pass stalls the UI instead of rebooting. The
// ref-count is shared with the UI history/backup guards, so a backup import
// that calls saveContacts stays balanced. On SD-routed devices this is cheap
// (FAT has no such GC); it matters most for card-less SPIFFS devices.
WdtHeavyGuard _wdt;
#endif
#if defined(ESP32)
// Write to a TEMP file and swap it in only after it is FULLY written, so a partial
// write — a multi-second SPIFFS GC stall or an SD hiccup mid-save — can never truncate
// the operator's live contact list. Without this, openWrite("/contacts3") truncates the
// real file up front, so any failed write permanently destroyed the list (the "lost all
// my repeaters overnight" report). Other platforms keep the direct write (LittleFS, and
// untested here). The swap + boot-recovery live after the write loop below.
const char* kContactsWriteTarget = "/contacts3.tmp";
#else
const char* kContactsWriteTarget = "/contacts3";
#endif
File file = openWrite(_getContactsChannelsFS(), kContactsWriteTarget);
bool wrote_ok = false; // true only if every record reached the file
if (file) {
bool ok = true; // cleared on any short write (partial file)
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
while (host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
// Field-by-field writes cost ~12 FS calls per contact — ~7000 tiny writes at
// ~570 contacts measured 2.6-3.0 s on SD, freezing the loop/UI for the whole
// save (issue #82). Pack the SAME 152-byte records into a heap chunk and
// flush in ~5 KB slabs instead: identical file bytes, ~20 large writes, tens
// of ms. Falls back to the original per-field path if the alloc fails.
static const size_t REC = 152; // 32+32+1+1+1+4+1+4+64+4+4+4
static const size_t CHUNK_RECS = 32;
uint8_t* buf = (uint8_t*)malloc(REC * CHUNK_RECS);
if (buf) {
size_t fill = 0;
while (ok && host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
idx++; // advance to next contact
continue;
}
uint8_t* p = buf + fill;
memcpy(p, c.id.pub_key, 32); p += 32;
memcpy(p, (uint8_t *)&c.name, 32); p += 32;
*p++ = c.type;
*p++ = c.flags;
*p++ = unused;
memcpy(p, (uint8_t *)&c.sync_since, 4); p += 4;
memcpy(p, (uint8_t *)&c.out_path_len, 1); p += 1;
memcpy(p, (uint8_t *)&c.last_advert_timestamp, 4); p += 4;
memcpy(p, c.out_path, 64); p += 64;
memcpy(p, (uint8_t *)&c.lastmod, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lat, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lon, 4); p += 4;
fill += REC;
if (fill == REC * CHUNK_RECS) {
ok = (file.write(buf, fill) == fill);
fill = 0;
}
idx++; // advance to next contact
continue;
}
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (ok && fill > 0) ok = (file.write(buf, fill) == fill);
free(buf);
} else {
while (host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
idx++; // advance to next contact
continue;
}
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break; // write failed
if (!success) { ok = false; break; } // write failed -> keep the live file
idx++; // advance to next contact
idx++; // advance to next contact
}
}
file.close();
wrote_ok = ok;
}
#if defined(ESP32)
// Commit the temp only if it was written IN FULL; otherwise keep the existing live list.
// (_rp uses one shared buffer, so build both paths explicitly to avoid clobbering it.)
{
FILESYSTEM* cfs = _getContactsChannelsFS();
char live[80], tmp[80];
if (_root[0]) { snprintf(live, sizeof live, "%s/contacts3", _root);
snprintf(tmp, sizeof tmp, "%s/contacts3.tmp", _root); }
else { strncpy(live, "/contacts3", sizeof live);
strncpy(tmp, "/contacts3.tmp", sizeof tmp); }
if (wrote_ok) {
cfs->remove(live); // FAT/SPIFFS rename won't overwrite an existing target
cfs->rename(tmp, live); // if this rename fails, loadContacts recovers the temp at next boot
} else {
cfs->remove(tmp); // discard the partial temp; the live contact list is untouched
}
}
#endif
}
void DataStore::loadChannels(DataStoreHost* host) {
@@ -714,3 +882,33 @@ bool DataStore::useSdStorage() {
return true;
}
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// P4 boards: same full-store adoption as useSdStorage(), but on the SD_MMC slot.
// The internal FFat 'locfd' has a broken FAT metadata layer (f_stat/exists lie —
// see the tile-cache notes), and the exists()-gated identity + prefs loads that
// "worked" at -Og read different garbage at -Os and came up empty: fresh node
// identity, default name, profile changes gone every reboot. The card's FAT
// metadata is truthful, so identity/prefs move there with everything else.
// The caller migrates any FFat-resident files first (open()-probed, never
// exists() on FFat).
bool DataStore::useSdMmcStorage() {
if (!SD_MMC.exists("/meshcomod")) SD_MMC.mkdir("/meshcomod");
if (!SD_MMC.exists("/meshcomod/bl")) SD_MMC.mkdir("/meshcomod/bl");
if (!SD_MMC.exists("/meshcomod/identity")) SD_MMC.mkdir("/meshcomod/identity");
// The old secondary-FS layout kept contacts/channels/blobs at the CARD ROOT;
// pull them under /meshcomod so the rooted store keeps reading them.
static const char* k_move[] = { "/contacts3", "/channels2", "/adv_blobs" };
for (const char* nm : k_move) {
char dst[40];
snprintf(dst, sizeof dst, "/meshcomod%s", nm);
if (SD_MMC.exists(nm) && !SD_MMC.exists(dst)) SD_MMC.rename(nm, dst);
}
strncpy(_root, "/meshcomod", sizeof(_root) - 1);
_root[sizeof(_root) - 1] = '\0';
_fs = &SD_MMC;
_fsExtra = nullptr;
identity_store.use(SD_MMC, "/meshcomod/identity");
return true;
}
#endif
+18 -1
View File
@@ -42,6 +42,12 @@ public:
// contacts, channels, blobs). Call after the SD card is mounted. Returns false
// if the dir can't be created. No-op on non-ESP32.
bool useSdStorage();
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// Full-store adoption of the SD_MMC card (the P4 boards): identity + prefs move
// off the broken-metadata internal FFat (its exists()/f_stat lie, which made the
// gated loads come up empty). Caller migrates FFat-resident files first.
bool useSdMmcStorage();
#endif
FILESYSTEM* getPrimaryFS() const { return _fs; }
FILESYSTEM* getSecondaryFS() const { return _fsExtra; }
@@ -52,7 +58,10 @@ public:
bool loadMainIdentity(mesh::LocalIdentity &identity);
bool saveMainIdentity(const mesh::LocalIdentity &identity);
void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon);
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
// Crash-safe: writes /new_prefs.tmp, then swaps it in. Returns false when the
// write or the swap failed (storage full / torn) so callers can surface it —
// the save used to fail silently and the UI toasted "saved" regardless.
bool savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
void loadChannels(DataStoreHost* host);
@@ -67,6 +76,14 @@ public:
bool removeFile(FILESYSTEM* fs, const char* filename);
uint32_t getStorageUsedKb() const;
uint32_t getStorageTotalKb() const;
#if defined(ESP32)
// True while contacts/channels live on the internal flash FS (SPIFFS/LittleFS),
// whose garbage collection makes a full rewrite expensive — a multi-second GC
// pass that freezes the loop. False once routed to an SD card (useSdStorage sets
// _root; setSecondaryFS sets _fsExtra) — FAT has no such GC. Lets callers coalesce
// the advert-driven contacts save on card-less devices without changing SD boards.
bool contactsOnInternalFlash() const { return _root[0] == '\0' && _fsExtra == nullptr; }
#endif
private:
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
+174 -35
View File
@@ -8,8 +8,8 @@
#include <time.h> // gmtime_r for the "clock" CLI command
#ifdef ESP32
#include <esp_system.h> // esp_restart for the "bootloader" CLI command
#if !defined(HAS_TANMATSU)
#include <soc/rtc_cntl_reg.h> // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; P4 lacks it)
#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4)
#include <soc/rtc_cntl_reg.h> // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; both P4 boards lack it)
#endif
#endif
#include <helpers/AdvertDataHelpers.h>
@@ -21,7 +21,9 @@
#include <WiFi.h>
#endif
#ifdef MULTI_TRANSPORT_COMPANION
#include <helpers/esp32/MultiTransportCompanionInterface.h>
// QUOTED on purpose: the vendored core lib ships a STALE copy of this header in its
// include path (no bleAllowNextRxLog); quotes force the local src/ copy we compile.
#include "helpers/esp32/MultiTransportCompanionInterface.h"
#include "helpers/esp32/MqttBridge.h"
#endif
#endif
@@ -135,6 +137,20 @@ static int s_companion_ota_pinned_reply_target = -1;
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
#define LAZY_CONTACTS_WRITE_DELAY 5000
// On card-less (internal-flash) devices, coalesce advert-refresh contacts saves to
// at most once per this window — a full rewrite can trigger a multi-second SPIFFS GC
// that freezes the loop, and re-adverts (last-heard/path refreshes) otherwise churn
// it constantly. A change in the contact SET still saves promptly (see MyMesh::loop).
#define CONTACTS_REFRESH_SAVE_INTERVAL 300000
// Our self-chosen room-session keep-alive interval (secs). Room servers zero the
// legacy suggested-interval field in LOGIN_OK, so the client picks. 128 is the
// value upstream itself recommended while the field was live (CLIENT_KEEP_ALIVE_SECS
// 128, disabled 2025-06 rather than tuned) — matching it stays friendly to the
// server's TODO'd "throttle keep-alives, evict fast pingers" heuristic. The core
// expires the connection at 2.5x (320 s) without an ACK; every received room push
// also counts as activity (markConnectionActive), so a busy room barely pings at
// all. One 9-byte direct REQ + 5-byte ACK per interval — negligible airtime.
#define ROOM_KEEPALIVE_SECS 128
#define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg=="
@@ -226,27 +242,26 @@ static int wifiScanWatchdogSafe(uint32_t cap_ms) {
#endif
#endif
static const char* kMeshcomodHelpMsg =
"help\n"
"status\n"
"ota start\n"
"ota url <https://...bin>\n"
"ota netdiag\n"
"ota status\n"
"wifi scan\n"
"wifi use <n>\n"
"wifi set ssid <v>\n"
"wifi set pwd <v>\n"
"wifi status\n"
"wifi on\n"
"wifi off\n"
"wifi apply\n"
"wifi clear\n"
"tcp on\n"
"tcp off\n"
"tcp status\n"
"ble on\n"
"ble off\n"
"ble status";
"help / ? this command list\n"
"status device status\n"
"ver firmware version\n"
"clock RTC time (UTC)\n"
"get show radio params\n"
"advert send a flood advert\n"
"advert.zerohop send a 0-hop advert\n"
"set name <v> set node name\n"
"set freq <MHz> set frequency\n"
"set bw <kHz> set bandwidth\n"
"set sf <7-12> set spreading factor\n"
"set cr <5-8> set coding rate\n"
"set tx <dBm> set TX power\n"
"wifi status|on|off|scan\n"
"wifi use <n> | set ssid <v> | set pwd <v> | apply | clear\n"
"tcp status|on|off\n"
"ble status|on|off\n"
"ota status|start|netdiag | ota url <https://...bin>\n"
"reboot restart the device\n"
"bootloader / dfu reboot to download mode";
#define MESHCOMOD_CMD_CACHE_SIZE 6
struct MeshcomodCmdCacheEntry {
@@ -254,7 +269,15 @@ struct MeshcomodCmdCacheEntry {
uint32_t seen_ms;
char text[128];
};
static MeshcomodCmdCacheEntry s_meshcomod_cmd_cache[MESHCOMOD_CMD_CACHE_SIZE] = {};
// PSRAM-first (internal fallback), zero-initialized (0.8 KB off internal .bss).
static void* msPsAlloc(size_t n) {
void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM);
if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT);
if (p) memset(p, 0, n);
return p;
}
static MeshcomodCmdCacheEntry* s_meshcomod_cmd_cache =
(MeshcomodCmdCacheEntry*)msPsAlloc(sizeof(MeshcomodCmdCacheEntry) * MESHCOMOD_CMD_CACHE_SIZE);
static int s_meshcomod_cmd_cache_next = 0;
static uint32_t s_meshcomod_last_reply_ts = 0;
static uint32_t s_last_cmd_txt_ts = 0;
@@ -563,7 +586,7 @@ bool MyMesh::handleMeshcomodCommand(const char* text, int text_len) {
// rest of RTC_CNTL_OPTION1 and wedges the RTC so esp_restart() hangs instead
// of resetting (that was the earlier "freeze"). board.reboot() == esp_restart,
// the proven reset path on this board; the RTC bit survives it.
#if !defined(HAS_TANMATSU)
#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4)
uint32_t opt1 = REG_READ(RTC_CNTL_OPTION1_REG);
REG_WRITE(RTC_CNTL_OPTION1_REG, opt1 | RTC_CNTL_FORCE_DOWNLOAD_BOOT);
#endif
@@ -1475,7 +1498,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
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);
if (uiCountEcho(fnv1a32(raw + poff, len - poff), lasthop, lasthop ? hsz_p : 0)) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
// Echo of OUR OWN send: let this one RX-log frame through to BLE too.
// The app's "Repeats heard" is computed exactly from these (issue #94)
// and a few frames per send can't re-create the #46/#54 BLE flood.
MultiTransportCompanionInterface::bleAllowNextRxLog();
#endif
}
}
}
}
@@ -2239,6 +2269,16 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
if (keep_alive_secs > 0) {
startConnection(contact, keep_alive_secs);
} else if (contact.type == ADV_TYPE_ROOM) {
// Modern room servers zero the legacy keep-alive field (simple_room_server:
// `reply_data[5] = 0; // Legacy`), so no connection was ever armed and we
// never pinged. The server still NEEDS to hear from us: its push loop
// abandons a client after 3 unACKed pushes (`push_failures < 3`) and only a
// received transmission — a post, or exactly this REQ_TYPE_KEEP_ALIVE —
// resets the counter (issue #89: rooms went one-way-dead). Arm our own
// interval; checkConnections() in loop() does the rest (9-byte direct REQ,
// its ACK also refreshes the server's last_activity for us).
startConnection(contact, ROOM_KEEPALIVE_SECS);
}
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
out_frame[i++] = data[6]; // permissions (eg. is_admin)
@@ -2266,6 +2306,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
// Notify the touch UI so the admin console can flip from "logging in…"
// to the prompt (success) or show "wrong password" (fail). Same data
// the companion app sees via PUSH_CODE_LOGIN_SUCCESS/_FAIL.
// On LOGIN_OK also hand it the server's clock (`tag` = the timestamp prefix
// of every server response) so it can warn about device-vs-server skew —
// skew is what makes the server's replay guard silently eat us (#89).
if (_ui && ok) _ui->onServerClock(contact, tag);
if (_ui) _ui->onAdminLoginResult(contact, ok, perms);
#endif
} else if (len > 4 && // check for status response
@@ -2357,6 +2401,22 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) {
_ui_sig_ms = millis();
}
}
// Discover scan (Discover app): collect EVERY NODE_DISCOVER_RESP matching the active discover
// sweep tag into _discover[], keyed by responder pubkey. Additive to the single-scalar probe
// capture above (they use different tags). RESP payload (MeshCore docs/payloads.md):
// [0]=0x9<<4|node_type [1]=their SNR*4 (reverse link) [2..5]=tag [6..]=pubkey (8 or 32 bytes).
if (packet->payload_len >= 6 + 8 && (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP
&& _discover_tag != 0) {
uint32_t rtag; memcpy(&rtag, &packet->payload[2], 4);
if (rtag == _discover_tag) {
uint8_t node_type = packet->payload[0] & 0x0F;
int8_t their_snr_q4 = (int8_t)packet->payload[1];
uint8_t pklen = (packet->payload_len >= 6 + 32) ? 32 : 8;
int8_t our_snr_q4 = (int8_t)(packet->getSNR() * 4.0f);
int8_t our_rssi = (int8_t)_radio->getLastRSSI();
discoverUpsert(&packet->payload[6], pklen, node_type, our_snr_q4, our_rssi, their_snr_q4, packet->path_len);
}
}
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
return;
@@ -2376,6 +2436,38 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) {
}
}
// Upsert a discover responder into _discover[] (keyed by the 8-byte pubkey prefix). New nodes
// append; a full table evicts the least-recently-heard. Both link directions + hop count are
// refreshed on every reply. See DiscoverHit / uiStartDiscoverScan in MyMesh.h.
void MyMesh::discoverUpsert(const uint8_t* pk, uint8_t pklen, uint8_t node_type,
int8_t our_snr_q4, int8_t our_rssi, int8_t their_snr_q4, uint8_t path_len) {
uint32_t now = millis();
int slot = -1;
for (uint8_t i = 0; i < _discover_cnt; i++) {
if (memcmp(_discover[i].pubkey, pk, 8) == 0) { slot = i; break; }
}
if (slot < 0) {
if (_discover_cnt < DISCOVER_MAX) {
slot = _discover_cnt++;
} else { // table full -> evict the least-recently-heard
uint32_t oldest = 0xFFFFFFFFu; slot = 0;
for (uint8_t i = 0; i < _discover_cnt; i++)
if (_discover[i].last_ms < oldest) { oldest = _discover[i].last_ms; slot = i; }
}
memset(&_discover[slot], 0, sizeof(DiscoverHit));
memcpy(_discover[slot].pubkey, pk, pklen > 32 ? 32 : pklen);
_discover[slot].first_ms = now;
}
DiscoverHit& h = _discover[slot];
h.node_type = node_type;
h.our_snr_q4 = our_snr_q4;
h.our_rssi = our_rssi;
h.their_snr_q4 = their_snr_q4;
h.path_len = path_len;
h.last_ms = now;
if (h.heard < 0xFFFF) h.heard++;
}
void MyMesh::onRawDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
@@ -2497,7 +2589,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // No automatic GPS updates by default
//_prefs.rx_delay_base = 10.0f; enable once new algo fixed
#if defined(USE_SX1262) || defined(USE_SX1268)
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121)
#ifdef SX126X_RX_BOOSTED_GAIN
_prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN ? 1 : 0;
#else
@@ -2507,9 +2599,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
}
void MyMesh::applyRadioFromPrefs() {
// setParams is a multi-step SPI sequence; hold the radio against the buffered-
// receive drain task so it can't re-arm RX between the steps (no-op when off).
radio_driver.radioAcquire();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.radioRelease();
radio_driver.setTxPower(_prefs.tx_power_dbm);
#if defined(USE_SX1262) || defined(USE_SX1268)
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121)
_prefs.rx_boosted_gain = _prefs.rx_boosted_gain ? 1 : 0;
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain != 0);
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
@@ -2956,12 +3052,13 @@ void MyMesh::handleCmdFrame(size_t len) {
bool success = getChannel(channel_idx, channel);
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
writeOKFrame();
#if defined(HAS_TANMATSU)
// Mirror the app-sent channel message into the on-device touch UI — the channel-send path
// otherwise never shows companion-originated channel sends on screen (the DM path does, via
// appSentMsgToContact). NUL-terminate the text in-place first, exactly as the DM path does.
// ALL boards — this sat behind HAS_TANMATSU from its beta_17 birth, so T-Deck/V4 users
// never saw their own app-sent channel posts on the device. Repeater echoes of our own
// flood can't double the bubble: the dispatcher's seen-packet table drops them pre-ingest.
if (_ui) { cmd_frame[len] = 0; _ui->appSentMsgToChannel(channel.name, text); }
#endif
// Sent channel messages are not added to shared history / broadcast: channel_idx and
// frame format are device-specific and clients (e.g. HA) without channel support can
// misparse or show "text as sender"; also avoids failed-to-sync when versions differ.
@@ -3305,10 +3402,12 @@ void MyMesh::handleCmdFrame(size_t len) {
_prefs.client_repeat = repeat;
savePrefs();
radio_driver.radioAcquire(); // hold off the RX drain task mid-sequence (no-op when off)
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(SX126X_RX_BOOSTED_GAIN)
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) || defined(SX126X_RX_BOOSTED_GAIN)
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain != 0);
#endif
radio_driver.radioRelease();
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
@@ -3374,6 +3473,11 @@ void MyMesh::handleCmdFrame(size_t len) {
if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed?
saveContacts();
}
// The app's reboot button must not drop chat history: the touch UI's writes
// are lazy (up to ~30 s apart on the deep SD ring), so flush synchronously
// first — the same contract the on-device power menu honors. Skipping this
// was the "read and unread messages deleted after a manual reboot" report.
if (_ui) _ui->persistHistoryNow();
board.reboot();
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
uint8_t reply[11];
@@ -3963,6 +4067,11 @@ static bool save_filter(const ContactInfo& c) {
void MyMesh::saveContacts() {
_store->saveContacts(this, save_filter);
// Keep the advert-save coalescer (MyMesh::loop) in sync on EVERY save path —
// lazy flush, app command, reboot — so the next lazy check compares against the
// freshly-written contact set + resets the refresh window.
_last_saved_contacts_n = getNumContacts();
_next_contacts_refresh_save = futureMillis(CONTACTS_REFRESH_SAVE_INTERVAL);
}
void MyMesh::enterCLIRescue() {
@@ -4368,6 +4477,17 @@ void MyMesh::checkSerialInterface() {
void MyMesh::loop() {
BaseChatMesh::loop();
// Session keep-alives for logged-in servers (rooms). The core pinger sends the
// 9-byte REQ_TYPE_KEEP_ALIVE (+ our sync_since) a room server expects; the ACK
// back refreshes the server's last_activity for us AND resets its push_failures
// counter — without it the server abandons a client after any 3 unacknowledged
// post pushes and never pushes again (simple_room_server: `push_failures < 3`
// gate in its round-robin push loop), which reads as a one-way-frozen room
// (issue #89). Upstream left this call commented out ("deprecate the
// Connections stuff"); the on-device room UI needs it. Cheap: scans 16 slots,
// transmits only when a connection is armed and due (see the room login branch).
checkConnections();
if (_cli_rescue) {
checkCLIRescueCmd();
} else {
@@ -4398,10 +4518,29 @@ void MyMesh::loop() {
checkSerialInterface();
}
// is there are pending dirty contacts write needed?
// Pending contacts write. On card-less devices the contacts file lives on internal
// flash (SPIFFS/LittleFS), where a full rewrite can trigger a multi-second GC pass
// that freezes the whole loop (About "Loop stalls" showed ~18 s on a V4). Adverts
// refresh existing contacts constantly, so persisting every refresh churns the
// flash. Save promptly when the contact SET changed (a new/removed node MUST
// survive a reboot), but coalesce pure refreshes (last-heard time, path, name/GPS
// of a known node — all self-healing, and a clean reboot flushes them via
// CMD_REBOOT) to CONTACTS_REFRESH_SAVE_INTERVAL. SD-routed boards (FAT, no GC) are
// unaffected — contactsOnInternalFlash() is false there, so they always save.
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
saveContacts();
dirty_contacts_expiry = 0;
bool defer = false;
#if defined(ESP32)
if (getNumContacts() == _last_saved_contacts_n && _store->contactsOnInternalFlash()
&& !millisHasNowPassed(_next_contacts_refresh_save)) {
defer = true; // no add/remove + still inside the refresh window on GC-prone flash
}
#endif
if (defer) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // re-check soon; don't rewrite yet
} else {
saveContacts(); // updates _last_saved_contacts_n + _next_contacts_refresh_save
dirty_contacts_expiry = 0;
}
}
#if defined(ENABLE_ADVERT_ON_BOOT) && ENABLE_ADVERT_ON_BOOT == 1
+115 -3
View File
@@ -380,6 +380,29 @@ public:
return r;
}
/** Is a keep-alive session to this server currently armed and alive?
* (Room chat UI: the core frees the slot 2.5x the interval after the last
* ACK/activity, so false = the session needs a re-login.) */
bool uiHasConnectionTo(const uint8_t pub_key[32]) { return hasConnectionTo(pub_key); }
/** Re-login to a room server WITHOUT a password. For a client already in the
* room's ACL a blank login replies LOGIN_OK with the stored permissions and
* skips the login replay guard (simple_room_server onAnonDataRecv, blank-
* password branch) — and when it arrives flooded the server also resets its
* stale out_path to us. The blank login itself resets NOTHING else server-side
* (not push_failures, not last_activity); the actual heal is the chain it
* starts: LOGIN_OK -> onContactResponse arms the room keep-alive -> the first
* REQ_TYPE_KEEP_ALIVE refreshes the server's last_activity and clears its
* push-abandon counter, so pushes resume (issue #89: self-heal + the chat
* sheet's "Log in again"). NOTE it cannot recover a server that REBOOTED
* (non-admin ACL entries aren't persisted there) — that needs a passworded
* Join from the Contacts sheet. */
int uiRoomRelogin(const uint8_t pub_key[32]) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c || c->type != ADV_TYPE_ROOM) return MSG_SEND_FAILED;
return uiSendAdminLogin(*c, "");
}
/** Send a CLI command line to a previously-logged-in repeater / room
* server. The reply lands in AbstractUITask::onAdminCommandReply via
* queueMessage when the server returns a TXT_TYPE_CLI_DATA frame.
@@ -458,6 +481,36 @@ public:
int8_t uiSignalRssi() const { return _ui_sig_rssi; }
uint32_t uiSignalMs() const { return _ui_sig_ms; }
// ---- Discover scan (the Discover app: active node-discovery, ALL node types) ----
// uiStartDiscoverScan() broadcasts a zero-hop NODE_DISCOVER_REQ with a type filter + a fresh
// random tag; EVERY neighbour that answers with a NODE_DISCOVER_RESP is upserted here (keyed by
// pubkey prefix) from onControlDataRecv. Unlike the single-scalar signal probe, every responder
// is kept, with BOTH link directions (our RX of them + their RX of us, from RESP payload[1]).
struct DiscoverHit {
uint8_t pubkey[32]; // responder identity (full key — the REQ sets prefix_only=0)
uint8_t node_type; // ADV_TYPE_* (RESP payload[0] low nibble): repeater/chat/room/sensor
int8_t our_snr_q4; // our RX SNR*4 of their reply (forward link)
int8_t our_rssi; // our RX RSSI dBm of their reply
int8_t their_snr_q4; // their RX SNR*4 of our request (reverse link — RESP payload[1])
uint8_t path_len; // hops the reply travelled (0 = heard directly, i.e. in RF range)
uint32_t first_ms; // millis() first heard this session
uint32_t last_ms; // millis() last heard
uint16_t heard; // reply count
};
static const int DISCOVER_MAX = 64;
DiscoverHit _discover[DISCOVER_MAX];
uint8_t _discover_cnt = 0;
uint32_t _discover_tag = 0; // active scan tag (matches RESPs; 0 = no scan yet)
uint32_t _discover_scan_ms = 0; // millis() the current scan sweep was fired
uint8_t discoverCount() const { return _discover_cnt; }
bool discoverGet(uint8_t i, DiscoverHit& out) const {
if (i >= _discover_cnt) return false; out = _discover[i]; return true;
}
void discoverClear() { _discover_cnt = 0; }
uint32_t discoverScanMs() const { return _discover_scan_ms; }
void discoverUpsert(const uint8_t* pk, uint8_t pklen, uint8_t node_type,
int8_t our_snr_q4, int8_t our_rssi, int8_t their_snr_q4, uint8_t path_len);
// ---- 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
@@ -564,7 +617,10 @@ public:
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). `hop`
* (optional) is the re-flooding repeater's hash — the echo's last path hop —
* recorded deduped + bounded so the sent-message Info can name the repeaters. */
void uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
/** Returns true when `fp` matched one of OUR recent sends (i.e. this RX is an
* echo of our own flood) — logRxRaw uses that to let the frame through to BLE
* for the app's "Repeats heard" (issue #94). */
bool uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
for (int i = 0; i < UI_ECHO_SLOTS; i++)
if (_echo_fp[i] == fp) {
if (_echo_rep[i] < 255) _echo_rep[i]++;
@@ -579,8 +635,9 @@ public:
_echo_hop_n[i]++;
}
}
return;
return true;
}
return false;
}
/** 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. */
@@ -684,6 +741,29 @@ public:
return _ui_sig_probe_tag;
}
/** DISCOVER SCAN (Discover app): like uiSendSignalProbe, but asks ALL node types and KEEPS
* every responder (see _discover[] + discoverUpsert, populated in onControlDataRecv). Broadcasts
* one zero-hop NODE_DISCOVER_REQ; neighbours reply DIRECTLY (never floods). type_filter = OR of
* (1<<ADV_TYPE_*); pass 0 for "all types". Returns the scan tag (0 = failed). The caller should
* airtime-gate repeated sweeps (Dispatcher::getRemainingTxBudget). */
uint32_t uiStartDiscoverScan(uint8_t type_filter = 0) {
uint8_t data[10];
data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80; low bit prefix_only=0 -> full 32-byte pubkeys
data[1] = type_filter ? type_filter
: (uint8_t)((1 << ADV_TYPE_CHAT) | (1 << ADV_TYPE_REPEATER) |
(1 << ADV_TYPE_ROOM) | (1 << ADV_TYPE_SENSOR)); // all types
getRNG()->random(&data[2], 4); // fresh random tag to match this sweep's replies
memcpy(&_discover_tag, &data[2], 4);
if (_discover_tag == 0) { _discover_tag = 1; memcpy(&data[2], &_discover_tag, 4); }
uint32_t since = 0; // 0 = answer regardless of freshness
memcpy(&data[6], &since, 4);
mesh::Packet* pkt = createControlData(data, sizeof(data));
if (!pkt) { _discover_tag = 0; return 0; }
sendZeroHop(pkt);
_discover_scan_ms = millis();
return _discover_tag;
}
/** Request CayenneLPP telemetry from a remote contact. Reply is delivered
* via AbstractUITask::onTelemetryReply with the raw LPP payload after the
* 4-byte timestamp header. Falls back to onPingReply if the UI didn't
@@ -704,7 +784,7 @@ public:
private:
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
bool savePrefs() { return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
// Set the default flood scope (region) used to tag outgoing flood packets, so
// repeaters on region-scoped networks ("flood only for their region") will
@@ -804,11 +884,36 @@ public:
return true;
}
/** Add a node found by the Discover app (active NODE_DISCOVER sweep) to contacts, from its full
* 32-byte pubkey + node TYPE + a display name. Like uiAddManualContact but preserves the type
* (repeater/room/sensor/chat) so the contact lands with the right role/icon — the discovery
* response carries the type + full pubkey but no advert, so name is a placeholder until one
* arrives. Returns false if it's already a contact, the name is empty, or the table is full. */
bool uiAddDiscoveredContact(const uint8_t pub_key[32], uint8_t type, const char* name) {
if (!name || !name[0]) return false;
if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != nullptr) return false;
ContactInfo ci{};
memcpy(ci.id.pub_key, pub_key, PUB_KEY_SIZE);
ci.type = type;
ci.out_path_len = OUT_PATH_UNKNOWN;
ci.last_advert_timestamp = 0; // unknown — we only heard a discovery reply
ci.lastmod = getRTCClock()->getCurrentTime();
StrHelper::strncpy(ci.name, name, sizeof(ci.name));
if (!addContact(ci)) return false;
saveContacts();
if (_ui) _ui->onThreadsChanged();
return true;
}
/** Persist the in-RAM contact table to flash (/contacts3). Public wrapper so
* UI paths that insert via the base addContact() — e.g. the Discovered-list
* "Add to contacts" button — can persist; otherwise that contact is RAM-only
* and lost on reboot. */
bool uiPersistContacts() { saveContacts(); return true; }
// Flush a pending (possibly coalesced) contacts write before a deliberate
// shutdown/reboot, so the on-device power paths don't lose the last refresh
// window on card-less devices (see MyMesh::loop). No-op when nothing is pending.
void flushContactsIfDirty() { if (dirty_contacts_expiry) { saveContacts(); dirty_contacts_expiry = 0; } }
/** Remove a contact from a device-UI action and PERSIST it (mirrors the
* companion app's CMD_REMOVE_CONTACT). The base removeContact() only drops it
@@ -952,6 +1057,13 @@ private:
uint8_t *sign_data;
uint32_t sign_data_len;
unsigned long dirty_contacts_expiry;
// Advert-driven contacts-save coalescer (see MyMesh::loop). On card-less devices a
// full contacts rewrite can trigger a multi-second SPIFFS GC that freezes the loop;
// constant advert refreshes would churn it. A change in the contact SET saves
// promptly; pure refreshes coalesce to CONTACTS_REFRESH_SAVE_INTERVAL. -1 forces
// the first save. Kept in sync by every MyMesh::saveContacts() call.
int _last_saved_contacts_n = -1;
uint32_t _next_contacts_refresh_save = 0;
TransportKey send_scope;
TransportKey _chan_scope_saved; // push/popChannelScope stash
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <helpers/AutoDiscoverRTCClock.h>
// Monotonic clock floor for outgoing protocol timestamps (issue #89 follow-up).
//
// MeshCore servers keep a per-client "newest timestamp seen" high-water mark and
// silently drop anything at-or-below it (replay guard) — and since beta_28 our
// room keep-alives refresh that mark every ~2 minutes. A device clock that steps
// BACKWARD (reboot without GPS/phone around, or a sync correcting a fast clock)
// therefore black-holes every login/post/keep-alive with zero feedback. None of
// our boards has a battery-backed RTC chip, so a power cycle without a time
// source is exactly such a step.
//
// getCurrentTime() ratchets: it never returns less than the highest value it has
// ever returned — and getCurrentTimeUnique() (which stamps every login, post and
// keep-alive in the core) builds directly on it. The ratchet is seeded from a
// persisted copy at boot and written back rate-capped (see the UITask wiring +
// touchPrefsGet/SetClockFloor), so it survives power loss.
//
// Two escape hatches keep a WRONG-future clock from sticking forever:
// • setCurrentTime() rejects anything below MIN_VALID_EPOCH, whatever the
// source — this centrally kills the 1902/1970-class garbage sets (GPS date
// bugs, unset system clocks) for every path: GPS NMEA, phone CMD, NTP.
// • a set that lands more than TRUSTED_BACK_CAP below the floor pulls the
// floor down to it: a >10 min backward correction means the floor itself was
// built on a bad clock, and freezing time for hours to bridge it would be
// worse than the one-time server re-lock it avoids (which the login skew
// warning surfaces to the user anyway).
class ClockFloorRTC : public AutoDiscoverRTCClock {
uint32_t _floor = 0;
public:
ClockFloorRTC(mesh::RTCClock& fallback) : AutoDiscoverRTCClock(fallback) {}
static constexpr uint32_t MIN_VALID_EPOCH = 1715770351UL; // 15 May 2024 — the core's own unset-clock seed
static constexpr uint32_t TRUSTED_BACK_CAP = 10UL * 60UL;
// High-side twin of MIN_VALID_EPOCH: an external RTC chip with corrupted/unset date
// registers can read as a *future* date (seen live: a T-Display P4's PCF8563 came back
// from a reboot asserting 2043). Future garbage is worse than past garbage — it passes
// the MIN check, latches the ratchet, and every timestamp sticks years ahead until a
// >10 min backward set arrives. Anything past ~(build year + 3) cannot be a real clock.
static constexpr uint32_t BUILD_YEAR =
(uint32_t)(__DATE__[7]-'0')*1000u + (uint32_t)(__DATE__[8]-'0')*100u +
(uint32_t)(__DATE__[9]-'0')*10u + (uint32_t)(__DATE__[10]-'0');
static constexpr uint32_t MAX_PLAUSIBLE_EPOCH = (BUILD_YEAR + 3u - 1970u) * 31557600u;
uint32_t getCurrentTime() override {
uint32_t t = AutoDiscoverRTCClock::getCurrentTime();
if (t > MAX_PLAUSIBLE_EPOCH) {
// Hardware clock is asserting garbage-future: ignore the read entirely and hold at
// the last trusted point (or the core's unset seed) until a real source sets us.
return (_floor >= MIN_VALID_EPOCH) ? _floor : MIN_VALID_EPOCH;
}
if (t < _floor) return _floor;
_floor = t;
return t;
}
void setCurrentTime(uint32_t time) override {
if (time < MIN_VALID_EPOCH || time > MAX_PLAUSIBLE_EPOCH) return; // garbage set — ignore, whatever the source
AutoDiscoverRTCClock::setCurrentTime(time);
if (_floor > time + TRUSTED_BACK_CAP) _floor = time;
}
// Boot-time seed from the persisted floor (only ever raises), and the getter
// the persister reads back. Benign u32 races: both cores may touch _floor.
// A poisoned persisted value (saved while the hw clock asserted garbage) is refused.
void seedFloor(uint32_t persisted) {
if (persisted > MAX_PLAUSIBLE_EPOCH) return;
if (persisted > _floor) _floor = persisted;
}
uint32_t getFloor() const { return _floor; }
};
+43 -6
View File
@@ -5,16 +5,35 @@
// makes MqttBridge a no-op stub on the Tanmatsu, and the real bridge below on all other boards.
MqttBridge mqtt_bridge;
#if !defined(HAS_TANMATSU) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 ----
#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 boards ----
#include <Preferences.h>
#include <WiFi.h>
#include <stdio.h>
#include <string.h>
#include <lwip/sockets.h>
#include "esp_random.h"
#include "mbedtls/gcm.h"
#include "mbedtls/md.h"
#include "mbedtls/base64.h"
// Only hand bytes to lwIP when the socket can accept them right now (zero-timeout
// select). Otherwise WiFiClient::write() blocks in 1 s select() retries against a
// broker that stopped ACKing — on the loop thread that is a visible UI freeze per
// publish. A refused write fails the publish; PubSubClient then flags the
// connection down and the (async) reconnect path takes over.
size_t MqttNbClient::write(const uint8_t* buf, size_t size) {
int fd_ = fd();
if (fd_ < 0) return 0;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd_, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
if (select(fd_ + 1, NULL, &wset, NULL, &tv) <= 0 || !FD_ISSET(fd_, &wset)) return 0;
return WiFiClient::write(buf, size);
}
// Read every field from the "mqtt" NVS namespace into the members. isKey() guards
// avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the
// USB-CDC companion stream (see TouchPrefsStore prefsGetStr note).
@@ -86,13 +105,27 @@ bool MqttBridge::reconnect() {
return ok;
}
// reconnect() blocks on DNS + TCP connect + CONNACK (2-10 s against an unreachable
// broker) — never run it on the loop thread, it drives LVGL. One-shot task instead;
// _connecting hands _mqtt/_wc to the task and back.
void MqttBridge::reconnectTask(void* arg) {
MqttBridge* self = (MqttBridge*)arg;
self->reconnect();
self->_connecting = false;
vTaskDelete(nullptr);
}
void MqttBridge::loop() {
if (!_enabled) return;
if (!_enabled || _connecting) return;
if (!_mqtt.connected()) {
uint32_t now = millis();
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
_lastReconnectMs = now;
reconnect();
_connecting = true;
if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this,
1, nullptr, 0) != pdPASS) {
_connecting = false; // task OOM — try again next interval
}
}
return;
}
@@ -128,7 +161,7 @@ bool MqttBridge::sealToB64(const char* plain, char* out, size_t outCap) {
}
void MqttBridge::pub(const char* subtopic, const char* json) {
if (!_enabled || !_mqtt.connected()) return;
if (!_enabled || _connecting || !_mqtt.connected()) return;
char topic[80];
snprintf(topic, sizeof(topic), "wadamesh/%s/%s", _nodeHex, subtopic);
if (_encOn) {
@@ -158,7 +191,7 @@ void MqttBridge::escapeJson(const char* src, char* dst, size_t dstLen) {
void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubDm || !_mqtt.connected()) return; // DMs are opt-in
if (!_enabled || !_pubDm || _connecting || !_mqtt.connected()) return; // DMs are opt-in
char keyHex[13] = {};
for (int i = 0; i < 6; ++i) snprintf(keyHex + i * 2, 3, "%02x", senderKey32[i]);
@@ -175,7 +208,7 @@ void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
void MqttBridge::publishChannel(int channelIdx, const char* channelName,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubChannel || !_mqtt.connected()) return; // channel publish toggle
if (!_enabled || !_pubChannel || _connecting || !_mqtt.connected()) return; // channel publish toggle
char safeName[48], safeText[300];
escapeJson(channelName, safeName, sizeof(safeName));
escapeJson(text, safeText, sizeof(safeText));
@@ -204,6 +237,10 @@ void MqttBridge::saveConfig(const char* host, uint16_t port,
}
void MqttBridge::reloadConfig() {
// A connect attempt may be in flight on the one-shot task; PubSubClient is not
// thread-safe, so wait it out (bounded: DNS + TCP + CONNACK <= ~10 s, and it
// only overlaps when Save lands inside an attempt window on a dead broker).
while (_connecting) delay(10);
if (_mqtt.connected()) _mqtt.disconnect();
loadConfig();
if (!_enabled) { Serial.println("[MQTT] disabled"); return; }
+19 -4
View File
@@ -3,12 +3,12 @@
#include <Arduino.h>
#if defined(HAS_TANMATSU)
// ---- MQTT DISABLED on the Tanmatsu (ESP32-P4) ----
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ----
// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient
// path this bridge uses is not wired for that and crashed at boot. Provide a no-op bridge with the
// SAME public API so the shared companion + settings code links without pulling PubSubClient into
// the P4 build. Re-enable once esp-hosted TCP is proven on the Tanmatsu.
// the P4 build. Re-enable once esp-hosted TCP is proven on the P4.
class MqttBridge {
public:
void begin(const char* /*nodeHex*/) {}
@@ -24,6 +24,17 @@ public:
#include <PubSubClient.h>
#include <WiFiClient.h>
// WiFiClient whose write() refuses to enter the framework's blocking-select path:
// bytes are only handed to lwIP when the socket can take them right now, so a
// wedged broker connection (half-open socket, peer stopped ACKing) fails the
// publish instead of stalling the loop thread ~1 s per write attempt. Same probe
// as the TCP/WS companion servers.
class MqttNbClient : public WiFiClient {
public:
size_t write(const uint8_t* buf, size_t size) override;
size_t write(uint8_t b) override { return write(&b, 1); }
};
// Publishes received mesh messages to an MQTT broker over the existing WiFi link.
//
// PRIVACY MODEL (mirrors how Meshtastic's MQTT module guards content):
@@ -73,7 +84,7 @@ public:
void reloadConfig();
private:
WiFiClient _wc;
MqttNbClient _wc;
PubSubClient _mqtt{_wc};
char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0'
bool _enabled = false;
@@ -87,12 +98,16 @@ private:
bool _encOn = false;
uint16_t _port = 1883;
uint32_t _lastReconnectMs = 0;
// True while the one-shot connect task owns _mqtt/_wc. The loop thread must
// not touch either until it clears (PubSubClient is not thread-safe).
volatile bool _connecting = false;
static const uint32_t RECONNECT_INTERVAL_MS = 15000;
void loadConfig(); // shared by begin() / reloadConfig()
void deriveKey(); // _key = SHA-256(_psk); sets _encOn
bool reconnect();
static void reconnectTask(void* arg); // one-shot task body wrapping reconnect()
void pub(const char* subtopic, const char* json); // seals if _encOn
bool sealToB64(const char* plain, char* out, size_t outCap);
static void escapeJson(const char* src, char* dst, size_t dstLen);
@@ -1,12 +1,16 @@
#include "MultiTransportCompanionInterface.h"
#include <helpers/RepeaterTcpOtaEmit.h>
#include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots
#include "WebMirror.h" // web UI mirror bridge (served over the WS server)
#include <string.h>
// Companion push code for the per-packet RX log (matches MyMesh.cpp). It is kept OFF
// the BLE transport in writeFrameToAll — see the note there (issues #46, #54).
// the BLE transport in writeFrameToAll — see the note there (issues #46, #54) — EXCEPT
// for one-shot passes armed via bleAllowNextRxLog() (echoes of our own sends, #94).
#define PUSH_CODE_LOG_RX_DATA 0x88
bool MultiTransportCompanionInterface::s_ble_rxlog_once = false;
MultiTransportCompanionInterface::MultiTransportCompanionInterface()
: _tcp_port(0), _ws_port(0), _tcp_started(false), _ws_started(false), _tcp_enabled(true), _isEnabled(false), _broadcast(false), _last_reply_target(REPLY_TARGET_USB), _ota_tcp_suspended(false), _ota_ws_suspended(false), _ota_ws_listen_paused(false)
#ifdef BLE_PIN_CODE
@@ -29,6 +33,19 @@ void MultiTransportCompanionInterface::begin(Stream& usb_serial, uint16_t tcp_po
}
void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) {
#if defined(HAS_TDISPLAY_P4)
// ESP-AT allows ONE listening port, so the router server (_p4_srv) owns it and dispatches
// each inbound connection by first byte — see p4RouteClients(). _tcp/_ws never listen
// themselves here; started flags only mark them willing to adopt routed clients.
if (_tcp_enabled && !_tcp_started && _tcp_port != 0) {
_p4_srv.begin(_tcp_port);
_tcp_started = true;
}
if (_tcp_enabled && !_ws_started && _ws_port != 0 && wifi_connected) {
_ws_started = true; // enables tickHandshake + the mirror stream task; _ws itself stays listen-less
printf("[WS] P4 router: web UI shares TCP:%u (first-byte dispatch)\n", (unsigned)_tcp_port);
}
#else
if (_tcp_enabled && !_tcp_started && _tcp_port != 0) {
_tcp.begin(_tcp_port);
_tcp_started = true;
@@ -38,10 +55,82 @@ void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) {
_ws.begin(_ws_port);
_ws_started = true;
}
#endif
}
#if defined(HAS_TDISPLAY_P4)
// One AT listener serves BOTH protocols. Accept every inbound connection here, peek its first
// byte, and hand the socket to the right server: HTTP requests start with an ASCII verb
// (GET/HEAD/POST/PUT/OPTIONS/DELETE...) and cover the web viewer page, the mirror/terminal
// WebSockets and the WS companion; companion frames from the phone app always start with '<'.
// A peer that stays silent gets the legacy benefit of the doubt after 3 s: some companion
// clients connect and only listen for pushes without ever sending.
void MultiTransportCompanionInterface::p4RouteClients() {
if (!_tcp_started && !_ws_started) return;
const int NPEND = (int)(sizeof(_p4_pend) / sizeof(_p4_pend[0]));
while (_p4_srv.hasClient()) {
int slot = -1;
for (int i = 0; i < NPEND; i++) {
if (!_p4_pend[i].used) {
slot = i;
break;
}
}
if (slot < 0) break; // all pending slots mid-peek; the accept FIFO holds the rest
WiFiClient incoming = _p4_srv.accept();
if (!incoming) continue;
_p4_pend[slot].c = incoming;
_p4_pend[slot].ms = millis();
_p4_pend[slot].used = true;
}
for (int i = 0; i < NPEND; i++) {
if (!_p4_pend[i].used) continue;
WiFiClient& c = _p4_pend[i].c;
if (!c.connected()) {
c.stop();
_p4_pend[i].used = false;
continue;
}
int b = c.peek();
bool is_http = (b == 'G' || b == 'H' || b == 'P' || b == 'O' || b == 'D');
if (b < 0 && millis() - _p4_pend[i].ms < 3000) continue; // no first byte yet
if (is_http && _ws_started) {
_ws.adoptClient(c);
} else if (!is_http && _tcp_started) {
_tcp.adoptClient(c);
} else {
c.stop(); // the matching server is suspended (OTA) — refuse politely
}
_p4_pend[i].used = false;
_p4_pend[i].c = WiFiClient(); // drop our shared link ref; the adopting server holds its own
}
}
#endif
// Web-mirror streaming runs on CORE 0 (parallel to the UI on core 1) so the browser's
// socket stays fed at full link speed even while core 1 is busy with a heavy LVGL render.
// The WS client array is guarded by WebSocketCompanionServer's recursive mutex, so this
// task and the main-loop companion/handshake code never corrupt each other. serviceMirror
// is non-blocking, so the mutex is held only briefly and cross-core contention is tiny.
static void wsMirrorStreamTask(void* arg) {
WebSocketCompanionServer* ws = static_cast<WebSocketCompanionServer*>(arg);
for (;;) {
ws->serviceMirror(g_web_mirror);
vTaskDelay(pdMS_TO_TICKS(g_web_mirror.clients() > 0 ? 2 : 25)); // fast when a browser is watching, idle otherwise
}
}
void MultiTransportCompanionInterface::tickWebSocketHandshake() {
if (_ws_started) _ws.tickHandshake();
#if defined(HAS_TDISPLAY_P4)
p4RouteClients(); // dispatch newly accepted AT-listener connections to _tcp / _ws
#endif
if (_ws_started) {
_ws.tickHandshake(); // accept + WS handshake stay on core 1 (the main loop)
if (!_mirror_task) { // spawn the core-0 streamer once, on first tick after the WS server is up
xTaskCreatePinnedToCore(wsMirrorStreamTask, "ws_mirror", 4096, &_ws, 2,
reinterpret_cast<TaskHandle_t*>(&_mirror_task), 0);
}
}
}
void MultiTransportCompanionInterface::stopTcpServer() {
@@ -53,6 +142,16 @@ void MultiTransportCompanionInterface::stopTcpServer() {
_tcp.stop();
_tcp_started = false;
}
#if defined(HAS_TDISPLAY_P4)
_p4_srv.stop(); // the router owns the AT listener (see startTcpServer)
for (size_t i = 0; i < sizeof(_p4_pend) / sizeof(_p4_pend[0]); i++) {
if (_p4_pend[i].used) {
_p4_pend[i].c.stop();
_p4_pend[i].c = WiFiClient();
_p4_pend[i].used = false;
}
}
#endif
_tcp_enabled = false;
}
@@ -228,14 +327,22 @@ void MultiTransportCompanionInterface::restoreAfterHttpOta() {
}
#endif
if (_ota_tcp_suspended) {
#if defined(HAS_TDISPLAY_P4)
_tcp_started = true; // the router still owns the AT listener; just resume adopting
#else
_tcp.begin(_tcp_port);
_tcp_started = true;
#endif
_ota_tcp_suspended = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion TCP server");
}
if (_ota_ws_suspended) {
#if defined(HAS_TDISPLAY_P4)
_ws_started = true; // ditto — never begin a second AT listener on _ws_port
#else
_ws.begin(_ws_port);
_ws_started = true;
#endif
_ota_ws_suspended = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion WebSocket server");
}
@@ -334,10 +441,16 @@ size_t MultiTransportCompanionInterface::writeFrameToAll(const uint8_t src[], si
all_ok = false;
#ifdef BLE_PIN_CODE
// The per-packet RX log floods BLE's ~16 frames/sec budget on a busy mesh, starving
// the frames that matter — chat messages + admin responses (issues #46, #54). The
// MeshCore app doesn't consume the RX log, so keep it OFF BLE; USB/TCP/WS (far more
// bandwidth) still receive it for the on-device Monitor app / diagnostics.
const bool skip_ble = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
// the frames that matter — chat messages + admin responses (issues #46, #54). So it
// is kept OFF BLE (USB/TCP/WS have the bandwidth) — EXCEPT when MyMesh::logRxRaw
// armed a one-shot pass because this frame is an echo of OUR OWN flood send: the
// app's "Repeats heard" is computed exactly from those (it broke on BLE when the
// blanket skip landed in beta_23 — issue #94), and a few echoes per send are
// nowhere near the flood that caused #46/#54.
const bool rxlog = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
const bool ble_pass = rxlog && s_ble_rxlog_once;
if (rxlog) s_ble_rxlog_once = false; // consume the one-shot either way
const bool skip_ble = rxlog && !ble_pass;
if (!skip_ble && _ble_begun && _ble_enabled && _ble.isConnected() && _ble.writeFrame(src, len) != len)
all_ok = false;
#endif
@@ -39,7 +39,16 @@ public:
void enableBle() override;
void disableBle() override;
bool isBleEnabled() const override { return _ble_enabled; }
#if defined(HAS_TDISPLAY_P4)
// T-Display P4: the factory C6 ESP-AT firmware has its BLE advertising commands stubbed
// (BLEADVDATA/ADVSTART all ERROR — Meck-P4 hit the same wall and ships Wi-Fi companion), and
// reflashing the C6 is ruled out for users. Phone pairing on this board = Wi-Fi (TCP:5000) or
// USB; the UI hides its Bluetooth toggles off this. The MeshCore GATT table stays pre-provisioned
// on the C6 (c6_at) so a future LilyGo AT firmware with working advertising lights up cheaply.
bool hasBleCapability() const override { return false; }
#else
bool hasBleCapability() const override { return true; }
#endif
bool getBlePeerAddress(char* buf, size_t len) const override;
#endif
@@ -47,7 +56,13 @@ public:
void disableTcp() override;
bool isTcpEnabled() const override { return _tcp_enabled; }
bool isWsStarted() const override { return _ws_started; }
#if defined(HAS_TDISPLAY_P4)
// The web UI shares the companion TCP port (single AT listener + first-byte router),
// so that's the port to show/open — _ws_port is never listened on here.
uint16_t getWsPort() const override { return _tcp_port; }
#else
uint16_t getWsPort() const override { return _ws_port; }
#endif
int getWsConnectedCount() const override { return _ws.connectedCount(); }
/** Accept WebSocket clients / handshakes; call from main loop. */
void tickWebSocketHandshake() override;
@@ -64,6 +79,13 @@ public:
bool isWriteBusy() const override;
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t writeFrameToAll(const uint8_t src[], size_t len) override;
/** One-shot: let the NEXT RX-log frame (PUSH_CODE_LOG_RX_DATA) through to BLE.
* RX logs are normally kept OFF BLE (#46/#54 congestion), but the app's
* "Repeats heard" is computed from them — MyMesh::logRxRaw calls this right
* before writeFrameToAll when the frame is an echo of OUR OWN send, so BLE
* gets exactly the few frames the app needs and none of the flood (#94).
* Same-thread set-then-consume (both in the mesh loop), so no atomics. */
static void bleAllowNextRxLog() { s_ble_rxlog_once = true; }
bool companionUnsolicitedPushesBroadcastToAll() const override { return _broadcast; }
size_t checkRecvFrame(uint8_t dest[]) override;
@@ -73,6 +95,10 @@ public:
int getReplyTarget() const override { return _last_reply_target; }
void setReplyTarget(int target) override { _last_reply_target = target; }
private:
static bool s_ble_rxlog_once; // one-shot BLE pass for the next RX-log frame (#94)
public:
private:
int _clientIdSlot() const;
static const size_t _max_client_id_len = 32;
@@ -84,6 +110,19 @@ private:
uint16_t _ws_port;
bool _tcp_started;
bool _ws_started;
#if defined(HAS_TDISPLAY_P4)
// ESP-AT allows ONE listening port, so this router server owns it: every inbound
// connection is accepted here, held briefly, and dispatched on its first byte —
// an HTTP verb ('G','H','P','O','D') goes to _ws (web mirror/VNC/remote/terminal +
// WS companion), everything else ('<' companion frames) to _tcp (phone app).
// _tcp/_ws never begin() their own listeners on this board (their C6Server stays
// inert); they receive clients via adoptClient().
WiFiServer _p4_srv;
struct P4PendingClient { WiFiClient c; uint32_t ms = 0; bool used = false; };
P4PendingClient _p4_pend[3];
void p4RouteClients();
#endif
void* _mirror_task = nullptr; // TaskHandle_t for the core-0 web-mirror stream task (created once)
bool _tcp_enabled; // if false, startTcpServer() no-ops until enableTcp()
bool _isEnabled;
bool _broadcast; // if true, also send responses to all other clients
+2
View File
@@ -30,6 +30,8 @@ void SdNvsPrefs::useFile(fs::FS* fs, const char* dir) {
static bool fileMode() { return s_file_mode && s_file_fs; }
static fs::FS* activeFs() { return fileMode() ? s_file_fs : (fs::FS*)&SD; }
fs::FS* SdNvsPrefs::fileFs() { return fileMode() ? s_file_fs : nullptr; }
// 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.
+1
View File
@@ -29,6 +29,7 @@ namespace fs { class FS; } // forward decl only — keep this header's layout
class SdNvsPrefs {
public:
static void useFile(fs::FS* fs, const char* dir); // route prefs to <dir>/<ns>.kv
static fs::FS* fileFs(); // the active file-mode fs, or nullptr when NVS-backed
bool begin(const char* ns, bool readOnly = false);
void end();
+72 -21
View File
@@ -1,20 +1,51 @@
#include "TCPCompanionServer.h"
#include <lwip/sockets.h>
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
#define RECV_STATE_LEN1_FOUND 2
#define RECV_STATE_LEN2_FOUND 3
#define TCP_WRITE_TIMEOUT_MS 120
#define TCP_WEDGED_DROP_MS 10000
// True when lwIP can accept bytes for this socket right now. select() only reports
// writable once at least TCP_SNDLOWAT (~2.8 KB) of send buffer is free, which holds a
// whole companion frame (MAX_FRAME_SIZE + header), so a write issued after this probe
// completes without blocking. Without the probe, WiFiClient::write() sits in 1 s
// select() waits (up to 10) against a peer that stopped ACKing — a phone asleep in a
// pocket keeps its socket "connected" but unwritable, and every mesh-RX push then
// stalls the loop thread (and the UI) for seconds.
static bool socketWritableNow(WiFiClient& client) {
#if defined(HAS_TDISPLAY_P4)
// C6/AT transport: no lwIP fd to select() on (fd() is -1 by design). Writes go through the AT
// worker whose CIPSEND exchange is itself bounded (the C6 buffers TX), so liveness is the only
// cheap pre-check here.
return client.connected();
#else
int fd = client.fd();
if (fd < 0) return false;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset);
#endif
}
static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) {
size_t sent = 0;
uint32_t start = millis();
while (sent < len) {
if (!client.connected()) return false;
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
if (socketWritableNow(client)) {
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
}
}
if (millis() - start >= timeout_ms) return false;
delay(1);
@@ -26,6 +57,7 @@ TCPCompanionServer::TCPCompanionServer() : _server(WiFiServer()), _port(0), _pol
for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) {
_clients[i].state = RECV_STATE_IDLE;
_clients[i].in_use = false;
_clients[i].stall_ms = 0;
}
}
@@ -44,25 +76,32 @@ void TCPCompanionServer::stop() {
}
}
void TCPCompanionServer::adoptClient(WiFiClient& incoming) {
// Slot-insert an already-accepted connection. Used by our own accept loop below AND by the
// T-Display P4's first-byte router (one shared AT listener dispatches companion vs web).
int slot = -1;
for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) {
if (!_clients[i].in_use) {
slot = i;
break;
}
}
if (slot >= 0) {
_clients[slot].client = incoming;
_clients[slot].in_use = true;
_clients[slot].state = RECV_STATE_IDLE;
_clients[slot].rx_len = 0;
_clients[slot].stall_ms = 0;
} else {
incoming.stop();
}
}
void TCPCompanionServer::acceptNewClients() {
while (_server.hasClient()) {
WiFiClient incoming = _server.accept();
if (!incoming) continue;
int slot = -1;
for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) {
if (!_clients[i].in_use) {
slot = i;
break;
}
}
if (slot >= 0) {
_clients[slot].client = incoming;
_clients[slot].in_use = true;
_clients[slot].state = RECV_STATE_IDLE;
_clients[slot].rx_len = 0;
} else {
incoming.stop();
}
adoptClient(incoming);
}
}
@@ -81,6 +120,7 @@ void TCPCompanionServer::disconnectClient(int client_index) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].state = RECV_STATE_IDLE;
_clients[client_index].stall_ms = 0;
}
}
@@ -160,10 +200,21 @@ size_t TCPCompanionServer::writeToClient(int client_index, const uint8_t src[],
WiFiClient* cl = &_clients[client_index].client;
if (!writeAllBytes(*cl, hdr, 3, TCP_WRITE_TIMEOUT_MS) ||
!writeAllBytes(*cl, src, len, TCP_WRITE_TIMEOUT_MS)) {
// Return 0 so caller can retry (e.g. contact list). Do not disconnect on transient
// buffer full; companion layer retries and will complete the sequence.
// Return 0 so caller can retry (e.g. contact list). A transient full buffer must
// not disconnect — the companion layer retries and completes the sequence. But a
// peer that stopped ACKing (phone asleep, walked out of range) keeps a half-open
// socket "connected" indefinitely while staying unwritable; drop it once the
// failure has been continuous for TCP_WEDGED_DROP_MS so it stops eating the
// write-timeout budget on every pushed frame.
TCPClientState* c = &_clients[client_index];
if (c->stall_ms == 0) {
c->stall_ms = millis() | 1;
} else if (millis() - c->stall_ms >= TCP_WEDGED_DROP_MS) {
disconnectClient(client_index);
}
return 0;
}
_clients[client_index].stall_ms = 0;
return len;
}
+15
View File
@@ -3,6 +3,16 @@
#include <helpers/BaseSerialInterface.h>
#include <WiFi.h>
#if defined(HAS_TDISPLAY_P4)
// T-Display P4: TCP/IP lives on the C6 (ESP-AT over SDIO) — rebind the server + client types to
// the c6_at facades BEFORE the class definitions below so every TU sees one consistent layout.
// Inbound listening = AT+CIPSERVER (single port; it's this companion server's).
#include <C6WifiShim.h>
#include <C6Socket.h>
#define WiFiClient C6Client
#define WiFiServer C6Server
#endif
#ifndef TCP_COMPANION_MAX_CLIENTS
#define TCP_COMPANION_MAX_CLIENTS 3
#endif
@@ -15,6 +25,7 @@ struct TCPClientState {
uint16_t rx_len;
uint8_t rx_buf[MAX_FRAME_SIZE];
bool in_use;
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
};
// Multi-client TCP server for companion protocol. Same frame format as serial.
@@ -42,6 +53,10 @@ public:
int connectedCount() const;
void disconnectClient(int client_index);
// Slot-insert an already-accepted connection (stops it if no slot is free). Lets an
// external router (T-Display P4: one shared AT listener) hand us companion clients.
void adoptClient(WiFiClient& incoming);
private:
WiFiServer _server;
mutable TCPClientState _clients[TCP_COMPANION_MAX_CLIENTS];
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more