Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a51e7e504 | ||
|
|
e4aa702251 | ||
|
|
6d1d38196e | ||
|
|
19107bf7e0 | ||
|
|
e26b406e02 | ||
|
|
f5d8cfa390 | ||
|
|
3b1a07dd93 | ||
|
|
31cb4c38a8 | ||
|
|
8dc02a2b23 | ||
|
|
47ba9e010c | ||
|
|
0b1349aa00 | ||
|
|
2b554940af | ||
|
|
3779724000 | ||
|
|
71dff36904 | ||
|
|
35d0daf203 | ||
|
|
4df3dce481 | ||
|
|
e7c7df65fa | ||
|
|
4e6a9e0108 | ||
|
|
2d6a88bd1f | ||
|
|
b1ad449147 | ||
|
|
b1b689c48f | ||
|
|
b525f75335 | ||
|
|
3d6e6f0963 | ||
|
|
5e4f9a221e | ||
|
|
57f206220d | ||
|
|
756bb9f821 | ||
|
|
99423e5bf8 | ||
|
|
cab0c81208 | ||
|
|
b67f559d15 | ||
|
|
85e8681fef | ||
|
|
3fbc3e3582 | ||
|
|
22a08c6df6 | ||
|
|
041dd60b8d | ||
|
|
36e49d4d9e | ||
|
|
ae02fb6238 | ||
|
|
fd0b952620 | ||
|
|
f029dcc744 | ||
|
|
be0a49255c | ||
|
|
2353c56adc | ||
|
|
0adcd26b78 | ||
|
|
53a60dfbb2 | ||
|
|
e5cd766f73 | ||
|
|
5c97d02fbe | ||
|
|
01833d0365 | ||
|
|
a95f976262 | ||
|
|
2540e230c6 | ||
|
|
6073b6fa4e | ||
|
|
57802eba3d | ||
|
|
b16206bf7d | ||
|
|
c4c28d493d | ||
|
|
8e7d6ae902 | ||
|
|
a87ebe1e81 | ||
|
|
69ffe71bfe | ||
|
|
e617bf306c | ||
|
|
687eb3865c | ||
|
|
9881bb0038 | ||
|
|
4b0c2e9de1 | ||
|
|
43f040ec0a | ||
|
|
c22c76b704 | ||
|
|
79df5281e4 | ||
|
|
308be40b96 | ||
|
|
8a2825e7fd | ||
|
|
5f40653e58 | ||
|
|
d787d77365 | ||
|
|
831959ea5c | ||
|
|
4df391a283 | ||
|
|
3f375f2fe4 | ||
|
|
9da367d0bc | ||
|
|
01a5238662 | ||
|
|
1514e7638c | ||
|
|
8c7fb6af5c | ||
|
|
c9033a3823 | ||
|
|
946e73bb0b | ||
|
|
326d34aaec | ||
|
|
d2fe2ed728 | ||
|
|
9547ce6ede | ||
|
|
49a8f31b8c | ||
|
|
ded72bb91a | ||
|
|
cb08116eae | ||
|
|
f0a7265dec | ||
|
|
814c8f5941 | ||
|
|
76cd025bf5 | ||
|
|
d20241627c | ||
|
|
0b886e4fee | ||
|
|
a49726d4ce | ||
|
|
6aa1147eb3 | ||
|
|
d9f45405d5 | ||
|
|
f04e01769d | ||
|
|
9785d8b7d1 | ||
|
|
bf96253308 | ||
|
|
65a485d28c | ||
|
|
b2950a7f88 | ||
|
|
e95dd755e1 | ||
|
|
2532ddc7ba | ||
|
|
6309503060 | ||
|
|
b7bebd508b | ||
|
|
b470097534 | ||
|
|
1aab86b530 | ||
|
|
bc129b5ffb | ||
|
|
8133a47769 | ||
|
|
4bbfcb5550 | ||
|
|
9bcad3c2c2 | ||
|
|
bf5c6828a3 | ||
|
|
facbe49b01 | ||
|
|
3c845115bf | ||
|
|
f59d9f4b23 | ||
|
|
48e77933e2 | ||
|
|
49a6f80419 | ||
|
|
667436fdd2 | ||
|
|
d59cf5d9ae | ||
|
|
e682fbcb5c | ||
|
|
db7a43fcc4 | ||
|
|
cca9a444e8 | ||
|
|
381658ab2a | ||
|
|
4846d4fa51 | ||
|
|
c322b1fa3a | ||
|
|
d964c029d9 | ||
|
|
d2d3060051 | ||
|
|
e2eb0d3118 | ||
|
|
dd19cbe52d | ||
|
|
aa598d626b | ||
|
|
862ec32f80 | ||
|
|
1eeeb267d2 | ||
|
|
2b30ca2a5a | ||
|
|
dc4ba96825 | ||
|
|
f18929b312 | ||
|
|
df7f9bc2ab | ||
|
|
2a34ad476e | ||
|
|
0ea242c81f | ||
|
|
b3c638842e | ||
|
|
a8f8a79d3f | ||
|
|
30ed2d77f6 | ||
|
|
c87cd3cc98 | ||
|
|
996eac21fd | ||
|
|
3a3a37d193 | ||
|
|
05b24f01ad | ||
|
|
dcf3958cc7 | ||
|
|
6de8346e7f | ||
|
|
f61b8fbbcf | ||
|
|
be32315b98 | ||
|
|
fe4ed029f5 | ||
|
|
53251c33e0 | ||
|
|
f9c3db99c2 | ||
|
|
230b0936bc | ||
|
|
5da14d8bda | ||
|
|
32e1668406 | ||
|
|
d4ca18335e | ||
|
|
259aa36e40 | ||
|
|
78a6c01f95 | ||
|
|
51b513b181 | ||
|
|
6dc09b3185 | ||
|
|
b3004b85eb | ||
|
|
a7d435b03b | ||
|
|
8718e49827 | ||
|
|
641700fad0 | ||
|
|
38c0e7a016 | ||
|
|
3e71867e71 | ||
|
|
0be7c20600 | ||
|
|
ae690e3fbb | ||
|
|
ad98f661cc | ||
|
|
546cc9079a | ||
|
|
9d5df3001d | ||
|
|
82b6d3b56d | ||
|
|
a38ea1e3bb | ||
|
|
be1996ce77 |
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -42,40 +42,54 @@ if os.path.exists(nf):
|
||||
notes = "\n".join(l.strip() for l in open(nf, encoding="utf-8")
|
||||
if l.strip() and not l.strip().startswith("#"))
|
||||
|
||||
# Both touch boards are ESP32-S3, single touch-UI ("gui") role. ota_0 sits at
|
||||
# 0x10000 on both (partitions_t*_touch.csv), so flash-update@0x10000 is correct.
|
||||
# Every wadamesh tile here is ESP32-S3, touch-UI ("gui") role. ota_0 sits at
|
||||
# 0x10000 on every touch env (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.
|
||||
#
|
||||
# A board may carry `variants` instead of a single `slug`: one "gui" firmware entry is emitted per
|
||||
# variant, all under the one tile. The T-Lora Pager ships with either radio, and Mesh America folds both
|
||||
# under a single "LilyGo T-Lora Pager" tile distinguished by a [RADIO] title prefix — matching how their
|
||||
# own stock pager entries are labelled ([SX1262]/[LR1121]), so ours sit naturally alongside them.
|
||||
BOARDS = [
|
||||
{"name": "Heltec v4 + Expansion Kit (Touch)", "slug": "heltec-v4-tft"},
|
||||
{"name": "LilyGo T-Deck", "slug": "tdeck"},
|
||||
{"name": "LilyGo T-Lora Pager", "variants": [
|
||||
{"title": "[LR1121] Touch UI", "slug": "tlora-pager-lr1121"},
|
||||
{"title": "[SX1262] Touch UI", "slug": "tlora-pager-sx1262"},
|
||||
]},
|
||||
]
|
||||
|
||||
def _firmware(title, slug):
|
||||
merged, appbin = "wadamesh-%s-merged.bin" % slug, "wadamesh-%s.bin" % slug
|
||||
return {
|
||||
"role": "gui",
|
||||
"title": title,
|
||||
"version": {TAG: {
|
||||
"notes": notes,
|
||||
"files": [
|
||||
{"type": "flash-wipe", "name": merged,
|
||||
"url": "%s/%s" % (BASE, merged),
|
||||
"title": "Full install (bootloader + firmware)"},
|
||||
{"type": "flash-update", "name": appbin,
|
||||
"url": "%s/%s" % (BASE, appbin),
|
||||
"title": "Update (app only)"},
|
||||
],
|
||||
}},
|
||||
}
|
||||
|
||||
def device(b):
|
||||
merged, appbin = "wadamesh-%s-merged.bin" % b["slug"], "wadamesh-%s.bin" % b["slug"]
|
||||
# `variants` (per-radio builds) fold under one tile; a plain board is the single default variant.
|
||||
variants = b.get("variants") or [{"title": "Touch UI", "slug": b["slug"]}]
|
||||
return {
|
||||
"maker": "wadamesh",
|
||||
"class": "wadamesh",
|
||||
"name": b["name"],
|
||||
"type": "esp32",
|
||||
"tooltip": "<img class='device' src='%s'>" % ICON,
|
||||
"firmware": [{
|
||||
"role": "gui",
|
||||
"title": "Touch UI",
|
||||
"version": {TAG: {
|
||||
"notes": notes,
|
||||
"files": [
|
||||
{"type": "flash-wipe", "name": merged,
|
||||
"url": "%s/%s" % (BASE, merged),
|
||||
"title": "Full install (bootloader + firmware)"},
|
||||
{"type": "flash-update", "name": appbin,
|
||||
"url": "%s/%s" % (BASE, appbin),
|
||||
"title": "Update (app only)"},
|
||||
],
|
||||
}},
|
||||
}],
|
||||
"firmware": [_firmware(v["title"], v["slug"]) for v in variants],
|
||||
}
|
||||
|
||||
# Optional "what's this?" blurb Mesh America shows on a hover tooltip next to our provider badge
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
"role": "gui",
|
||||
"title": "Touch UI",
|
||||
"version": {
|
||||
"beta_33": {
|
||||
"notes": "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.\nThe 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.\nFavoriting a contact when the favorites list is full (16) now says \"Favorites full (max 16)\" instead of the incorrect \"Removed from favorites\".",
|
||||
"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_33/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_33/wadamesh-heltec-v4-tft.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-heltec-v4-tft.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
@@ -49,19 +49,74 @@
|
||||
"role": "gui",
|
||||
"title": "Touch UI",
|
||||
"version": {
|
||||
"beta_33": {
|
||||
"notes": "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.\nThe 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.\nFavoriting a contact when the favorites list is full (16) now says \"Favorites full (max 16)\" instead of the incorrect \"Removed from favorites\".",
|
||||
"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_33/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_33/wadamesh-tdeck.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tdeck.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"maker": "wadamesh",
|
||||
"class": "wadamesh",
|
||||
"name": "LilyGo T-Lora Pager",
|
||||
"type": "esp32",
|
||||
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
|
||||
"firmware": [
|
||||
{
|
||||
"role": "gui",
|
||||
"title": "[LR1121] Touch UI",
|
||||
"version": {
|
||||
"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-tlora-pager-lr1121-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121-merged.bin",
|
||||
"title": "Full install (bootloader + firmware)"
|
||||
},
|
||||
{
|
||||
"type": "flash-update",
|
||||
"name": "wadamesh-tlora-pager-lr1121.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-lr1121.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "gui",
|
||||
"title": "[SX1262] Touch UI",
|
||||
"version": {
|
||||
"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-tlora-pager-sx1262-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262-merged.bin",
|
||||
"title": "Full install (bootloader + firmware)"
|
||||
},
|
||||
{
|
||||
"type": "flash-update",
|
||||
"name": "wadamesh-tlora-pager-sx1262.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_46/wadamesh-tlora-pager-sx1262.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -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 & web app</a>
|
||||
<a href="#web">Web browser</a>
|
||||
<div class="grp">Reference</div>
|
||||
<a href="#updates">Updates & test builds</a>
|
||||
<a href="#settings-pages">Settings pages</a>
|
||||
@@ -203,7 +210,7 @@
|
||||
<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>
|
||||
@@ -266,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 & web app</h2>
|
||||
<div class="row">
|
||||
<div class="prose">
|
||||
<p>Over Wi-Fi, a WADAMESH device can put its screen — or a whole browser app — 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 → 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) — 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> — 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> — 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 — 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 → 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 — 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 — 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 — 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 — 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 — add with a tap</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_settings.png" alt="Settings in the web app"><figcaption>Settings — including live radio</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_terminal.png" alt="Terminal in the web app"><figcaption>Terminal — 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 — 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> — no JavaScript, no images, no ads. Pages load small and read clean.</li>
|
||||
<li><b>Tappable links</b> — 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 & private</b> — 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 MB Heltec V4 it is hidden — that board can't complete the secure handshake — 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 — 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 V4 — 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 — 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 — 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 & test builds</h2>
|
||||
<div class="row">
|
||||
@@ -286,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 & radio</div>
|
||||
<div class="scards">
|
||||
@@ -357,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> — 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 & power</div>
|
||||
|
||||
@@ -149,12 +149,56 @@
|
||||
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}
|
||||
/* device selector — grouped by manufacturer, collapsible, searchable */
|
||||
.devsel{max-width:840px; margin:20px auto 0}
|
||||
.devsearch{position:relative; max-width:460px; margin:0 auto 18px}
|
||||
.devsearch>svg{position:absolute; left:15px; top:50%; transform:translateY(-50%); width:17px; height:17px; color:var(--mut); pointer-events:none}
|
||||
.devsearch input{width:100%; box-sizing:border-box; font:inherit; font-size:.95rem; padding:11px 42px; border:1px solid var(--line); border-radius:11px; background:#fff; color:var(--ink)}
|
||||
.devsearch input::-webkit-search-cancel-button{display:none}
|
||||
.devsearch input:focus{outline:none; border-color:var(--teal); box-shadow:0 0 0 3px rgba(21,182,166,.15)}
|
||||
.devsearch-x{position:absolute; right:8px; top:50%; transform:translateY(-50%); width:26px; height:26px; border:0; border-radius:50%; background:var(--bg2); color:var(--mut); font-size:1.15rem; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center}
|
||||
.devsearch-x:hover{color:var(--ink); background:var(--line)}
|
||||
.devsearch-empty{text-align:center; color:var(--mut); font-size:.9rem; margin:12px 0 0}
|
||||
.devsearch-empty button{font:inherit; font-size:.9rem; color:var(--teal-ink); background:none; border:0; padding:0; cursor:pointer; text-decoration:underline}
|
||||
.mfr{border:1px solid var(--line); border-radius:14px; background:var(--bg2); margin:0 auto 12px; overflow:hidden}
|
||||
.mfr[hidden]{display:none}
|
||||
.mfr>summary{list-style:none; cursor:pointer; user-select:none; display:flex; align-items:center; gap:11px; padding:15px 18px; font-weight:700; font-size:1.05rem; color:var(--ink)}
|
||||
.mfr>summary::-webkit-details-marker{display:none}
|
||||
.mfr>summary:hover{color:var(--teal-ink)}
|
||||
.mfr-name{flex:1 1 auto}
|
||||
.mfr-count{font-weight:600; font-size:.78rem; color:var(--mut); letter-spacing:.02em}
|
||||
.mfr .chev{width:18px; height:18px; color:var(--mut); flex:none; transition:transform .18s}
|
||||
.mfr[open]>summary .chev{transform:rotate(180deg)}
|
||||
.mfr-boards{max-width:none; margin:0; padding:2px 16px 18px}
|
||||
.board[hidden]{display:none}
|
||||
/* "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}
|
||||
.verpick summary{cursor:pointer; padding:12px 16px; font-weight:600; font-size:.92rem; color:var(--ink); list-style:none; user-select:none}
|
||||
@@ -345,16 +389,102 @@
|
||||
<div class="step"><div class="num">2</div><div><h4>Pick your board & install</h4><p>Hit <b>Install</b> below, choose the serial port, and let it flash. It does a clean full install.</p></div></div>
|
||||
<div class="step"><div class="num">3</div><div><h4>You're on the mesh</h4><p>The device reboots into WADAMESH. Set your name + region in the first-run wizard.</p></div></div>
|
||||
</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>
|
||||
<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>
|
||||
<div class="board"><h4>Tanmatsu</h4><p>ESP32-P4 keyboard handheld.</p>
|
||||
<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="devsel" id="devsel">
|
||||
<div class="devsearch">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.6-3.6"/></svg>
|
||||
<input type="search" id="devSearch" autocomplete="off" placeholder="Search by device or maker — T-Deck, Heltec, P4…" aria-label="Search devices">
|
||||
<button type="button" id="devClearX" class="devsearch-x" data-devclear hidden aria-label="Clear search">×</button>
|
||||
</div>
|
||||
<p class="devsearch-empty" data-devempty hidden>No devices match “<span data-devq></span>”. <button type="button" data-devclear>Clear search</button></p>
|
||||
|
||||
<details class="mfr" open data-mfr="LilyGo">
|
||||
<summary><span class="mfr-name">LilyGo</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="lilygo t-deck tdeck plus esp32-s3 keyboard handheld"><h4>LilyGo T-Deck / Plus</h4><p>ESP32-S3 keyboard handheld.</p>
|
||||
<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" data-search="lilygo t-display p4 tdisplay amoled rm69a10 esp32-p4 touch handheld"><h4>LilyGo T-Display P4 <span style="font-weight:600;color:var(--mut)">(AMOLED)</span></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" data-search="lilygo t-display p4 tdisplay lcd tft hi8561 esp32-p4 touch handheld"><h4>LilyGo T-Display P4 <span style="font-weight:600;color:var(--mut)">(LCD)</span></h4><p>ESP32-P4 · TFT-LCD touch handheld.</p>
|
||||
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The LCD-screen model of the T-Display P4 (the AMOLED variant is listed above). Early port — the core works: LoRa mesh, chat, map, GPS and the phone-app link over Wi-Fi or USB. Bluetooth pairing isn't available on this board. Flash with care and report back.</span></span>
|
||||
<esp-web-install-button id="inst-tdp4-lcd"></esp-web-install-button>
|
||||
<a class="dl2" id="dl-tdp4-lcd" download><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" id="dl-tdp4-lcd-ver"></span></a>
|
||||
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New screen variant, not yet hardware-tested — beta only. Flash with care and report back.</p></div>
|
||||
<div class="board" data-search="lilygo t-lora pager tlora esp32-s3 qwerty rotary encoder lr1121 sx1262"><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. 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>
|
||||
|
||||
<details class="mfr" open data-mfr="Heltec">
|
||||
<summary><span class="mfr-name">Heltec</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="heltec v4 tft esp32-s3 chsc6x touch"><h4>Heltec V4 + TFT</h4><p>ESP32-S3 with CHSC6x touch.</p>
|
||||
<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" data-search="heltec v4-r8 v4 r8 esp32-s3 psram sd card experimental"><h4>Heltec V4-R8 <span style="font-weight:600;color:var(--mut)">(experimental)</span></h4><p>ESP32-S3 · 8 MB PSRAM · 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 — beta only. Please flash with care and report back.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mfr" data-mfr="Elecrow">
|
||||
<summary><span class="mfr-name">Elecrow</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="elecrow thinknode m9 esp32-s3 qwerty lr1110 handheld"><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>
|
||||
</details>
|
||||
|
||||
<details class="mfr" data-mfr="RAKwireless">
|
||||
<summary><span class="mfr-name">RAKwireless</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="rakwireless rak wismesh tap v2 esp32-s3 touch node"><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>
|
||||
</details>
|
||||
|
||||
<details class="mfr" data-mfr="Nicolai Electronics">
|
||||
<summary><span class="mfr-name">Nicolai Electronics</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="nicolai electronics tanmatsu esp32-p4 keyboard handheld app store"><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>
|
||||
</details>
|
||||
|
||||
<details class="mfr" data-mfr="Attaky">
|
||||
<summary><span class="mfr-name">Attaky</span><span class="mfr-count"></span><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></summary>
|
||||
<div class="boards mfr-boards">
|
||||
<div class="board" data-search="attaky core esp32-s3 psram experimental"><h4>Attaky Core <span style="font-weight:600;color:var(--mut)">(experimental)</span></h4><p>ESP32-S3 · 16 MB · 8 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 — beta only. Please flash with care and report back.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<details class="verpick">
|
||||
<details class="verpick">
|
||||
<summary>Install a previous version</summary>
|
||||
<div class="verpick-body">
|
||||
<div class="verpick-row"><label for="verSel">Version</label><select id="verSel" aria-label="Firmware version"></select></div>
|
||||
@@ -371,6 +501,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 T-Deck only.</b> On the Heltec 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>
|
||||
@@ -378,7 +513,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>
|
||||
@@ -510,6 +646,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(){
|
||||
@@ -535,18 +675,79 @@ 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 = {};
|
||||
// The T-Display P4 LCD-screen variant ships only in the per-tag BETA archive (no
|
||||
// rolling-feed bin, no server manifest), so wire its Install button to a manifest
|
||||
// built client-side (same blob trick as the previous-version picker) and point its
|
||||
// download at the immutable archive bin. Called once the beta tag is known.
|
||||
window.wmWireP4Lcd = function(tag){
|
||||
const bin = 'https://firmware.wadamesh.com/releases/BETA/' + tag + '/wadamesh-tdisplay-p4-lcd-merged.bin';
|
||||
const btn = document.getElementById('inst-tdp4-lcd');
|
||||
if (btn){ const m = { name:'wadamesh ' + tag + ' \u2014 T-Display P4 (LCD)', version:tag, new_install_prompt_erase:true,
|
||||
builds:[{ chipFamily:'ESP32-P4', parts:[{ path:bin, offset:0 }] }] };
|
||||
btn.setAttribute('manifest', URL.createObjectURL(new Blob([JSON.stringify(m)], {type:'application/json'}))); }
|
||||
const dl = document.getElementById('dl-tdp4-lcd');
|
||||
if (dl){ dl.href = bin; const v = document.getElementById('dl-tdp4-lcd-ver'); if (v) v.textContent = tag; }
|
||||
};
|
||||
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(); if (feed === 'latest-beta' && window.wmWireP4Lcd) wmWireP4Lcd(v.tag); } }).catch(()=>{}));
|
||||
document.querySelectorAll('[data-wm-ch]').forEach(b => b.addEventListener('click', () => wmSetChannel(b.getAttribute('data-wm-ch'))));
|
||||
wmSetChannel('stable');
|
||||
|
||||
// ---- device selector: fill counts, remember default open state, live search ----
|
||||
(function(){
|
||||
const root = document.getElementById('devsel'); if (!root) return;
|
||||
const input = document.getElementById('devSearch');
|
||||
const clearX = document.getElementById('devClearX');
|
||||
const empty = root.querySelector('[data-devempty]'), qout = root.querySelector('[data-devq]');
|
||||
const groups = Array.from(root.querySelectorAll('.mfr'));
|
||||
groups.forEach(g => { g.dataset.defopen = g.open ? '1' : '0';
|
||||
const n = g.querySelectorAll('.board').length, c = g.querySelector('.mfr-count');
|
||||
if (c) c.textContent = n + (n === 1 ? ' device' : ' devices'); });
|
||||
const txt = b => (b.getAttribute('data-search') || b.textContent || '').toLowerCase();
|
||||
function run(q){
|
||||
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let anyVisible = false;
|
||||
groups.forEach(g => { let vis = 0;
|
||||
g.querySelectorAll('.board').forEach(b => { const ok = !terms.length || terms.every(t => txt(b).includes(t));
|
||||
b.hidden = !ok; if (ok) vis++; });
|
||||
g.hidden = vis === 0;
|
||||
if (!g.hidden){ anyVisible = true; g.open = terms.length ? true : (g.dataset.defopen === '1'); } });
|
||||
if (empty) empty.hidden = !(terms.length && !anyVisible);
|
||||
if (qout) qout.textContent = q;
|
||||
if (clearX) clearX.hidden = !q;
|
||||
}
|
||||
function clr(){ if (input){ input.value = ''; input.focus(); } run(''); }
|
||||
if (input){ input.addEventListener('input', () => run(input.value));
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Escape') clr(); }); }
|
||||
root.querySelectorAll('[data-devclear]').forEach(b => b.addEventListener('click', clr));
|
||||
})();
|
||||
|
||||
// ---- 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>' },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# beta_48 - stability and fixes: crash, boot-stuck, MQTT, message times, keyboard and more
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# Fixed
|
||||
Fixed a crash that could reboot the device while you were typing in a chat.
|
||||
Fixed devices getting stuck on the boot screen after an update when a microSD card is fitted. The boot-time copy of your data to the card could hang on a slow or damaged card; it is now guarded and the device always boots.
|
||||
MQTT bridge settings now save and survive a reboot. They were being written to the wrong storage and getting lost.
|
||||
Message times now match the clock at the top of the screen, instead of showing 1910 or other wrong dates.
|
||||
Dutch keyboard: the "1" and "!" keys were typing "ij" and "IJ". Fixed.
|
||||
The message box now stops you at the real send limit instead of quietly dropping the end of a long message when you send.
|
||||
Copy and paste no longer cuts off long text.
|
||||
The quick messages list now scrolls when there are more entries than fit on screen.
|
||||
Room server names that start with an unusual character no longer show an empty box on the Join screen.
|
||||
Tanmatsu: you can now reach every emoji in the picker, not just the first half.
|
||||
@@ -0,0 +1,13 @@
|
||||
# beta_49 - T-Display P4 only
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
This beta only has T-Display P4 fixes. If you run any other board there is nothing new for you here, stay on beta_48.
|
||||
|
||||
# Fixed (T-Display P4)
|
||||
The Snake game no longer traps you. Tap the top bar to leave it. Before, the only way out was a reset.
|
||||
The full-screen tools (Monitor, Spectrum, Discover, Web mirror, Advert) can now be left by tapping the top bar, so they no longer trap you either.
|
||||
The screen timeout now works. The display turns off after your idle time and wakes on touch. This also fixes standby and burn-in dimming.
|
||||
Text no longer overflows its boxes. The interface is pinned to the 100% size that fits the panel.
|
||||
GPS: the L76K module is now found reliably and read at 9600 baud. You still need the GPS antenna connected and a clear view of the sky to get a fix.
|
||||
The control center dropdown is taller so the GPS status line sits below the buttons instead of behind them.
|
||||
P4 LCD model only: fixed the black screen. The backlight now turns on. Please test and report.
|
||||
@@ -0,0 +1,13 @@
|
||||
# beta_50 - on-device updates for every board, plus pager and Attaky fixes
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# On-device updates
|
||||
Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.
|
||||
T-Display P4: the "Get test builds (beta)" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.
|
||||
|
||||
# T-LoRa Pager (codemonkeybr)
|
||||
Lock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.
|
||||
|
||||
# Attaky Mesh Series (attakygit)
|
||||
On-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.
|
||||
Board fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.
|
||||
@@ -0,0 +1,14 @@
|
||||
# beta_51 - chat layout, an Airtime tool, and a lockscreen fix
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
A community drop. Thanks to Yazutsu, scratchdiver and jacobpretorius.
|
||||
|
||||
# Chat
|
||||
Message bubbles now show the time on the top row next to the sender, so more messages fit on screen. Applies to channels, DMs and room-server threads. (scratchdiver)
|
||||
|
||||
# New: Airtime tool
|
||||
New Airtime tool in the Apps drawer measures how busy the LoRa channel really is, so you can check congestion instead of guessing. It shows channel use over time, packets lost at the radio, live signal and noise, the LoRa preset, and a plain verdict on whether congestion is likely. (jacobpretorius)
|
||||
|
||||
# Fixed
|
||||
Lockscreen wallpaper no longer turns into coloured noise after you use the map. A custom JPG wallpaper could show garbage until a reboot. (Yazutsu, fixes #127)
|
||||
T-Display P4: the text size setting works again. It was stuck at 100% no matter what you picked.
|
||||
@@ -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
|
||||
|
||||
@@ -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)})")
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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>•</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>⚙</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>‹</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>></span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>⚡</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({'&':'&','<':'<','>':'>','"':'"'})[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> </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>
|
||||
@@ -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()
|
||||
@@ -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); });
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#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)
|
||||
#define MAX_BLOBRECS 100
|
||||
@@ -240,16 +243,29 @@ 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("/new_prefs.tmp"))) {
|
||||
} 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 (_fs->exists(_rp("/node_prefs"))) {
|
||||
} 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
|
||||
@@ -261,7 +277,7 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon)
|
||||
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
|
||||
@@ -404,6 +420,23 @@ bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -446,8 +479,21 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
// (FAT has no such GC); it matters most for card-less SPIFFS devices.
|
||||
WdtHeavyGuard _wdt;
|
||||
#endif
|
||||
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
#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;
|
||||
@@ -462,7 +508,6 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
uint8_t* buf = (uint8_t*)malloc(REC * CHUNK_RECS);
|
||||
if (buf) {
|
||||
size_t fill = 0;
|
||||
bool ok = true;
|
||||
while (ok && host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
idx++; // advance to next contact
|
||||
@@ -488,7 +533,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
}
|
||||
idx++; // advance to next contact
|
||||
}
|
||||
if (ok && fill > 0) file.write(buf, fill);
|
||||
if (ok && fill > 0) ok = (file.write(buf, fill) == fill);
|
||||
free(buf);
|
||||
} else {
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
@@ -509,13 +554,32 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
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
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -818,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
|
||||
@@ -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; }
|
||||
@@ -70,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;};
|
||||
|
||||
@@ -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>
|
||||
@@ -137,6 +137,11 @@ 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
|
||||
@@ -237,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 {
|
||||
@@ -582,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
|
||||
@@ -2397,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;
|
||||
@@ -2416,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);
|
||||
@@ -2537,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
|
||||
@@ -2553,7 +2605,7 @@ void MyMesh::applyRadioFromPrefs() {
|
||||
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",
|
||||
@@ -3352,7 +3404,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
|
||||
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();
|
||||
@@ -3421,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];
|
||||
@@ -4010,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() {
|
||||
@@ -4456,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
|
||||
|
||||
@@ -481,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
|
||||
@@ -711,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
|
||||
@@ -831,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
|
||||
@@ -979,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
|
||||
|
||||
@@ -35,21 +35,40 @@ public:
|
||||
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) return; // garbage set — ignore, whatever the source
|
||||
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.
|
||||
void seedFloor(uint32_t persisted) { if (persisted > _floor) _floor = persisted; }
|
||||
// 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; }
|
||||
};
|
||||
@@ -5,8 +5,10 @@
|
||||
// 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 ----
|
||||
#include <Preferences.h>
|
||||
#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 boards ----
|
||||
#include "SdNvsPrefs.h" // NOT raw NVS Preferences: the touch firmware abandoned NVS (tiny, shared,
|
||||
// doesn't survive Launcher) for a file-backed store. MQTT was the last setting
|
||||
// still on NVS, so its writes silently failed / didn't persist (GH #128).
|
||||
#include <WiFi.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
@@ -42,7 +44,7 @@ void MqttBridge::loadConfig() {
|
||||
_host[0] = _user[0] = _pwd[0] = _psk[0] = '\0';
|
||||
_port = 1883;
|
||||
|
||||
Preferences p;
|
||||
SdNvsPrefs p;
|
||||
if (p.begin("mqtt", true)) {
|
||||
_enabled = p.getBool("en", false);
|
||||
_port = (uint16_t)p.getUInt("port", 1883);
|
||||
@@ -223,7 +225,7 @@ void MqttBridge::publishChannel(int channelIdx, const char* channelName,
|
||||
void MqttBridge::saveConfig(const char* host, uint16_t port,
|
||||
const char* user, const char* pwd,
|
||||
bool pubDm, bool pubChannel, const char* psk, bool enable) {
|
||||
Preferences p;
|
||||
SdNvsPrefs p;
|
||||
if (!p.begin("mqtt", false)) return;
|
||||
p.putBool("en", enable);
|
||||
p.putString("host", host);
|
||||
|
||||
@@ -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*/) {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#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
|
||||
@@ -32,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;
|
||||
@@ -41,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() {
|
||||
@@ -56,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;
|
||||
}
|
||||
|
||||
@@ -231,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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -95,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
// 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;
|
||||
@@ -26,6 +32,7 @@ static bool socketWritableNow(WiFiClient& client) {
|
||||
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) {
|
||||
@@ -69,26 +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;
|
||||
_clients[slot].stall_ms = 0;
|
||||
} else {
|
||||
incoming.stop();
|
||||
}
|
||||
adoptClient(incoming);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -43,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];
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
|
||||
#include "SdNvsPrefs.h" // NVS, or SD /meshcomod fallback when NVS is unusable (Launcher)
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <SPIFFS.h>
|
||||
#include <stddef.h> // offsetof
|
||||
#include <string.h> // memcpy
|
||||
#include <vector> // writeUseSdToSpiffsKv record list
|
||||
|
||||
static const char* TOUCH_NS = "touch";
|
||||
|
||||
@@ -24,8 +27,8 @@ static bool s_begun = false;
|
||||
// packed into ONE versioned blob keyed "cfg". Strings (tile_srv, rgn_scope,
|
||||
// lk_wall, channel-scopes, quick-replies), the byte blobs (fav / ign / rpw) and
|
||||
// the Wi-Fi slots keep their own keys — and "use_sd" / "setup_ok" stay standalone
|
||||
// keys too, because main.cpp reads (and for use_sd, writes) them directly via a
|
||||
// raw Preferences open at boot, BEFORE the touch prefs load.
|
||||
// keys too. "use_sd" is mirrored to NVS on every UI toggle (main.cpp reads it at
|
||||
// boot via touchPrefsReadUseSdAtBoot); "setup_ok" is still NVS-only.
|
||||
//
|
||||
// On first run with the blob absent we read every legacy per-key into s_cfg, write
|
||||
// "cfg" ONCE, and only after that durable write do we remove() the legacy keys to
|
||||
@@ -35,7 +38,7 @@ static bool s_begun = false;
|
||||
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
|
||||
static const char* KEY_CFG = "cfg";
|
||||
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
|
||||
static const uint8_t TOUCH_CFG_VER = 33; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF)
|
||||
static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF)
|
||||
|
||||
// Defaults (kept identical to the historical per-key defaults).
|
||||
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
|
||||
@@ -103,6 +106,11 @@ struct __attribute__((packed)) TouchCfg {
|
||||
uint8_t compact_chat; // IRC-style dense chat rows instead of bubbles (bool, 0=off) — v31 (trailing)
|
||||
uint32_t clock_floor; // highest epoch this device handed out (ClockFloorRTC persistence) — v32 (trailing)
|
||||
uint8_t rx_queue; // buffered LoRa receive: drain task + packet ring (bool, 0=off, experimental) — v33 (trailing)
|
||||
uint8_t web_mirror; // web control panel: mirror the live UI to a phone browser + inject taps (bool, 0=off) — v34 (trailing)
|
||||
uint8_t remote_mode; // render the UI off-screen at a web resolution instead of the panel (bool, 0=off) — v35 (trailing)
|
||||
uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing)
|
||||
uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing)
|
||||
uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing)
|
||||
};
|
||||
|
||||
static TouchCfg s_cfg;
|
||||
@@ -194,6 +202,11 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
c.show_sensors_tab = 1; // default: show the V4 Expansion-Kit Sensors tab + Home env widget
|
||||
c.map_show_links = 1; // default: show self->contact link lines (PR #61)
|
||||
c.map_style = 0; // default: OpenStreetMap (OpenTopoMap is opt-in)
|
||||
c.web_mirror = 0; // OFF: web control panel is opt-in (remote control over the LAN)
|
||||
c.remote_mode = 0; // OFF: render to the physical panel (remote mode is opt-in, reboots to apply)
|
||||
c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle
|
||||
c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC)
|
||||
c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer)
|
||||
}
|
||||
|
||||
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
|
||||
@@ -259,6 +272,12 @@ static void cfgLoadOrMigrate() {
|
||||
if (s_cfg.ver < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
|
||||
if (s_cfg.ver < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
|
||||
if (s_cfg.ver < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
|
||||
if (s_cfg.ver < 34) s_cfg.web_mirror = 0; // new trailing field: web control panel off by default (opt-in remote control)
|
||||
if (s_cfg.ver < 35) s_cfg.remote_mode = 0; // new trailing field: remote mode off by default (opt-in, reboots to apply)
|
||||
if (s_cfg.ver < 36) s_cfg.remote_landscape = 0;
|
||||
if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle)
|
||||
if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in)
|
||||
if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default
|
||||
s_cfg.ver = TOUCH_CFG_VER;
|
||||
s_cfg.magic = TOUCH_CFG_MAGIC;
|
||||
cfgFlush(); // rewrite with new fields defaulted-in
|
||||
@@ -728,6 +747,58 @@ bool touchPrefsSetAccentPopups(bool on) {
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
// Web control panel: mirror the live UI to a phone browser (Settings > Wi-Fi).
|
||||
bool touchPrefsGetWebMirror() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.web_mirror != 0;
|
||||
}
|
||||
bool touchPrefsSetWebMirror(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.web_mirror = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
// Remote mode: render the UI off-screen at a web resolution (boot mode; REMOTE app).
|
||||
bool touchPrefsGetRemoteMode() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.remote_mode != 0;
|
||||
}
|
||||
bool touchPrefsSetRemoteMode(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.remote_mode = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
bool touchPrefsGetRemoteLandscape() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.remote_landscape != 0;
|
||||
}
|
||||
bool touchPrefsSetRemoteLandscape(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.remote_landscape = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
bool touchPrefsGetWebTerminal() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.web_terminal != 0;
|
||||
}
|
||||
bool touchPrefsSetWebTerminal(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.web_terminal = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
bool touchPrefsGetMapTileDebug() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.map_tile_debug != 0;
|
||||
}
|
||||
bool touchPrefsSetMapTileDebug(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.map_tile_debug = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
// UI accent colour (buttons, active tab, keyboard, highlights) as 0xRRGGBB.
|
||||
// Default = the WADAMESH brand teal (the logo dots). The picker clamps it dark
|
||||
// enough that the off-white button text stays readable on any hue.
|
||||
@@ -1113,14 +1184,110 @@ bool touchPrefsSetHomeIsDrawer(bool on) {
|
||||
// Store ALL device data (identity, prefs, contacts, channels) on the SD card
|
||||
// under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE
|
||||
// the data loads, so changing it needs a reboot. Key "use_sd" in the "touch"
|
||||
// namespace — main.cpp reads the same key directly.
|
||||
// namespace — main.cpp must see the same value the UI toggle writes.
|
||||
static const char* KEY_USE_SD_STORAGE = "use_sd";
|
||||
static const char* TOUCH_KV_BOOT_PATH = "/prefs/touch.kv";
|
||||
|
||||
// SdNvsPrefs file mode writes touch.kv only — parse a bool for boot migration.
|
||||
static bool readBoolFromTouchKvFile(const char* want_key) {
|
||||
if (!SPIFFS.exists(TOUCH_KV_BOOT_PATH)) return false;
|
||||
File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ);
|
||||
if (!f) return false;
|
||||
while (f.available() > 0) {
|
||||
int kl = f.read();
|
||||
if (kl <= 0 || kl > 15) break;
|
||||
char k[16] = {0};
|
||||
if (f.read((uint8_t*)k, kl) != kl) break;
|
||||
int lo = f.read(), hi = f.read();
|
||||
if (lo < 0 || hi < 0) break;
|
||||
size_t vl = (size_t)lo | ((size_t)hi << 8);
|
||||
if (vl > 2048) break;
|
||||
if (strncmp(k, want_key, sizeof k) == 0) {
|
||||
const bool on = (vl >= 1) && (f.read() != 0);
|
||||
f.close();
|
||||
return on;
|
||||
}
|
||||
for (size_t i = 0; i < vl; ++i) {
|
||||
if (f.read() < 0) { f.close(); return false; }
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool touchPrefsReadUseSdAtBoot() {
|
||||
bool nvs_val = false;
|
||||
Preferences p;
|
||||
if (p.begin(TOUCH_NS, true)) {
|
||||
nvs_val = p.getBool(KEY_USE_SD_STORAGE, false);
|
||||
p.end();
|
||||
}
|
||||
if (nvs_val) return true;
|
||||
const bool file_val = readBoolFromTouchKvFile(KEY_USE_SD_STORAGE);
|
||||
if (file_val) {
|
||||
Serial.println("[BOOT] use_sd read from /prefs/touch.kv (syncing to NVS)");
|
||||
if (p.begin(TOUCH_NS, false)) {
|
||||
p.putBool(KEY_USE_SD_STORAGE, true);
|
||||
p.end();
|
||||
}
|
||||
}
|
||||
return file_val;
|
||||
}
|
||||
|
||||
bool touchPrefsGetUseSdStorage() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_prefs.getBool(KEY_USE_SD_STORAGE, false); // default = SPIFFS
|
||||
}
|
||||
|
||||
// Rewrite (or create) the SPIFFS copy of touch.kv with use_sd set, keeping every
|
||||
// other record. Needed because the boot storage decision can only read SPIFFS
|
||||
// (SD isn't mounted yet, see touchPrefsReadUseSdAtBoot): when prefs actively
|
||||
// live on the SD card, the toggle must land in BOTH file copies — otherwise
|
||||
// installs with unusable NVS (Launcher) could turn SD storage on but never OFF
|
||||
// again (the stale SPIFFS true would win every boot). PR #123 follow-up.
|
||||
static void writeUseSdToSpiffsKv(bool on) {
|
||||
struct Rec { char k[16]; std::vector<uint8_t> v; };
|
||||
std::vector<Rec> recs;
|
||||
File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ);
|
||||
if (f) {
|
||||
while (f.available() > 0 && recs.size() < 256) {
|
||||
int kl = f.read();
|
||||
if (kl <= 0 || kl > 15) break;
|
||||
Rec r{};
|
||||
if (f.read((uint8_t*)r.k, kl) != kl) break;
|
||||
int lo = f.read(), hi = f.read();
|
||||
if (lo < 0 || hi < 0) break;
|
||||
size_t vl = (size_t)lo | ((size_t)hi << 8);
|
||||
if (vl > 2048) break;
|
||||
r.v.resize(vl);
|
||||
if (vl && f.read(r.v.data(), vl) != (int)vl) break;
|
||||
recs.push_back(std::move(r));
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
bool found = false;
|
||||
for (auto& r : recs) {
|
||||
if (strncmp(r.k, KEY_USE_SD_STORAGE, sizeof r.k) == 0) { r.v.assign(1, on ? 1 : 0); found = true; }
|
||||
}
|
||||
if (!found) {
|
||||
Rec r{};
|
||||
strncpy(r.k, KEY_USE_SD_STORAGE, sizeof r.k - 1);
|
||||
r.v.assign(1, on ? 1 : 0);
|
||||
recs.push_back(std::move(r));
|
||||
}
|
||||
File w = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_WRITE); // truncate + rewrite
|
||||
if (!w) return;
|
||||
for (auto& r : recs) {
|
||||
size_t kl = strnlen(r.k, sizeof r.k), vl = r.v.size();
|
||||
w.write((uint8_t)kl);
|
||||
w.write((const uint8_t*)r.k, kl);
|
||||
w.write((uint8_t)(vl & 0xFF));
|
||||
w.write((uint8_t)((vl >> 8) & 0xFF));
|
||||
if (vl) w.write(r.v.data(), vl);
|
||||
}
|
||||
w.close();
|
||||
}
|
||||
|
||||
bool touchPrefsSetUseSdStorage(bool use_sd) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_prefs.end();
|
||||
@@ -1128,6 +1295,18 @@ bool touchPrefsSetUseSdStorage(bool use_sd) {
|
||||
bool ok = s_prefs.putBool(KEY_USE_SD_STORAGE, use_sd);
|
||||
s_prefs.end();
|
||||
s_begun = s_prefs.begin(TOUCH_NS, true);
|
||||
// Mirror to raw NVS for the boot read (PR #123). Best effort ONLY: on
|
||||
// Launcher installs NVS is unusable and the file write above is the
|
||||
// authoritative copy — a failed mirror must not fail the setter there.
|
||||
Preferences nvs;
|
||||
if (nvs.begin(TOUCH_NS, false)) {
|
||||
nvs.putBool(KEY_USE_SD_STORAGE, use_sd);
|
||||
nvs.end();
|
||||
}
|
||||
// When prefs live on the SD card, boot's fallback still reads the SPIFFS
|
||||
// copy — keep it in sync so the toggle works in BOTH directions there.
|
||||
fs::FS* ffs = SdNvsPrefs::fileFs();
|
||||
if (ffs && ffs != (fs::FS*)&SPIFFS) writeUseSdToSpiffsKv(use_sd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -1632,10 +1811,31 @@ bool touchPrefsSetBlob(const char* key, const uint8_t* data, size_t len) {
|
||||
}
|
||||
uint8_t touchPrefsGetSoundVolume() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
#if defined(TLORA_PAGER)
|
||||
// The pager's ES8311 codec + NS4150B amp run noticeably louder at a given
|
||||
// percentage than the T-Deck's I2S amp/Tanmatsu's ES8156 — 70% clips into
|
||||
// uncomfortable territory, so this board gets a quieter first-boot default.
|
||||
uint8_t v = s_prefs.getUChar("snd_vol", 50);
|
||||
#else
|
||||
uint8_t v = s_prefs.getUChar("snd_vol", 70);
|
||||
#endif
|
||||
return v > 100 ? 100 : v;
|
||||
}
|
||||
void touchPrefsSetSoundVolume(uint8_t vol) { if (vol > 100) vol = 100; if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_vol", vol); }
|
||||
bool touchPrefsGetDndEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("dnd_en", 0) != 0; }
|
||||
void touchPrefsSetDndEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_en", on ? 1 : 0); }
|
||||
uint8_t touchPrefsGetDndStartSlot() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
uint8_t s = s_prefs.getUChar("dnd_ss", 44);
|
||||
return s > 47 ? 47 : s;
|
||||
}
|
||||
void touchPrefsSetDndStartSlot(uint8_t slot) { if (slot > 47) slot = 47; if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_ss", slot); }
|
||||
uint8_t touchPrefsGetDndEndSlot() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
uint8_t s = s_prefs.getUChar("dnd_es", 12);
|
||||
return s > 47 ? 47 : s;
|
||||
}
|
||||
void touchPrefsSetDndEndSlot(uint8_t slot) { if (slot > 47) slot = 47; if (!s_begun) touchPrefsBegin(); prefsPutUChar("dnd_es", slot); }
|
||||
uint8_t touchPrefsGetKbdBacklight() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
uint8_t v = s_prefs.getUChar("kbd_bl", 100);
|
||||
|
||||
@@ -49,6 +49,24 @@ bool touchPrefsSetEnabledLayouts(uint16_t mask);
|
||||
bool touchPrefsGetAccentPopups();
|
||||
bool touchPrefsSetAccentPopups(bool on);
|
||||
|
||||
/** Web control panel: serve the live UI to a phone browser over the WebSocket
|
||||
* server and inject taps back (remote control). Opt-in, default OFF. */
|
||||
bool touchPrefsGetWebMirror();
|
||||
bool touchPrefsSetWebMirror(bool on);
|
||||
|
||||
/** Remote mode: on boot, render the UI to an off-screen web resolution (480x800)
|
||||
* instead of the physical panel, served to a browser (headless/remote use). It is
|
||||
* a boot mode (reboot to apply). Opt-in, default OFF. */
|
||||
bool touchPrefsGetRemoteMode();
|
||||
bool touchPrefsSetRemoteMode(bool on);
|
||||
|
||||
/** Remote-mode orientation: landscape (800x480, for desktop) vs portrait (480x800, for
|
||||
* phones). Applied at boot (reboots to change). Default portrait. */
|
||||
bool touchPrefsGetRemoteLandscape();
|
||||
bool touchPrefsSetRemoteLandscape(bool on);
|
||||
bool touchPrefsGetWebTerminal();
|
||||
bool touchPrefsSetWebTerminal(bool on);
|
||||
|
||||
/** UI language index (UiLang enum in i18n.h; 0 = English). Read at boot. */
|
||||
uint8_t touchPrefsGetUiLang();
|
||||
bool touchPrefsSetUiLang(uint8_t lang);
|
||||
@@ -96,6 +114,8 @@ bool touchPrefsGetMapShowCoords();
|
||||
bool touchPrefsSetMapShowCoords(bool on);
|
||||
bool touchPrefsGetMapShowTileXYZ();
|
||||
bool touchPrefsSetMapShowTileXYZ(bool on);
|
||||
bool touchPrefsGetMapTileDebug();
|
||||
bool touchPrefsSetMapTileDebug(bool on);
|
||||
bool touchPrefsGetMapShowContacts();
|
||||
bool touchPrefsSetMapShowContacts(bool on);
|
||||
bool touchPrefsGetMapShowLinks();
|
||||
@@ -223,6 +243,9 @@ bool touchPrefsSetShowSensorsTab(bool on);
|
||||
* loads, so changing it requires a reboot. Default false (SPIFFS). */
|
||||
bool touchPrefsGetUseSdStorage();
|
||||
bool touchPrefsSetUseSdStorage(bool use_sd);
|
||||
/** Boot-time read for main.cpp's storage decision (before SdNvsPrefs::useFile).
|
||||
* Checks NVS, then SPIFFS /prefs/touch.kv for a file-only write. */
|
||||
bool touchPrefsReadUseSdAtBoot();
|
||||
|
||||
/** Global UI orientation, applied at boot before the screens are built so the
|
||||
* whole layout reflows to the rotated resolution. Stored as the raw LVGL
|
||||
@@ -355,6 +378,20 @@ void touchPrefsSetSoundDirect(bool on);
|
||||
uint8_t touchPrefsGetSoundVolume(); // 0..100, default 70
|
||||
void touchPrefsSetSoundVolume(uint8_t vol);
|
||||
|
||||
/** Do Not Disturb: silences the incoming-message chime during a daily time
|
||||
* window. Start/end are half-hour slots (0..47, slot = hour*2 + (min>=30)),
|
||||
* so the window can be set in 30-minute steps. The window may wrap past
|
||||
* midnight (start > end means "start..23:59 AND 00:00..end"); start == end
|
||||
* is a zero-length "never active" window. Only gates the sound chokepoint in
|
||||
* newMsgImpl() — chat bubbles/badges/unread counts are unaffected, and
|
||||
* Settings-page sound previews still play while DND is on. */
|
||||
bool touchPrefsGetDndEnabled(); // default false
|
||||
void touchPrefsSetDndEnabled(bool on);
|
||||
uint8_t touchPrefsGetDndStartSlot(); // 0..47, default 44 (22:00)
|
||||
void touchPrefsSetDndStartSlot(uint8_t slot);
|
||||
uint8_t touchPrefsGetDndEndSlot(); // 0..47, default 12 (06:00)
|
||||
void touchPrefsSetDndEndSlot(uint8_t slot);
|
||||
|
||||
/** Per-event notification sound FILE (empty = built-in chime). Slot:
|
||||
* 0 = message, 1 = direct/DM, 2 = @-mention. Stored as a path pref like the
|
||||
* lock wallpaper ("" | "/sounds/x.wav" | "sd:/x.wav"). T-Deck only. */
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#include "WebMirror.h"
|
||||
#include <Arduino.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <string.h>
|
||||
|
||||
WebMirror g_web_mirror;
|
||||
|
||||
#ifndef WEB_MIRROR_RING_BYTES
|
||||
#define WEB_MIRROR_RING_BYTES (384 * 1024) // PSRAM; holds several banded frames
|
||||
#endif
|
||||
|
||||
void WebMirror::begin(size_t ring_bytes) {
|
||||
if (_ring) return;
|
||||
if (ring_bytes == 0) ring_bytes = WEB_MIRROR_RING_BYTES;
|
||||
_ring = (uint8_t*)heap_caps_malloc(ring_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!_ring) _ring = (uint8_t*)malloc(ring_bytes); // last-ditch internal DRAM
|
||||
_cap = _ring ? ring_bytes : 0;
|
||||
_head = _tail = 0;
|
||||
}
|
||||
|
||||
void WebMirror::setEnabled(bool en) {
|
||||
_enabled = en;
|
||||
if (en) {
|
||||
_need_full = true; // next connect wants a full frame
|
||||
} else {
|
||||
_tail = _head; // drain the ring so nothing stale lingers
|
||||
}
|
||||
}
|
||||
|
||||
static inline size_t ringUsed(size_t head, size_t tail, size_t cap) {
|
||||
return (head + cap - tail) % cap;
|
||||
}
|
||||
|
||||
bool WebMirror::pushFrame(const uint8_t* hdr, size_t hdr_len, const uint8_t* body, size_t body_len) {
|
||||
if (!_ring || _cap == 0) return false;
|
||||
const size_t payload = hdr_len + body_len;
|
||||
if (payload == 0 || payload > 0xFFFF) { _need_full = true; return false; }
|
||||
const size_t total = 2 + payload; // 2-byte length prefix + payload
|
||||
|
||||
size_t head = _head;
|
||||
const size_t tail = _tail; // written by the consumer; a stale read is conservative
|
||||
const size_t freeb = _cap - ringUsed(head, tail, _cap) - 1;
|
||||
if (freeb < total) { _need_full = true; return false; } // backed up -> drop, heal via full repaint
|
||||
|
||||
_ring[head] = (uint8_t)(payload & 0xFF); head = (head + 1) % _cap;
|
||||
_ring[head] = (uint8_t)(payload >> 8); head = (head + 1) % _cap;
|
||||
for (size_t i = 0; i < hdr_len; i++) { _ring[head] = hdr[i]; head = (head + 1) % _cap; }
|
||||
{
|
||||
size_t first = _cap - head;
|
||||
if (first > body_len) first = body_len;
|
||||
memcpy(_ring + head, body, first);
|
||||
if (first < body_len) memcpy(_ring, body + first, body_len - first);
|
||||
head = (head + body_len) % _cap;
|
||||
}
|
||||
__sync_synchronize(); // publish the payload before advancing head
|
||||
_head = head;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t WebMirror::popFrame(uint8_t* dst, size_t max_len) {
|
||||
if (!_ring || _cap == 0) return 0;
|
||||
const size_t head = _head;
|
||||
__sync_synchronize(); // read head before the payload it guards
|
||||
size_t tail = _tail;
|
||||
const size_t used = ringUsed(head, tail, _cap);
|
||||
if (used < 2) return 0;
|
||||
|
||||
size_t l0 = _ring[tail]; tail = (tail + 1) % _cap;
|
||||
size_t l1 = _ring[tail]; tail = (tail + 1) % _cap;
|
||||
const size_t payload = l0 | (l1 << 8);
|
||||
if (payload == 0 || payload + 2 > used || payload > max_len) {
|
||||
_tail = head; // corrupt/oversized -> resync by dropping the ring
|
||||
return 0;
|
||||
}
|
||||
size_t first = _cap - tail;
|
||||
if (first > payload) first = payload;
|
||||
memcpy(dst, _ring + tail, first);
|
||||
if (first < payload) memcpy(dst + first, _ring, payload - first);
|
||||
_tail = (tail + payload) % _cap;
|
||||
return payload;
|
||||
}
|
||||
|
||||
void WebMirror::pushPointer(int16_t x, int16_t y, uint8_t pressed) {
|
||||
// Producer = network/stream core, consumer = UI core: publish the coords before the
|
||||
// _pknown flag so the reader never sees a half-updated point.
|
||||
_px = x; _py = y; _ppressed = pressed ? 1 : 0;
|
||||
__sync_synchronize();
|
||||
_pknown = 1;
|
||||
}
|
||||
|
||||
bool WebMirror::readPointer(int16_t* x, int16_t* y, bool* pressed) const {
|
||||
if (!_pknown) return false;
|
||||
__sync_synchronize();
|
||||
if (x) *x = _px;
|
||||
if (y) *y = _py;
|
||||
if (pressed) *pressed = (_ppressed != 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebMirror::pushKey(uint16_t cp) {
|
||||
uint8_t n = (uint8_t)((_khead + 1) % KEY_RING);
|
||||
if (n != _ktail) { _keys[_khead] = cp; __sync_synchronize(); _khead = n; } // publish key before advancing head; drop if full
|
||||
}
|
||||
|
||||
bool WebMirror::popKey(uint16_t* cp) {
|
||||
if (_khead == _ktail) return false;
|
||||
__sync_synchronize(); // read head before the key slot it guards
|
||||
if (cp) *cp = _keys[_ktail];
|
||||
_ktail = (uint8_t)((_ktail + 1) % KEY_RING);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================ web mesh terminal ============================
|
||||
#ifndef WEB_TERM_REP_BYTES
|
||||
#define WEB_TERM_REP_BYTES (16 * 1024) // PSRAM reply-text ring
|
||||
#endif
|
||||
#ifndef WEB_TERM_DATA_BYTES
|
||||
#define WEB_TERM_DATA_BYTES (24 * 1024) // PSRAM framed-JSON ring (Contacts/Chats data)
|
||||
#endif
|
||||
|
||||
void WebMirror::termBegin() {
|
||||
if (_term_rep && _term_data) return;
|
||||
if (!_term_rep) {
|
||||
_term_rep = (uint8_t*)heap_caps_malloc(WEB_TERM_REP_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!_term_rep) _term_rep = (uint8_t*)malloc(WEB_TERM_REP_BYTES);
|
||||
_tr_cap = _term_rep ? WEB_TERM_REP_BYTES : 0;
|
||||
_tr_head = _tr_tail = 0;
|
||||
}
|
||||
if (!_term_data) {
|
||||
_term_data = (uint8_t*)heap_caps_malloc(WEB_TERM_DATA_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!_term_data) _term_data = (uint8_t*)malloc(WEB_TERM_DATA_BYTES);
|
||||
_td_cap = _term_data ? WEB_TERM_DATA_BYTES : 0;
|
||||
_td_head = _td_tail = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void WebMirror::setTerminalOn(bool on) {
|
||||
if (on) termBegin();
|
||||
_term_on = on;
|
||||
if (!on) { _tr_tail = _tr_head; _tc_tail = _tc_head; _td_tail = _td_head; } // drain rings on disable
|
||||
}
|
||||
|
||||
// command ring (browser -> device): [len_lo, len_hi, bytes...]
|
||||
bool WebMirror::pushTermCmd(const char* s, size_t len) {
|
||||
if (!s || len == 0 || len > 250) return false;
|
||||
size_t head = _tc_head;
|
||||
const size_t tail = _tc_tail;
|
||||
const size_t used = (head + TERM_CMD_CAP - tail) % TERM_CMD_CAP;
|
||||
const size_t freeb = TERM_CMD_CAP - used - 1;
|
||||
if (freeb < len + 2) return false;
|
||||
_term_cmd[head] = (uint8_t)(len & 0xFF); head = (head + 1) % TERM_CMD_CAP;
|
||||
_term_cmd[head] = (uint8_t)(len >> 8); head = (head + 1) % TERM_CMD_CAP;
|
||||
for (size_t i = 0; i < len; i++) { _term_cmd[head] = (uint8_t)s[i]; head = (head + 1) % TERM_CMD_CAP; }
|
||||
__sync_synchronize();
|
||||
_tc_head = head;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t WebMirror::popTermCmd(char* dst, size_t max) {
|
||||
const size_t head = _tc_head;
|
||||
__sync_synchronize();
|
||||
size_t tail = _tc_tail;
|
||||
const size_t used = (head + TERM_CMD_CAP - tail) % TERM_CMD_CAP;
|
||||
if (used < 2) return 0;
|
||||
size_t l0 = _term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP;
|
||||
size_t l1 = _term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP;
|
||||
const size_t len = l0 | (l1 << 8);
|
||||
if (len == 0 || len + 2 > used || len + 1 > max) { _tc_tail = head; return 0; } // corrupt/too-big -> resync
|
||||
for (size_t i = 0; i < len; i++) { dst[i] = (char)_term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP; }
|
||||
dst[len] = '\0';
|
||||
_tc_tail = tail;
|
||||
return len;
|
||||
}
|
||||
|
||||
// reply ring (device -> browser): raw byte stream (browser just appends text)
|
||||
void WebMirror::pushTermReply(const char* s) {
|
||||
if (!s || !_term_rep || _tr_cap == 0) return;
|
||||
size_t head = _tr_head;
|
||||
const size_t tail = _tr_tail;
|
||||
size_t used = (head + _tr_cap - tail) % _tr_cap;
|
||||
size_t freeb = _tr_cap - used - 1;
|
||||
for (const char* p = s; *p && freeb > 0; ++p) {
|
||||
_term_rep[head] = (uint8_t)*p;
|
||||
head = (head + 1) % _tr_cap;
|
||||
freeb--;
|
||||
}
|
||||
__sync_synchronize();
|
||||
_tr_head = head;
|
||||
}
|
||||
|
||||
size_t WebMirror::popTermReply(uint8_t* dst, size_t max) {
|
||||
if (!_term_rep || _tr_cap == 0) return 0;
|
||||
const size_t head = _tr_head;
|
||||
__sync_synchronize();
|
||||
size_t tail = _tr_tail;
|
||||
size_t n = 0;
|
||||
while (tail != head && n < max) {
|
||||
dst[n++] = _term_rep[tail];
|
||||
tail = (tail + 1) % _tr_cap;
|
||||
}
|
||||
_tr_tail = tail;
|
||||
return n;
|
||||
}
|
||||
|
||||
// data ring (device -> browser): [len_lo, len_hi, json bytes...] framed messages
|
||||
bool WebMirror::pushTermData(const char* json) {
|
||||
if (!json || !_term_data || _td_cap == 0) return false;
|
||||
size_t len = strlen(json);
|
||||
if (len == 0 || len > 0xFFFF) return false;
|
||||
size_t head = _td_head;
|
||||
const size_t tail = _td_tail;
|
||||
const size_t used = (head + _td_cap - tail) % _td_cap;
|
||||
const size_t freeb = _td_cap - used - 1;
|
||||
if (freeb < len + 2) return false; // ring backed up -> drop this message (browser can re-request)
|
||||
_term_data[head] = (uint8_t)(len & 0xFF); head = (head + 1) % _td_cap;
|
||||
_term_data[head] = (uint8_t)(len >> 8); head = (head + 1) % _td_cap;
|
||||
for (size_t i = 0; i < len; i++) { _term_data[head] = (uint8_t)json[i]; head = (head + 1) % _td_cap; }
|
||||
__sync_synchronize();
|
||||
_td_head = head;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t WebMirror::popTermData(uint8_t* dst, size_t max) {
|
||||
if (!_term_data || _td_cap == 0) return 0;
|
||||
const size_t head = _td_head;
|
||||
__sync_synchronize();
|
||||
size_t tail = _td_tail;
|
||||
const size_t used = (head + _td_cap - tail) % _td_cap;
|
||||
if (used < 2) return 0;
|
||||
size_t l0 = _term_data[tail]; tail = (tail + 1) % _td_cap;
|
||||
size_t l1 = _term_data[tail]; tail = (tail + 1) % _td_cap;
|
||||
const size_t len = l0 | (l1 << 8);
|
||||
if (len == 0 || len + 2 > used || len > max) { _td_tail = head; return 0; } // corrupt/too-big -> resync
|
||||
for (size_t i = 0; i < len; i++) { dst[i] = _term_data[tail]; tail = (tail + 1) % _td_cap; }
|
||||
_td_tail = tail;
|
||||
return len;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
// ============================================================================
|
||||
// Web UI mirror — bridge between the LVGL UI thread and the network loop thread.
|
||||
//
|
||||
// The device streams its live framebuffer (every LVGL flush) out to a phone
|
||||
// browser over the existing WebSocket companion server, and the browser sends
|
||||
// taps back. The browser renders the device's OWN UI verbatim, so any change to
|
||||
// the firmware UI shows up on the web with zero web-side code.
|
||||
//
|
||||
// Two independent single-producer / single-consumer channels, so no locks:
|
||||
// * display : LVGL flush thread PRODUCES rects -> network thread CONSUMES + sends
|
||||
// * input : network thread PRODUCES pointers -> LVGL indev CONSUMES
|
||||
//
|
||||
// Header is lvgl-free (stdint only) so WebSocketCompanionServer can include it
|
||||
// without pulling in LVGL.
|
||||
// ============================================================================
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
class WebMirror {
|
||||
public:
|
||||
void begin(size_t ring_bytes = 0); // lazily allocate the PSRAM display ring (0 = default size)
|
||||
void setEnabled(bool en); // user opt-in (Settings > Wi-Fi toggle)
|
||||
bool enabled() const { return _enabled; }
|
||||
|
||||
void setScreenSize(int w, int h) { _scr_w = (uint16_t)w; _scr_h = (uint16_t)h; }
|
||||
uint16_t screenW() const { return _scr_w; }
|
||||
uint16_t screenH() const { return _scr_h; }
|
||||
size_t ringBytes() const { return _cap; } // 0 = ring alloc failed (feature can't stream)
|
||||
|
||||
// True only when we should capture/stream: opted in, ring ready, >=1 client.
|
||||
// The LVGL flush hook checks this first, so the feature costs one bool load
|
||||
// when off.
|
||||
bool active() const { return _enabled && _ring && _clients > 0; }
|
||||
|
||||
// ---- display: PRODUCER (LVGL flush thread) ----
|
||||
// Push one framebuffer rect as header+body. Returns false if dropped (ring
|
||||
// backed up); a drop arms a full-repaint so the mirror self-heals.
|
||||
bool pushFrame(const uint8_t* hdr, size_t hdr_len, const uint8_t* body, size_t body_len);
|
||||
|
||||
// ---- display: CONSUMER (network thread) ----
|
||||
size_t popFrame(uint8_t* dst, size_t max_len); // 0 if the ring is empty
|
||||
bool empty() const { return _head == _tail; } // ring fully drained -> producer backpressure gate
|
||||
|
||||
// Full-screen repaint handshake (new client / after a drop). The UI thread
|
||||
// consumes takeFullRepaint() and invalidates the screen.
|
||||
void requestFullRepaint() { _need_full = true; }
|
||||
bool takeFullRepaint() { if (!_need_full) return false; _need_full = false; return true; }
|
||||
|
||||
// Mirror client count, refreshed by the WS server each loop.
|
||||
void noteClients(int n) { _clients = n; }
|
||||
int clients() const { return _clients; }
|
||||
|
||||
// ---- pointer: PRODUCER (network thread) / CONSUMER (LVGL indev) ----
|
||||
void pushPointer(int16_t x, int16_t y, uint8_t pressed);
|
||||
bool readPointer(int16_t* x, int16_t* y, bool* pressed) const; // latest known state
|
||||
|
||||
// ---- keyboard: PRODUCER (network thread) / CONSUMER (LVGL/UI thread) ----
|
||||
void pushKey(uint16_t cp); // codepoint, or a control code (0x08 backspace, 0x0D enter)
|
||||
bool popKey(uint16_t* cp); // false if the queue is empty
|
||||
|
||||
// ---- text-field focus (device -> browser): show/hide the phone soft keyboard ----
|
||||
void setKbFocused(bool f) { if (f != _kb_focused) { _kb_focused = f; _kb_dirty = true; } }
|
||||
bool kbTake(bool* out) { if (!_kb_dirty) return false; _kb_dirty = false; if (out) *out = _kb_focused; return true; }
|
||||
|
||||
// ---- remote-mode flag (device -> browser, carried in the size meta): the browser
|
||||
// shows the Rotate button only in remote mode (VNC mirrors a fixed panel). ----
|
||||
void setRemote(bool r) { _remote = r; }
|
||||
bool remote() const { return _remote; }
|
||||
|
||||
// ---- orientation request (browser -> device): the Rotate button asks for a new axis;
|
||||
// the UI thread consumes it, saves the pref and reboots into that orientation. ----
|
||||
void requestOrient(uint8_t v) { _orient_req = v; } // 1 = landscape, 2 = portrait, 0 = none
|
||||
uint8_t takeOrient() { uint8_t v = _orient_req; _orient_req = 0; return v; }
|
||||
|
||||
// ---- exit-remote request (browser -> device): the Exit button leaves remote mode.
|
||||
// Board-agnostic way out for keyboard-less boards (V4/RAK). ----
|
||||
void requestExit() { _exit_req = true; }
|
||||
bool takeExit() { bool v = _exit_req; _exit_req = false; return v; }
|
||||
|
||||
// ============ web mesh terminal (text CLI over WS; separate from the framebuffer) ============
|
||||
// Lightweight text path: browser sends a command line, the device runs runLocalCli() and
|
||||
// streams the CLI reply text back. No framebuffer, no reboot. Runtime toggle; the WS server
|
||||
// serves the terminal page + a /term socket while on.
|
||||
void termBegin(); // lazily alloc the reply ring (PSRAM); idempotent
|
||||
void setTerminalOn(bool on); // runtime enable (Remote app "Remote terminal")
|
||||
bool terminalOn() const { return _term_on; }
|
||||
void noteTermClients(int n) { _term_clients = n; }
|
||||
int termClients() const { return _term_clients; }
|
||||
bool termActive() const { return _term_on && _term_rep && _term_clients > 0; }
|
||||
|
||||
// browser -> device: one command line. PRODUCER = WS server (net core), CONSUMER = UI/mesh loop.
|
||||
bool pushTermCmd(const char* s, size_t len);
|
||||
size_t popTermCmd(char* dst, size_t max); // one command (0 if none)
|
||||
|
||||
// device -> browser: CLI reply text stream. PRODUCER = mesh loop (CLI sink), CONSUMER = WS server.
|
||||
void pushTermReply(const char* s); // append text (drops if the ring is full)
|
||||
size_t popTermReply(uint8_t* dst, size_t max); // drain up to max bytes (0 if empty)
|
||||
|
||||
// device -> browser: framed JSON data messages (contacts / threads / msgs / live push for the
|
||||
// Contacts + Chats tabs). PRODUCER = UI loop, CONSUMER = WS server. Length-prefixed, so each
|
||||
// pops as one complete message the browser can JSON.parse.
|
||||
bool pushTermData(const char* json); // one complete JSON message (dropped if it doesn't fit)
|
||||
size_t popTermData(uint8_t* dst, size_t max); // one complete message (0 if none)
|
||||
|
||||
private:
|
||||
uint8_t* _ring = nullptr;
|
||||
size_t _cap = 0;
|
||||
volatile size_t _head = 0; // producer writes, consumer reads
|
||||
volatile size_t _tail = 0; // consumer writes, producer reads
|
||||
volatile bool _enabled = false;
|
||||
volatile bool _need_full = false;
|
||||
volatile int _clients = 0;
|
||||
uint16_t _scr_w = 0, _scr_h = 0;
|
||||
volatile int16_t _px = 0, _py = 0;
|
||||
volatile uint8_t _ppressed = 0;
|
||||
volatile uint8_t _pknown = 0;
|
||||
|
||||
static const int KEY_RING = 32; // typed keys awaiting the UI thread (SPSC)
|
||||
uint16_t _keys[KEY_RING];
|
||||
volatile uint8_t _khead = 0, _ktail = 0;
|
||||
volatile bool _kb_focused = false, _kb_dirty = false; // editable-field focus signal
|
||||
volatile bool _remote = false; // remote mode -> browser shows the Rotate button
|
||||
volatile uint8_t _orient_req = 0; // browser-requested orientation (1=landscape, 2=portrait, 0=none)
|
||||
volatile bool _exit_req = false; // browser asked to leave remote mode
|
||||
|
||||
// ---- web terminal channels ----
|
||||
volatile bool _term_on = false;
|
||||
volatile int _term_clients = 0;
|
||||
static const int TERM_CMD_CAP = 512; // command ring: length-prefixed lines (small; commands are short)
|
||||
uint8_t _term_cmd[TERM_CMD_CAP];
|
||||
volatile size_t _tc_head = 0, _tc_tail = 0;
|
||||
uint8_t* _term_rep = nullptr; // reply ring: byte stream, PSRAM, lazily allocated
|
||||
size_t _tr_cap = 0;
|
||||
volatile size_t _tr_head = 0, _tr_tail = 0;
|
||||
uint8_t* _term_data = nullptr; // data ring: length-prefixed JSON messages, PSRAM
|
||||
size_t _td_cap = 0;
|
||||
volatile size_t _td_head = 0, _td_tail = 0;
|
||||
};
|
||||
|
||||
extern WebMirror g_web_mirror;
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <mbedtls/sha1.h>
|
||||
#include <string.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include "WebMirror.h"
|
||||
|
||||
#ifndef WS_FRAME_DEBUG
|
||||
#define WS_FRAME_DEBUG 0
|
||||
@@ -10,17 +12,397 @@
|
||||
|
||||
#define TCP_WRITE_TIMEOUT_MS 120
|
||||
#define WS_WEDGED_DROP_MS 10000
|
||||
#define WS_MIRROR_WRITE_TIMEOUT_MS 250 // mirror bands are bigger than companion frames — a little more headroom before we treat the frame as unrecoverable
|
||||
#define WS_MAGIC "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
#define WS_MIRROR_TXBUF 33000 // max popped mirror frame (band <= 32000 + header)
|
||||
#define WS_MIRROR_TX_BUDGET (48 * 1024) // max mirror bytes pushed per serviceMirror() call
|
||||
#define WS_MIRROR_CLIENT_TXBUF 16384 // per-client outgoing frame buffer (mirror bands <=4 KB; terminal JSON data frames can be larger)
|
||||
|
||||
// Browser opens http://device:8765 — short info page (plain WebSocket endpoint).
|
||||
// Browser opens http://device:8765/ — the web UI mirror page. It opens a
|
||||
// WebSocket to /mirror, paints framebuffer bands the device streams (RGB565 LE,
|
||||
// LV_COLOR_16_SWAP=0), and forwards pointer taps back. Self-contained (no CDN):
|
||||
// a strict LAN, plain-HTTP page. The MeshCore companion app still connects with a
|
||||
// WebSocket upgrade to "/" and is routed to the companion protocol, not here.
|
||||
static const char WS_HTTP_INFO_PAGE[] =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: text/html; charset=utf-8\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
"<!DOCTYPE html><html><head><meta charset=utf-8><title>meshcomod WebSocket</title></head><body>"
|
||||
"<h1>meshcomod WebSocket</h1><p>Plain WebSocket endpoint. Use the meshcomod client with "
|
||||
"<code>ws://</code> to this host and port.</p></body></html>";
|
||||
"<!DOCTYPE html><html><head><meta charset=utf-8>\n"
|
||||
"<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no'>\n"
|
||||
"<link href='https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700&display=swap' rel=stylesheet>\n"
|
||||
"<title>wadamesh</title>\n"
|
||||
"<style>\n"
|
||||
"html,body{margin:0;height:100%;background:#0b0b0c;color:#e8e8ea;font-family:system-ui,-apple-system,sans-serif;overflow:hidden;-webkit-user-select:none;user-select:none;touch-action:none}\n"
|
||||
"#w{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;height:100%;gap:8px;padding-top:6px;box-sizing:border-box}\n"
|
||||
"#c{background:#000;image-rendering:auto;max-width:100vw;box-shadow:0 0 26px #000a;border-radius:8px}\n"
|
||||
"#s{font-size:13px;opacity:.7} #s.ok{color:#19d6c2} #s.err{color:#ff6b6b}\n"
|
||||
"b{color:#19d6c2;letter-spacing:.5px}\n"
|
||||
"#kb{margin-top:8px;padding:9px 18px;font-size:14px;background:#1c1c1f;color:#e8e8ea;border:1px solid #333;border-radius:8px}\n"
|
||||
"#kb:active{background:#19d6c2;color:#000;border-color:#19d6c2}\n"
|
||||
"#rot{margin-top:6px;padding:7px 15px;font-size:12px;background:#141416;color:#c8ccce;border:1px solid #2a2a2e;border-radius:8px;display:none}\n"
|
||||
"#rot:active{background:#19d6c2;color:#000;border-color:#19d6c2}\n"
|
||||
"#xit{margin-top:6px;padding:7px 15px;font-size:12px;background:#1e1416;color:#e0a6a6;border:1px solid #4a2a2e;border-radius:8px;display:none}\n"
|
||||
"#xit:active{background:#ff6b6b;color:#000;border-color:#ff6b6b}\n"
|
||||
"#k{position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;font-size:16px}\n"
|
||||
"#hdr{position:fixed;top:14px;left:18px;display:none;align-items:center;gap:11px;z-index:5}\n"
|
||||
"#hdr .wm{font-family:'JetBrains Mono','Courier New',monospace;font-weight:700;font-size:22px;letter-spacing:2px;color:#e8e8ea}\n"
|
||||
"#hdr .wm .m{color:#15B6A6}\n"
|
||||
// Desktop = a real mouse (hover + fine pointer), NOT window width — a half-width or
|
||||
// display-scaled desktop window is still a desktop. Touch devices get the compact layout.
|
||||
"@media(hover:hover) and (pointer:fine){#hdr{display:flex}#cap{display:none}}\n"
|
||||
"</style></head><body>\n"
|
||||
"<div id=hdr><svg width='40' height='27' viewBox='0 0 180 120' fill='none'><path d='M10 60 L50 108 L90 60 L130 108 L170 60' stroke='#dfe3e8' stroke-width='9' stroke-linecap='round' stroke-linejoin='round'/><path d='M10 60 L50 12 L90 60 L130 12 L170 60' stroke='#dfe3e8' stroke-width='9' stroke-linecap='round' stroke-linejoin='round'/><g fill='#15B6A6'><circle cx='10' cy='60' r='7'/><circle cx='90' cy='60' r='7'/><circle cx='170' cy='60' r='7'/><circle cx='50' cy='12' r='7'/><circle cx='130' cy='12' r='7'/><circle cx='50' cy='108' r='7'/><circle cx='130' cy='108' r='7'/></g></svg><span class=wm>WADA<span class=m>MESH</span></span></div>\n"
|
||||
"<div id=w>\n"
|
||||
"<div id=cap><b>wadamesh</b> live UI</div>\n"
|
||||
"<canvas id=c width=320 height=240></canvas>\n"
|
||||
"<div id=s>connecting...</div>\n"
|
||||
"<button id=kb>⌨ Keyboard</button>\n"
|
||||
"<button id=rot>↻ Rotate</button>\n"
|
||||
"<button id=xit>✕ Exit remote</button>\n"
|
||||
"<textarea id=k autocomplete=off autocorrect=off autocapitalize=off spellcheck=false></textarea>\n"
|
||||
"</div>\n"
|
||||
"<script>\n"
|
||||
"var C=document.getElementById('c'),X=C.getContext('2d'),S=document.getElementById('s');\n"
|
||||
"var OT=document.createElement('canvas'),OX=OT.getContext('2d');\n"
|
||||
"var DW=320,DH=240,down=false,last=0,ws;\n"
|
||||
"var isTouch=('ontouchstart' in window)||navigator.maxTouchPoints>0,kbT=null;\n"
|
||||
"var ROT=document.getElementById('rot'),XIT=document.getElementById('xit');\n"
|
||||
"function st(t,k){S.textContent=t;S.className=k||''}\n"
|
||||
"function conn(){\n"
|
||||
" ws=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/mirror');\n"
|
||||
" ws.binaryType='arraybuffer';\n"
|
||||
" ws.onopen=function(){st('connected','ok')};\n"
|
||||
" ws.onclose=function(){st('disconnected - retrying','err');setTimeout(conn,1500)};\n"
|
||||
" ws.onerror=function(){st('connection error','err')};\n"
|
||||
" ws.onmessage=function(e){\n"
|
||||
" var a=new Uint8Array(e.data);\n"
|
||||
" if(a[0]==2){DW=a[1]|(a[2]<<8);DH=a[3]|(a[4]<<8);if(C.width!=DW)C.width=DW;if(C.height!=DH)C.height=DH;var rem=(a.length>5&&(a[5]&1))?'':'none';ROT.style.display=rem;XIT.style.display=rem;fit();return}\n"
|
||||
" if(a[0]==3){clearTimeout(kbT);if(a[1])K.focus();else K.blur();return}\n"
|
||||
" if(a[0]==1){\n"
|
||||
" var fl=a[1],x=a[2]|(a[3]<<8),y=a[4]|(a[5]<<8),w=a[6]|(a[7]<<8),h=a[8]|(a[9]<<8);\n"
|
||||
" var hf=fl&2,ow=hf?((w+1)>>1):w,oh=hf?((h+1)>>1):h;\n"
|
||||
" var img=X.createImageData(ow,oh),d=img.data,L=d.length,p=10,o=0,v,r,g,b,c;\n"
|
||||
" if(fl&1){\n"
|
||||
" while(o<L){c=a[p++];v=a[p]|(a[p+1]<<8);p+=2;r=(v>>8)&0xf8;g=(v>>3)&0xfc;b=(v<<3)&0xf8;\n"
|
||||
" while(c-->0){d[o++]=r;d[o++]=g;d[o++]=b;d[o++]=255}}\n"
|
||||
" }else{\n"
|
||||
" while(o<L){v=a[p]|(a[p+1]<<8);p+=2;d[o++]=(v>>8)&0xf8;d[o++]=(v>>3)&0xfc;d[o++]=(v<<3)&0xf8;d[o++]=255}\n"
|
||||
" }\n"
|
||||
" if(hf){OT.width=ow;OT.height=oh;OX.putImageData(img,0,0);X.imageSmoothingEnabled=true;X.drawImage(OT,0,0,ow,oh,x,y,w,h)}\n"
|
||||
" else{X.putImageData(img,x,y)}\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"function xy(e){var r=C.getBoundingClientRect();\n"
|
||||
" var x=(e.clientX-r.left)*DW/r.width,y=(e.clientY-r.top)*DH/r.height;\n"
|
||||
" x=x<0?0:x>=DW?DW-1:x;y=y<0?0:y>=DH?DH-1:y;return[x|0,y|0]}\n"
|
||||
"function snd(x,y,pr){if(!ws||ws.readyState!=1)return;ws.send(new Uint8Array([1,x&255,x>>8,y&255,y>>8,pr]))}\n"
|
||||
"C.addEventListener('pointerdown',function(e){down=true;try{C.setPointerCapture(e.pointerId)}catch(x){}var p=xy(e);snd(p[0],p[1],1);e.preventDefault()});\n"
|
||||
"C.addEventListener('pointermove',function(e){if(!down)return;var t=Date.now();if(t-last<33)return;last=t;var p=xy(e);snd(p[0],p[1],1)});\n"
|
||||
"function up(e){if(!down)return;down=false;var p=xy(e);snd(p[0],p[1],0);\n"
|
||||
" if(isTouch){K.value=' ';K.focus();try{K.setSelectionRange(1,1)}catch(x){}clearTimeout(kbT);kbT=setTimeout(function(){K.blur()},350)}}\n"
|
||||
"C.addEventListener('pointerup',up);C.addEventListener('pointercancel',up);\n"
|
||||
"var K=document.getElementById('k');\n"
|
||||
"document.getElementById('kb').addEventListener('click',function(){K.value=' ';K.focus();try{K.setSelectionRange(1,1)}catch(x){}});\n"
|
||||
"ROT.addEventListener('click',function(){if(!ws||ws.readyState!=1)return;st('rotating - reconnecting','');ws.send(new Uint8Array([4,DW<DH?1:0]))});\n"
|
||||
"XIT.addEventListener('click',function(){if(!ws||ws.readyState!=1)return;if(!confirm('Leave remote mode? The device reboots to its normal screen.'))return;st('leaving remote - rebooting','');ws.send(new Uint8Array([5]))});\n"
|
||||
"function skey(cp){if(!ws||ws.readyState!=1)return;ws.send(new Uint8Array([2,cp&255,(cp>>8)&255]))}\n"
|
||||
"K.addEventListener('beforeinput',function(e){var t=e.inputType;\n"
|
||||
" if(t=='insertText'&&e.data){for(var i=0;i<e.data.length;i++)skey(e.data.codePointAt(i));e.preventDefault()}\n"
|
||||
" else if(t=='deleteContentBackward'){skey(8);e.preventDefault()}\n"
|
||||
" else if(t=='insertLineBreak'||t=='insertParagraph'){skey(13);e.preventDefault()}\n"
|
||||
" K.value=' ';try{K.setSelectionRange(1,1)}catch(x){}});\n"
|
||||
"window.addEventListener('keydown',function(e){\n"
|
||||
" if(document.activeElement===K)return;if(e.ctrlKey||e.metaKey||e.altKey)return;\n"
|
||||
" if(e.key=='Backspace'){skey(8);e.preventDefault()}\n"
|
||||
" else if(e.key=='Enter'){skey(13);e.preventDefault()}\n"
|
||||
" else if(e.key.length==1){skey(e.key.codePointAt(0));e.preventDefault()}});\n"
|
||||
"function fit(){var vv=window.visualViewport,vw=vv?vv.width:window.innerWidth,vh=vv?vv.height:window.innerHeight;\n"
|
||||
" var desk=matchMedia('(hover:hover) and (pointer:fine)').matches,ch=desk?102:60,f=desk?0.72:0.99;\n"
|
||||
" var aw=vw-8,ah=vh-ch;if(aw<60)aw=60;if(ah<60)ah=60;var s=Math.min(aw/DW,ah/DH)*f;\n"
|
||||
" C.style.width=Math.max(1,Math.round(DW*s))+'px';C.style.height=Math.max(1,Math.round(DH*s))+'px'}\n"
|
||||
"if(window.visualViewport)window.visualViewport.addEventListener('resize',fit);\n"
|
||||
"window.addEventListener('resize',fit);fit();\n"
|
||||
"conn();\n"
|
||||
"</script></body></html>";
|
||||
|
||||
// Self-contained web mesh terminal (served at / while web_terminal is on). Talks the
|
||||
// device's runLocalCli over a /term WebSocket: type a command, get the reply streamed
|
||||
// back. Command history (up/down), tab-completion, clear, styled monospace output.
|
||||
static const char WS_HTML_TERMINAL_PAGE[] =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Type: text/html; charset=utf-8\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
"<!DOCTYPE html><html><head><meta charset=utf-8>\n"
|
||||
"<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1'>\n"
|
||||
"<title>wadamesh</title>\n"
|
||||
"<style>\n"
|
||||
"*{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%}\n"
|
||||
"#w{display:flex;flex-direction:column;height:100%}\n"
|
||||
"#hd{padding:8px 10px;border-bottom:1px solid #1c1c1f;display:flex;align-items:center;gap:8px}#hdl{flex:1}\n"
|
||||
"#hd b{color:#19d6c2;font-weight:700;letter-spacing:2px}#st{font-size:12px}\n"
|
||||
"#tabs{display:flex;border-bottom:1px solid #1c1c1f}\n"
|
||||
"#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}\n"
|
||||
"#tabs button.on{color:#19d6c2;border-bottom-color:#19d6c2}\n"
|
||||
".pane{flex:1;min-height:0;display:flex;flex-direction:column}\n"
|
||||
".list{flex:1;overflow-y:auto}\n"
|
||||
".row{display:flex;padding:11px 13px;border-bottom:1px solid #141416;cursor:pointer;gap:10px;align-items:center}\n"
|
||||
".row:active{background:#141416}.rmain{flex:1;min-width:0}\n"
|
||||
".rname{font-weight:600;color:#e8e8ea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"
|
||||
".rsub{font-size:12px;color:#7f868c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}\n"
|
||||
".rtime{font-size:11px;color:#63696e;white-space:nowrap}\n"
|
||||
".badge{background:#19d6c2;color:#04201d;border-radius:9px;padding:0 6px;font-size:11px;margin-left:6px;font-weight:700}\n"
|
||||
".sec{padding:9px 13px 4px;font-size:11px;color:#63696e;letter-spacing:1px}\n"
|
||||
".empty{padding:26px 13px;color:#63696e;text-align:center}\n"
|
||||
"#cview{flex:1;min-height:0;flex-direction:column}\n"
|
||||
"#cvh{display:flex;align-items:center;gap:6px;padding:8px 8px;border-bottom:1px solid #1c1c1f}\n"
|
||||
"#cvback{background:none;border:none;color:#19d6c2;font-size:24px;line-height:1;cursor:pointer;padding:0 6px}\n"
|
||||
"#cvname{font-weight:600}\n"
|
||||
"#cvmsgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px}\n"
|
||||
".b{max-width:80%;padding:6px 9px;border-radius:11px;word-break:break-word}\n"
|
||||
".b.in{align-self:flex-start;background:#17181b}.b.out{align-self:flex-end;background:#123f3a}\n"
|
||||
".bname{font-size:11px;color:#19d6c2;margin-bottom:2px}.btext{white-space:pre-wrap}\n"
|
||||
".bmeta{font-size:10px;color:#8a9095;text-align:right;margin-top:2px}\n"
|
||||
".cb{display:flex;border-top:1px solid #1c1c1f;padding:8px;gap:8px}\n"
|
||||
".cb input{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:18px;padding:9px 14px;font:inherit;outline:none}\n"
|
||||
".cb input:focus{border-color:#19d6c2}\n"
|
||||
".cb button{background:#19d6c2;color:#04201d;border:none;border-radius:18px;padding:0 16px;font-weight:700;cursor:pointer}\n"
|
||||
"#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}\n"
|
||||
"#tbar{display:flex;border-top:1px solid #1c1c1f;padding:8px 10px;gap:8px;align-items:center}\n"
|
||||
"#p{color:#19d6c2;font-weight:700}\n"
|
||||
"#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}\n"
|
||||
"#i:focus{border-color:#19d6c2}\n"
|
||||
".e{color:#7f868c}.c{color:#e8b84b}.ok{color:#19d6c2}.err{color:#ff6b6b}\n"
|
||||
"#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}\n"
|
||||
"#dot{font-size:11px;color:#63696e}#hdr{display:flex;align-items:center;gap:8px;font-size:12px;color:#9aa0a6;white-space:nowrap}\n"
|
||||
"#hdr svg{vertical-align:middle;display:block}#hdr>span{display:inline-flex;align-items:center}.bpct{font-size:11px;margin-left:3px;color:#9aa0a6}\n"
|
||||
".bars{letter-spacing:-1px;color:#19d6c2}.bars i{color:#3a3f44;font-style:normal}\n"
|
||||
".bgrn{color:#19d6c2}.byel{color:#e8b84b}.bred{color:#ff6b6b}\n"
|
||||
"#ctools{display:flex;gap:6px;padding:7px 8px;border-bottom:1px solid #1c1c1f}\n"
|
||||
"#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}\n"
|
||||
"#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}\n"
|
||||
".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}\n"
|
||||
"#scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:19;display:none}#scrim.on{display:block}\n"
|
||||
"#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}\n"
|
||||
"#qcmd{background:#17181b;color:#19d6c2;border:1px solid #2a2a2e;border-radius:6px;padding:0 11px;font-size:16px;line-height:34px;cursor:pointer}\n"
|
||||
"#sheet.on{transform:translateY(0)}#sheet h4{margin:8px 16px 4px;font-size:13px;color:#9aa0a6}\n"
|
||||
".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}\n"
|
||||
"#modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:21;padding:20px}#modal.on{display:flex}\n"
|
||||
"#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}\n"
|
||||
"#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}\n"
|
||||
"#mclose{margin-top:12px;width:100%;background:#19d6c2;color:#04201d;border:none;border-radius:8px;padding:10px;font-weight:700;cursor:pointer}\n"
|
||||
"#gear{background:none;border:none;color:#9aa0a6;font-size:17px;cursor:pointer;padding:0 2px;line-height:1}\n"
|
||||
".sset label{display:block;font-size:11px;color:#63696e;letter-spacing:1px;margin:12px 0 4px}\n"
|
||||
".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%}\n"
|
||||
".srow{display:flex;gap:8px;align-items:center;margin:4px 0}.srow input{flex:1;min-width:0}\n"
|
||||
".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}\n"
|
||||
".sgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.sgrid .f label{margin:2px 0}.sgrid .f{min-width:0}\n"
|
||||
".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}\n"
|
||||
".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}\n"
|
||||
".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}\n"
|
||||
"#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}\n"
|
||||
"#toast.on{transform:translateY(0)}#toast b{color:#19d6c2;display:block;font-size:13px;margin-bottom:1px}\n"
|
||||
"#toast span{font-size:12px;color:#cfeee9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}\n"
|
||||
"</style></head><body>\n"
|
||||
"<div id=w>\n"
|
||||
"<div id=hd><div id=hdl><span id=dot>•</span><b>WADAMESH</b><span id=hnm></span></div>\n"
|
||||
" <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>⚙</button></div>\n"
|
||||
"<div id=tabs><button data-t=chats class=on>Chats</button><button data-t=contacts>Contacts</button><button data-t=term>Terminal</button></div>\n"
|
||||
"<div id=pchats class=pane>\n"
|
||||
" <div id=tlist class=list></div>\n"
|
||||
" <div id=cview style=display:none>\n"
|
||||
" <div id=cvh><button id=cvback>‹</button><span id=cvname></span></div>\n"
|
||||
" <div id=cvmsgs></div>\n"
|
||||
" <div class=cb><input id=cvi placeholder='Message' autocomplete=off><button id=cvsend>Send</button></div>\n"
|
||||
" </div>\n"
|
||||
"</div>\n"
|
||||
"<div id=pcontacts class=pane style=display:none>\n"
|
||||
" <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>\n"
|
||||
" <div id=clist class=list></div>\n"
|
||||
"</div>\n"
|
||||
"<div id=pterm class=pane style=display:none>\n"
|
||||
" <div id=o></div>\n"
|
||||
" <div id=tbar><span id=p>></span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>⚡</button></div>\n"
|
||||
"</div>\n"
|
||||
"</div>\n"
|
||||
"<div id=scrim></div>\n"
|
||||
"<div id=sheet></div>\n"
|
||||
"<div id=modal><div id=mbox></div></div>\n"
|
||||
"<div id=toast></div>\n"
|
||||
"<script>\n"
|
||||
"var WS,dec=new TextDecoder(),curTab='chats',chat=null,threads=[],contacts=[],channels=[];\n"
|
||||
"var csort=0,cfilter=0,csearchq='',self={la:0,lo:0},lpAt=0,useMi=0,discCount=0;\n"
|
||||
"var SORTL=['Recent','A-Z','Distance'],FILTL=['All','Repeaters','Peers','Favorites','Located','Direct'];\n"
|
||||
"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'}\n"
|
||||
"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'}\n"
|
||||
"function E(i){return document.getElementById(i)}\n"
|
||||
"function each(l,f){Array.prototype.forEach.call(l,f)}\n"
|
||||
"function esc(s){return (s==null?'':''+s).replace(/[&<>\"]/g,function(c){return({'&':'&','<':'<','>':'>','\"':'"'})[c]})}\n"
|
||||
"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)}\n"
|
||||
"function fmtDate(t){return (t>1000000000)?new Date(t*1000).toLocaleString():'--'}\n"
|
||||
"function stat(t,k){var el=E('dot');el.style.color=(k=='ok')?'#19d6c2':((k=='err')?'#ff6b6b':'#63696e');el.title=t}\n"
|
||||
"function wsSend(s){if(WS&&WS.readyState==1){WS.send(s);return true}return false}\n"
|
||||
"function cById(i){for(var k=0;k<contacts.length;k++)if(contacts[k].i==i)return contacts[k];return null}\n"
|
||||
"function showTab(t){curTab=t;['chats','contacts','term'].forEach(function(x){E('p'+x).style.display=(x==t)?'':'none'});\n"
|
||||
" each(document.querySelectorAll('#tabs button'),function(b){b.className=(b.getAttribute('data-t')==t)?'on':''});\n"
|
||||
" if(t=='chats'){if(!chat){closeChat();wsSend('@t')}}else if(t=='contacts'){wsSend('@c')}else if(t=='term'){E('i').focus()}}\n"
|
||||
"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>'}\n"
|
||||
"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>'}\n"
|
||||
"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>'}\n"
|
||||
"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>'}\n"
|
||||
"function renderStatus(d){E('hnm').textContent=d.nm||'';self.la=d.sla||0;self.lo=d.slo||0;useMi=d.mi?1:0;\n"
|
||||
" var lvl=(d.snr<=-127)?0:(d.snr>=5?4:(d.snr>=0?3:(d.snr>=-7?2:1)));\n"
|
||||
" E('hsig').innerHTML=svgSig(lvl);E('hwifi').innerHTML=d.wifi?svgWifi(1):'';E('hble').innerHTML=d.ble?svgBt():'';\n"
|
||||
" E('hbatt').innerHTML=(d.batt<0)?'':(svgBatt(d.batt,d.chg)+'<span class=bpct>'+d.batt+'%</span>');\n"
|
||||
" E('hclk').textContent=fmt(d.clk);\n"
|
||||
" var cb=document.querySelector('#tabs [data-t=chats]');if(cb)cb.textContent=d.unr>0?('Chats ('+d.unr+')'):'Chats'}\n"
|
||||
"function closeOv(){E('sheet').classList.remove('on');E('modal').classList.remove('on');E('scrim').classList.remove('on')}\n"
|
||||
"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>'});\n"
|
||||
" 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()}});\n"
|
||||
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
|
||||
"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}\n"
|
||||
"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()}}\n"
|
||||
"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}}\n"
|
||||
" el.addEventListener('touchstart',s,{passive:true});el.addEventListener('touchmove',c,{passive:true});el.addEventListener('touchend',c);\n"
|
||||
" el.addEventListener('mousedown',s);el.addEventListener('mouseup',c);el.addEventListener('mouseleave',c);el.addEventListener('contextmenu',function(e){e.preventDefault();lpAt=Date.now();fn()})}\n"
|
||||
"function clicked(fn){return function(){if(Date.now()-lpAt<650)return;fn()}}\n"
|
||||
"function contactSheet(c){sheet(c.n||'Contact',[\n"
|
||||
" {label:'Message',fn:function(){if(c.t>=0)wsSend('@m '+c.t);else wsSend('@oc '+c.i)}},\n"
|
||||
" {label:'Info',fn:function(){wsSend('@ic '+c.i)}},\n"
|
||||
" {label:c.fav?'Unfavorite':'Favorite',fn:function(){wsSend('@fv '+c.i)}},\n"
|
||||
" {label:'Reset path',fn:function(){wsSend('@pr '+c.i)}},\n"
|
||||
" {label:c.ign?'Unblock':'Block',fn:function(){wsSend('@bk '+c.i)}},\n"
|
||||
" {label:'Delete',cls:'dng',fn:function(){if(confirm('Delete '+(c.n||'contact')+'?'))wsSend('@xc '+c.i)}}])}\n"
|
||||
"function chatSheet(t){sheet(t.n||'Chat',[\n"
|
||||
" {label:'Mark as read',fn:function(){wsSend('@r '+t.i)}},\n"
|
||||
" {label:'Clear history',fn:function(){if(confirm('Clear history?'))wsSend('@d '+t.i)}},\n"
|
||||
" {label:'Delete chat',cls:'dng',fn:function(){if(confirm('Delete this chat?'))wsSend('@xt '+t.i)}}])}\n"
|
||||
"function msgSheet(m){sheet('Message',[{label:'Copy',fn:function(){copy(m.x)}},{label:'Info',fn:function(){msgInfo(m)}}])}\n"
|
||||
"function msgInfo(m){var pl=(m.pl==255)?'Direct':(m.pl+' hop'+(m.pl==1?'':'s'));\n"
|
||||
" 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>';\n"
|
||||
" 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>';\n"
|
||||
" if(m.o)h+='<div class=kv><b>Delivery</b><span>'+(({0:'--',1:'Sent',2:'Delivered',3:'Failed'})[m.ds]||'--')+'</span></div>';\n"
|
||||
" if(m.o&&m.rp>0)h+='<div class=kv><b>Repeats heard</b><span>'+m.rp+'</span></div>';\n"
|
||||
" modal('Message info',h)}\n"
|
||||
"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')));\n"
|
||||
" 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>';\n"
|
||||
" if(d.adv>1000000000)h+='<div class=kv><b>Last heard</b><span>'+fmtDate(d.adv)+'</span></div>';\n"
|
||||
" 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>'}\n"
|
||||
" h+='<div class=kv><b>ID</b><span>'+esc(d.k)+'</span></div>';modal(d.n||'Contact',h)}\n"
|
||||
"var prevUnread={},toastTmr=null,AC=null;\n"
|
||||
"function onThreads(nt){\n"
|
||||
" 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')});\n"
|
||||
" prevUnread={};nt.forEach(function(t){prevUnread[t.i]=t.u});threads=nt;renderThreads()}\n"
|
||||
"function notify(ti,title,body){showToast(ti,title,body);\n"
|
||||
" try{if('Notification' in window&&Notification.permission=='granted'&&document.hidden)new Notification(title,{body:body})}catch(e){}\n"
|
||||
" try{beep()}catch(e){}}\n"
|
||||
"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;\n"
|
||||
" t.onclick=function(){t.classList.remove('on');wsSend('@m '+ti)};t.classList.add('on');\n"
|
||||
" if(toastTmr)clearTimeout(toastTmr);toastTmr=setTimeout(function(){t.classList.remove('on')},4200)}\n"
|
||||
"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)}\n"
|
||||
"document.addEventListener('click',function(){try{if('Notification' in window&&Notification.permission=='default')Notification.requestPermission()}catch(e){}},{once:true});\n"
|
||||
"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'},\n"
|
||||
" {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'},\n"
|
||||
" {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 '},\n"
|
||||
" {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'},\n"
|
||||
" {h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];\n"
|
||||
"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>'});\n"
|
||||
" 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()}});\n"
|
||||
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
|
||||
"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'];\n"
|
||||
"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}\n"
|
||||
"function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}\n"
|
||||
" tadd('\\n> '+v+'\\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\\n','err')}\n"
|
||||
"function onData(s){var d;try{d=JSON.parse(s)}catch(_){return}\n"
|
||||
" if(d.t=='t'){onThreads(d.threads||[])}\n"
|
||||
" else if(d.t=='c'){contacts=d.contacts||[];channels=d.channels||[];discCount=d.dc||0;renderContacts()}\n"
|
||||
" else if(d.t=='m'){openChatView(d)}\n"
|
||||
" else if(d.t=='st'){renderStatus(d)}\n"
|
||||
" else if(d.t=='ci'){showContactInfo(d)}\n"
|
||||
" else if(d.t=='dc'){showDiscovered(d.nodes||[])}\n"
|
||||
" else if(d.t=='sg'){showSettings(d)}\n"
|
||||
" else if(d.t=='rx'){wsSend('@t');if(chat&&chat.i>=0)wsSend('@m '+chat.i);if(curTab=='contacts')wsSend('@c')}}\n"
|
||||
"function renderThreads(){var h='';if(!threads.length)h='<div class=empty>No chats yet.<br>Start one from Contacts.</div>';\n"
|
||||
" threads.sort(function(a,b){return (b.ts||0)-(a.ts||0)});\n"
|
||||
" threads.forEach(function(t){var bd=t.u>0?'<span class=badge>'+t.u+'</span>':'';\n"
|
||||
" 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>'});\n"
|
||||
" E('tlist').innerHTML=h;each(E('tlist').querySelectorAll('.row'),function(r){var ti=+r.getAttribute('data-ti');\n"
|
||||
" 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)})})}\n"
|
||||
"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;\n"
|
||||
" 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))}\n"
|
||||
"function cVis(c){if(csearchq&&(c.n||'').toLowerCase().indexOf(csearchq)<0)return false;\n"
|
||||
" 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}\n"
|
||||
"function renderContacts(){E('csort').textContent='Sort: '+SORTL[csort];E('cfilt').textContent=FILTL[cfilter];var h='';\n"
|
||||
" 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>'}\n"
|
||||
" 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>'})}\n"
|
||||
" 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)});\n"
|
||||
" h+='<div class=sec>CONTACTS'+(cs.length?' ('+cs.length+')':'')+'</div>';if(!cs.length)h+='<div class=empty>No contacts.</div>';\n"
|
||||
" 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>':'';\n"
|
||||
" var sub=[fmtDist(cdist(c)),age(c.adv)].filter(Boolean).join(' \\u00b7 ');if(c.dir)sub=sub?(sub+' \\u00b7 direct'):'direct';\n"
|
||||
" 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>'});\n"
|
||||
" E('clist').innerHTML=h;var dr=E('discrow');if(dr)dr.onclick=function(){wsSend('@dc')};\n"
|
||||
" each(E('clist').querySelectorAll('.row'),function(r){\n"
|
||||
" 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)}\n"
|
||||
" 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'))})}})}\n"
|
||||
"function showDiscovered(nodes){if(!nodes.length){sheet('Discovered nodes',[{label:'No new nodes heard yet'}]);return}\n"
|
||||
" 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 ');\n"
|
||||
" return {label:(nd.n||'(unnamed)')+tag+(meta?' ('+meta+')':''),fn:function(){wsSend('@ad '+nd.i);showToast(-1,'Added contact',nd.n||'')}}});\n"
|
||||
" sheet('Discovered - tap to add',items)}\n"
|
||||
"function showSettings(d){var g=E;\n"
|
||||
" 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>';\n"
|
||||
" h+='<label>RADIO (applies live)</label><div class=sgrid>';\n"
|
||||
" 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>';\n"
|
||||
" 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>';\n"
|
||||
" h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label> </label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';\n"
|
||||
" h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';\n"
|
||||
" if(d.blecap)h+='<div class=stog><b>Bluetooth</b><div class=sw id=sble><i></i></div></div>';\n"
|
||||
" h+='<div class=stog><b>TCP (phone app)</b><div class=sw id=stcp><i></i></div></div>';\n"
|
||||
" h+='<div class=srow2><button id=sadvert>Send advert</button><button id=sreboot class=dng>Reboot</button></div></div>';\n"
|
||||
" E('mbox').innerHTML='<h3>Settings</h3>'+h+'<button id=mclose>Close</button>';\n"
|
||||
" 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;\n"
|
||||
" function tg(id,on){var el=g(id);if(el){on?el.classList.add('on'):el.classList.remove('on')}}\n"
|
||||
" tg('swifi',d.wifi);tg('sble',d.ble);tg('stcp',d.tcp);\n"
|
||||
" g('sbname').onclick=function(){var v=g('sfname').value.trim();if(v){wsSend('set name '+v);showToast(-1,'Name saved',v)}};\n"
|
||||
" 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','')};\n"
|
||||
" g('swifi').onclick=function(){var on=!g('swifi').classList.contains('on');tg('swifi',on);wsSend('wifi '+(on?'on':'off'))};\n"
|
||||
" if(g('sble'))g('sble').onclick=function(){var on=!g('sble').classList.contains('on');tg('sble',on);wsSend('ble '+(on?'on':'off'))};\n"
|
||||
" g('stcp').onclick=function(){var on=!g('stcp').classList.contains('on');tg('stcp',on);wsSend('tcp '+(on?'on':'off'))};\n"
|
||||
" g('sadvert').onclick=function(){wsSend('advert');showToast(-1,'Advert sent','')};\n"
|
||||
" g('sreboot').onclick=function(){if(confirm('Reboot the device now?'))wsSend('reboot')};\n"
|
||||
" g('mclose').onclick=closeOv;E('modal').classList.add('on');E('scrim').classList.add('on')}\n"
|
||||
"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;\n"
|
||||
" 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)};\n"
|
||||
" showTab('chats');E('tlist').style.display='none';E('cview').style.display='flex';E('cvname').textContent=(chat.ch?'# ':'')+chat.n;\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);\n"
|
||||
" if(m.o){meta+=' '+(({1:'\\u2713',2:'\\u2713\\u2713',3:'\\u2717'})[m.ds]||'');if(m.rp>0)meta+=' \\u21bb'+m.rp}\n"
|
||||
" var nm=(!m.o&&chat.ch)?'<div class=bname>'+esc(m.s)+'</div>':'';\n"
|
||||
" h+='<div class=\"'+cls+'\" data-mi='+i+'>'+nm+'<div class=btext>'+esc(m.x)+'</div><div class=bmeta>'+meta+'</div></div>'});\n"
|
||||
" E('cvmsgs').innerHTML=h;if(!same||wasBottom)E('cvmsgs').scrollTop=E('cvmsgs').scrollHeight;\n"
|
||||
" each(E('cvmsgs').querySelectorAll('.b'),function(b){var m=arr[+b.getAttribute('data-mi')];longPress(b,function(){if(m)msgSheet(m)})})}\n"
|
||||
"function closeChat(){chat=null;E('cview').style.display='none';E('tlist').style.display=''}\n"
|
||||
"function chatSend(){var v=E('cvi').value.trim();if(!v||!chat)return;E('cvi').value='';\n"
|
||||
" 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)}\n"
|
||||
"function conn(){WS=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/term');WS.binaryType='arraybuffer';\n"
|
||||
" WS.onopen=function(){stat('online','ok');wsSend('@st');if(curTab=='chats')wsSend('@t')};\n"
|
||||
" WS.onclose=function(){stat('reconnecting...','err');setTimeout(conn,1500)};WS.onerror=function(){stat('error','err')};\n"
|
||||
" 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)}}\n"
|
||||
"each(document.querySelectorAll('#tabs button'),function(b){b.onclick=function(){showTab(b.getAttribute('data-t'))}});\n"
|
||||
"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')};\n"
|
||||
"E('csort').onclick=function(){sheet('Sort by',SORTL.map(function(s,i){return {label:(i==csort?'\\u2713 ':'')+s,fn:function(){csort=i;renderContacts()}}}))};\n"
|
||||
"E('cfilt').onclick=function(){sheet('Filter',FILTL.map(function(f,i){return {label:(i==cfilter?'\\u2713 ':'')+f,fn:function(){cfilter=i;renderContacts()}}}))};\n"
|
||||
"E('csearch').addEventListener('input',function(){csearchq=E('csearch').value.trim().toLowerCase();renderContacts()});\n"
|
||||
"E('cvback').onclick=function(){closeChat();wsSend('@t')};E('cvsend').onclick=chatSend;\n"
|
||||
"E('cvi').addEventListener('keydown',function(e){if(e.key=='Enter'){chatSend();e.preventDefault()}});\n"
|
||||
"E('i').addEventListener('keydown',function(e){if(e.key=='Enter'){tsend();e.preventDefault()}\n"
|
||||
" else if(e.key=='ArrowUp'){if(hi>0){hi--;E('i').value=hist[hi];e.preventDefault()}}\n"
|
||||
" 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()}\n"
|
||||
" else if(e.key=='Tab'){var q=E('i').value.toLowerCase(),m=CMDS.filter(function(c){return c.indexOf(q)==0});\n"
|
||||
" if(m.length==1)E('i').value=m[0];else if(m.length>1)tadd('\\n'+m.join(' ').trim()+'\\n','e');e.preventDefault()}});\n"
|
||||
"tadd('wadamesh terminal - node config + messaging. help = all commands.\\n\\n','e');\n"
|
||||
"conn();showTab('chats');\n"
|
||||
"</script></body></html>";
|
||||
|
||||
#define COMP_STATE_IDLE 0
|
||||
#define COMP_STATE_HDR_FOUND 1
|
||||
@@ -53,6 +435,12 @@ static void base64Encode20(const uint8_t* in, char* out) {
|
||||
// in TCPCompanionServer.cpp. Without it, WiFiClient::write() against a peer that
|
||||
// stopped ACKing (half-open socket) blocks in 1 s select() waits on the loop thread.
|
||||
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;
|
||||
@@ -62,6 +450,7 @@ static bool socketWritableNow(WiFiClient& client) {
|
||||
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) {
|
||||
@@ -82,8 +471,22 @@ static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, ui
|
||||
return true;
|
||||
}
|
||||
|
||||
// RAII lock for the _clients array. Recursive so nested same-thread calls
|
||||
// (serviceMirror -> writeBinaryFrame -> drainClientTx -> disconnectClient, or
|
||||
// tickHandshake -> acceptNewClients/pruneDisconnected) never self-deadlock.
|
||||
namespace {
|
||||
struct WsClientsLock {
|
||||
SemaphoreHandle_t m;
|
||||
explicit WsClientsLock(SemaphoreHandle_t mtx) : m(mtx) { if (m) xSemaphoreTakeRecursive(m, portMAX_DELAY); }
|
||||
~WsClientsLock() { if (m) xSemaphoreGiveRecursive(m); }
|
||||
WsClientsLock(const WsClientsLock&) = delete;
|
||||
WsClientsLock& operator=(const WsClientsLock&) = delete;
|
||||
};
|
||||
}
|
||||
|
||||
WebSocketCompanionServer::WebSocketCompanionServer()
|
||||
: _server(WiFiServer()), _port(0), _listening(false), _poll_start_idx(0) {
|
||||
: _server(WiFiServer()), _port(0), _listening(false), _poll_start_idx(0), _mirror_txbuf(nullptr) {
|
||||
_client_mtx = xSemaphoreCreateRecursiveMutex();
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
_clients[i].in_use = false;
|
||||
_clients[i].accept_ms = 0;
|
||||
@@ -92,6 +495,12 @@ WebSocketCompanionServer::WebSocketCompanionServer()
|
||||
_clients[i].ws_state = WS_STATE_HEADER_0;
|
||||
_clients[i].comp_state = COMP_STATE_IDLE;
|
||||
_clients[i].stall_ms = 0;
|
||||
_clients[i].is_mirror = false;
|
||||
_clients[i].is_term = false;
|
||||
_clients[i].meta_sent = false;
|
||||
_clients[i].tx_buf = nullptr;
|
||||
_clients[i].tx_len = 0;
|
||||
_clients[i].tx_sent = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +512,7 @@ void WebSocketCompanionServer::begin(uint16_t port) {
|
||||
}
|
||||
|
||||
void WebSocketCompanionServer::stop() {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
_listening = false;
|
||||
_server.stop();
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
@@ -126,43 +536,55 @@ void WebSocketCompanionServer::resumeListen() {
|
||||
Serial.printf("[WS] listen resumed port=%u (plain)\n", (unsigned)_port);
|
||||
}
|
||||
|
||||
void WebSocketCompanionServer::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).
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
int slot = -1;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
if (!_clients[i].in_use) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// No free slot: evict the oldest existing client so a fresh connect can
|
||||
// recover from stuck slots (half-closed sockets where connected() still
|
||||
// reports true). Without this, every new connect is accept()ed then
|
||||
// immediately stop()ed, producing SYN/SYN+ACK/ACK/FIN-ACK with zero data.
|
||||
if (slot < 0) {
|
||||
uint32_t now = millis();
|
||||
uint32_t oldest_age = 0;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
uint32_t age = now - _clients[i].accept_ms;
|
||||
if (slot < 0 || age > oldest_age) {
|
||||
slot = i;
|
||||
oldest_age = age;
|
||||
}
|
||||
}
|
||||
_clients[slot].client.stop();
|
||||
_clients[slot].in_use = false;
|
||||
}
|
||||
_clients[slot].client = incoming;
|
||||
_clients[slot].in_use = true;
|
||||
_clients[slot].accept_ms = millis();
|
||||
_clients[slot].handshake_done = false;
|
||||
_clients[slot].handshake_len = 0;
|
||||
_clients[slot].ws_state = WS_STATE_HEADER_0;
|
||||
_clients[slot].comp_state = COMP_STATE_IDLE;
|
||||
_clients[slot].stall_ms = 0;
|
||||
_clients[slot].is_mirror = false;
|
||||
_clients[slot].is_term = false;
|
||||
_clients[slot].meta_sent = false;
|
||||
_clients[slot].tx_len = 0; // drop any stale pending frame from the previous occupant (tx_buf is reused)
|
||||
_clients[slot].tx_sent = 0;
|
||||
}
|
||||
|
||||
void WebSocketCompanionServer::acceptNewClients() {
|
||||
if (!_listening) return;
|
||||
while (_server.hasClient()) {
|
||||
WiFiClient incoming = _server.accept();
|
||||
if (!incoming) continue;
|
||||
int slot = -1;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
if (!_clients[i].in_use) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// No free slot: evict the oldest existing client so a fresh connect can
|
||||
// recover from stuck slots (half-closed sockets where connected() still
|
||||
// reports true). Without this, every new connect is accept()ed then
|
||||
// immediately stop()ed, producing SYN/SYN+ACK/ACK/FIN-ACK with zero data.
|
||||
if (slot < 0) {
|
||||
uint32_t now = millis();
|
||||
uint32_t oldest_age = 0;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
uint32_t age = now - _clients[i].accept_ms;
|
||||
if (slot < 0 || age > oldest_age) {
|
||||
slot = i;
|
||||
oldest_age = age;
|
||||
}
|
||||
}
|
||||
_clients[slot].client.stop();
|
||||
_clients[slot].in_use = false;
|
||||
}
|
||||
_clients[slot].client = incoming;
|
||||
_clients[slot].in_use = true;
|
||||
_clients[slot].accept_ms = millis();
|
||||
_clients[slot].handshake_done = false;
|
||||
_clients[slot].handshake_len = 0;
|
||||
_clients[slot].ws_state = WS_STATE_HEADER_0;
|
||||
_clients[slot].comp_state = COMP_STATE_IDLE;
|
||||
_clients[slot].stall_ms = 0;
|
||||
adoptClient(incoming);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,13 +645,22 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
|
||||
if (!writeAllBytes(*cl, (const uint8_t*)"\r\n\r\n", 4, TCP_WRITE_TIMEOUT_MS))
|
||||
return false;
|
||||
|
||||
// Route GET /mirror to the web-UI mirror channel (display out + pointer
|
||||
// in); every other WS upgrade (the companion app connects to "/") stays
|
||||
// a companion peer on the shared protocol.
|
||||
c->is_mirror = (strncmp(c->handshake_buf, "GET /mirror", 11) == 0);
|
||||
c->is_term = (strncmp(c->handshake_buf, "GET /term", 9) == 0); // web mesh terminal socket
|
||||
if (c->is_mirror || c->is_term) c->client.setNoDelay(true); // low latency: small frames, no Nagle coalescing
|
||||
c->meta_sent = false;
|
||||
c->handshake_done = true;
|
||||
c->ws_state = WS_STATE_HEADER_0;
|
||||
c->comp_state = COMP_STATE_IDLE;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
(void)writeAllBytes(*cl, (const uint8_t*)WS_HTTP_INFO_PAGE, strlen(WS_HTTP_INFO_PAGE), TCP_WRITE_TIMEOUT_MS);
|
||||
// Plain HTTP GET (no WS upgrade): serve the terminal page in terminal mode, else the mirror page.
|
||||
const char* page = g_web_mirror.terminalOn() ? WS_HTML_TERMINAL_PAGE : WS_HTTP_INFO_PAGE;
|
||||
(void)writeAllBytes(*cl, (const uint8_t*)page, strlen(page), TCP_WRITE_TIMEOUT_MS);
|
||||
c->client.stop();
|
||||
c->in_use = false;
|
||||
return false;
|
||||
@@ -244,11 +675,13 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
|
||||
}
|
||||
|
||||
void WebSocketCompanionServer::tickHandshake() {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
acceptNewClients();
|
||||
pruneDisconnected();
|
||||
}
|
||||
|
||||
size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index_out) {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
acceptNewClients();
|
||||
pruneDisconnected();
|
||||
|
||||
@@ -264,6 +697,9 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index
|
||||
doHandshake(idx);
|
||||
continue;
|
||||
}
|
||||
// Mirror clients carry framebuffer/pointer traffic, not companion frames —
|
||||
// serviceMirror() owns their socket. Never feed their bytes to the parser.
|
||||
if (c->is_mirror || c->is_term) continue;
|
||||
|
||||
WiFiClient* cl = &c->client;
|
||||
while (cl->available()) {
|
||||
@@ -355,6 +791,7 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index
|
||||
}
|
||||
|
||||
size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t src[], size_t len) {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS || len > MAX_FRAME_SIZE) return 0;
|
||||
if (!_clients[client_index].in_use || !_clients[client_index].client.connected()) return 0;
|
||||
|
||||
@@ -395,11 +832,12 @@ size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t s
|
||||
}
|
||||
|
||||
size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t len) {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
if (len == 0 || len > MAX_FRAME_SIZE) return 0;
|
||||
int connected = 0;
|
||||
int sent = 0;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done;
|
||||
bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term;
|
||||
if (ok) {
|
||||
connected++;
|
||||
if (writeToClient(i, src, len) == len) sent++;
|
||||
@@ -409,24 +847,318 @@ size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t l
|
||||
}
|
||||
|
||||
bool WebSocketCompanionServer::isClientConnected(int client_index) const {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS) return false;
|
||||
const WSClientState* c = &_clients[client_index];
|
||||
return c->in_use && c->client.connected() && c->handshake_done;
|
||||
return c->in_use && c->client.connected() && c->handshake_done && !c->is_mirror && !c->is_term;
|
||||
}
|
||||
|
||||
int WebSocketCompanionServer::connectedCount() const {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
int n = 0;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done)
|
||||
if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void WebSocketCompanionServer::disconnectClient(int client_index) {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
if (client_index >= 0 && client_index < WS_COMPANION_MAX_CLIENTS && _clients[client_index].in_use) {
|
||||
_clients[client_index].client.stop();
|
||||
_clients[client_index].in_use = false;
|
||||
_clients[client_index].stall_ms = 0;
|
||||
_clients[client_index].tx_len = 0; // discard any half-sent frame; tx_buf stays for slot reuse
|
||||
_clients[client_index].tx_sent = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Web UI mirror
|
||||
// ============================================================================
|
||||
int WebSocketCompanionServer::mirrorClientCount() const {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
int n = 0;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++)
|
||||
if (_clients[i].in_use && _clients[i].client.connected() &&
|
||||
_clients[i].handshake_done && _clients[i].is_mirror) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Queue one binary WS frame (FIN+binary, len up to 65535 via the 126 extended header)
|
||||
// into the client's tx_buf, then push what the socket accepts right now — all
|
||||
// NON-BLOCKING. The frame is drained in full over later drainClientTx() calls, so the
|
||||
// loop never spins on a write and a slow link never corrupts the stream or drops the
|
||||
// client. Returns 0 (rejected) if a previous frame is still draining (caller waits) or
|
||||
// the frame is too big to buffer. On success the whole frame is guaranteed atomic.
|
||||
size_t WebSocketCompanionServer::writeBinaryFrame(int client_index, const uint8_t src[], size_t len) {
|
||||
WsClientsLock _lk(_client_mtx);
|
||||
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS || len == 0 || len > 0xFFFF) return 0;
|
||||
WSClientState* c = &_clients[client_index];
|
||||
if (!c->in_use || !c->client.connected()) return 0;
|
||||
if (c->tx_len != 0) return 0; // a frame is still draining -> not ready
|
||||
|
||||
if (!c->tx_buf) {
|
||||
c->tx_buf = (uint8_t*)heap_caps_malloc(WS_MIRROR_CLIENT_TXBUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!c->tx_buf) c->tx_buf = (uint8_t*)malloc(WS_MIRROR_CLIENT_TXBUF);
|
||||
if (!c->tx_buf) return 0;
|
||||
}
|
||||
const size_t hdr_len = (len < 126) ? 2 : 4;
|
||||
if (hdr_len + len > WS_MIRROR_CLIENT_TXBUF) return 0; // never for our <=4 KB bands, but stay safe
|
||||
uint8_t* p = c->tx_buf;
|
||||
p[0] = 0x82; // FIN + binary
|
||||
if (len < 126) { p[1] = (uint8_t)len; }
|
||||
else { p[1] = 126; p[2] = (len >> 8) & 0xFF; p[3] = len & 0xFF; }
|
||||
memcpy(p + hdr_len, src, len);
|
||||
c->tx_len = (uint16_t)(hdr_len + len);
|
||||
c->tx_sent = 0;
|
||||
drainClientTx(client_index); // push whatever fits now (non-blocking)
|
||||
return len;
|
||||
}
|
||||
|
||||
// Push a mirror client's pending tx_buf bytes as far as the socket will take them right
|
||||
// now, without ever blocking. Frame boundaries are byte-exact (tx_sent offset), so a
|
||||
// partial push just resumes next call — the stream never desyncs. A client that can't
|
||||
// take a single byte for WS_WEDGED_DROP_MS is genuinely dead and gets reaped.
|
||||
void WebSocketCompanionServer::drainClientTx(int idx) {
|
||||
WSClientState* c = &_clients[idx];
|
||||
if (!c->in_use || c->tx_len == 0) return;
|
||||
if (!c->client.connected()) { c->tx_len = c->tx_sent = 0; return; }
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
// AT transport: write() is a BLOCKING worker round-trip (there is no lwIP buffer to
|
||||
// fill non-blockingly), and we run with _client_mtx held — draining a whole band here
|
||||
// starved the main loop (tickHandshake / pollRecvFrame / mesh) and froze the device.
|
||||
// Send ONE CIPSEND-sized chunk per call; tx_sent resumes next pass. serviceMirror's
|
||||
// all-idle check then also self-limits to one in-flight band. A failed chunk means the
|
||||
// worker already waited out its SEND-OK window (seconds) — retrying a sick browser tab
|
||||
// would stall the whole AT link every pass, so drop the client immediately (the viewer
|
||||
// page auto-reconnects).
|
||||
size_t chunk = c->tx_len - c->tx_sent;
|
||||
if (chunk > 1436) chunk = 1436; // == c6_at SOCK_PULL_MAX: exactly one AT round-trip
|
||||
size_t n = c->client.write(c->tx_buf + c->tx_sent, chunk);
|
||||
if (n == 0) { disconnectClient(idx); return; }
|
||||
c->tx_sent += (uint16_t)n;
|
||||
if (c->tx_sent >= c->tx_len) { c->tx_len = 0; c->tx_sent = 0; c->stall_ms = 0; }
|
||||
#else
|
||||
while (c->tx_sent < c->tx_len && socketWritableNow(c->client)) {
|
||||
size_t n = c->client.write(c->tx_buf + c->tx_sent, c->tx_len - c->tx_sent);
|
||||
if (n == 0) break;
|
||||
c->tx_sent += (uint16_t)n;
|
||||
}
|
||||
if (c->tx_sent >= c->tx_len) { c->tx_len = 0; c->tx_sent = 0; c->stall_ms = 0; return; }
|
||||
// Still bytes pending -> the socket is momentarily full. Just wait; only a peer that
|
||||
// stays unwritable for the full drop window is treated as wedged.
|
||||
if (c->stall_ms == 0) c->stall_ms = millis() | 1;
|
||||
else if (millis() - c->stall_ms >= WS_WEDGED_DROP_MS) disconnectClient(idx);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Read masked WebSocket binary frames from a mirror client and dispatch pointer
|
||||
// events. Reuses this client's ws_* parse state (pollRecvFrame skips mirror
|
||||
// clients, so it is exclusively ours). Payload: [0x01, x_lo,x_hi, y_lo,y_hi, pressed].
|
||||
void WebSocketCompanionServer::drainMirrorInput(int idx, WebMirror& m) {
|
||||
WSClientState* c = &_clients[idx];
|
||||
WiFiClient* cl = &c->client;
|
||||
int guard = 0;
|
||||
while (cl->available() && guard++ < 1024) {
|
||||
switch (c->ws_state) {
|
||||
case WS_STATE_HEADER_0: {
|
||||
uint8_t b = (uint8_t)cl->read();
|
||||
c->ws_opcode = b & 0x0F;
|
||||
c->ws_state = WS_STATE_HEADER_1;
|
||||
break;
|
||||
}
|
||||
case WS_STATE_HEADER_1: {
|
||||
uint8_t b = (uint8_t)cl->read();
|
||||
uint8_t l7 = b & 0x7F;
|
||||
c->ws_payload_read = 0;
|
||||
c->comp_rx_len = 0;
|
||||
if (l7 == 126 || l7 == 127) { c->ws_payload_len = l7; c->ws_state = WS_STATE_LEN_EXT; }
|
||||
else { c->ws_payload_len = l7; c->ws_state = WS_STATE_MASK; }
|
||||
break;
|
||||
}
|
||||
case WS_STATE_LEN_EXT: {
|
||||
if (c->ws_payload_len == 126) {
|
||||
if (cl->available() < 2) return;
|
||||
uint8_t hi = (uint8_t)cl->read(), lo = (uint8_t)cl->read();
|
||||
c->ws_payload_len = ((uint16_t)hi << 8) | lo;
|
||||
} else {
|
||||
if (cl->available() < 8) return;
|
||||
c->ws_payload_len = 0;
|
||||
for (int i = 0; i < 8; i++) c->ws_payload_len = (c->ws_payload_len << 8) | (uint8_t)cl->read();
|
||||
}
|
||||
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_MASK;
|
||||
break;
|
||||
}
|
||||
case WS_STATE_MASK: {
|
||||
if (cl->available() < 4) return;
|
||||
for (int i = 0; i < 4; i++) c->ws_mask[i] = (uint8_t)cl->read();
|
||||
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_PAYLOAD;
|
||||
break;
|
||||
}
|
||||
default: { // WS_STATE_PAYLOAD
|
||||
uint8_t b = (uint8_t)cl->read() ^ c->ws_mask[c->ws_payload_read % 4];
|
||||
if (c->comp_rx_len < MAX_FRAME_SIZE) c->comp_rx_buf[c->comp_rx_len++] = b;
|
||||
c->ws_payload_read++;
|
||||
if (c->ws_payload_read >= c->ws_payload_len) {
|
||||
if (c->ws_opcode == 0x02 && c->comp_rx_len >= 1) {
|
||||
const uint8_t ty = c->comp_rx_buf[0];
|
||||
if (ty == 0x01 && c->comp_rx_len >= 6) { // pointer: [0x01, x,y (LE16), pressed]
|
||||
int16_t x = (int16_t)(c->comp_rx_buf[1] | (c->comp_rx_buf[2] << 8));
|
||||
int16_t y = (int16_t)(c->comp_rx_buf[3] | (c->comp_rx_buf[4] << 8));
|
||||
m.pushPointer(x, y, c->comp_rx_buf[5]);
|
||||
} else if (ty == 0x02 && c->comp_rx_len >= 3) { // key: [0x02, codepoint LE16]
|
||||
m.pushKey((uint16_t)(c->comp_rx_buf[1] | (c->comp_rx_buf[2] << 8)));
|
||||
} else if (ty == 0x04 && c->comp_rx_len >= 2) { // orientation: [0x04, want_landscape]
|
||||
m.requestOrient(c->comp_rx_buf[1] ? 1 : 2); // 1=landscape, 2=portrait; UI thread reboots into it
|
||||
} else if (ty == 0x05) { // exit remote mode
|
||||
m.requestExit();
|
||||
}
|
||||
} else if (c->ws_opcode == 0x08) { // client close
|
||||
disconnectClient(idx);
|
||||
return;
|
||||
}
|
||||
c->ws_state = WS_STATE_HEADER_0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a terminal client's masked WS frames; each complete text/binary frame is one
|
||||
// command line -> m.pushTermCmd. Reuses this client's ws_* parse state (pollRecvFrame
|
||||
// skips is_term clients, so it is exclusively ours).
|
||||
void WebSocketCompanionServer::drainTermInput(int idx, WebMirror& m) {
|
||||
WSClientState* c = &_clients[idx];
|
||||
WiFiClient* cl = &c->client;
|
||||
int guard = 0;
|
||||
while (cl->available() && guard++ < 2048) {
|
||||
switch (c->ws_state) {
|
||||
case WS_STATE_HEADER_0: { uint8_t b = (uint8_t)cl->read(); c->ws_opcode = b & 0x0F; c->ws_state = WS_STATE_HEADER_1; break; }
|
||||
case WS_STATE_HEADER_1: {
|
||||
uint8_t b = (uint8_t)cl->read(); uint8_t l7 = b & 0x7F; c->ws_payload_read = 0; c->comp_rx_len = 0;
|
||||
if (l7 == 126 || l7 == 127) { c->ws_payload_len = l7; c->ws_state = WS_STATE_LEN_EXT; }
|
||||
else { c->ws_payload_len = l7; c->ws_state = WS_STATE_MASK; }
|
||||
break;
|
||||
}
|
||||
case WS_STATE_LEN_EXT: {
|
||||
if (c->ws_payload_len == 126) { if (cl->available() < 2) return; uint8_t hi = (uint8_t)cl->read(), lo = (uint8_t)cl->read(); c->ws_payload_len = ((uint16_t)hi << 8) | lo; }
|
||||
else { if (cl->available() < 8) return; c->ws_payload_len = 0; for (int i = 0; i < 8; i++) c->ws_payload_len = (c->ws_payload_len << 8) | (uint8_t)cl->read(); }
|
||||
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_MASK; break;
|
||||
}
|
||||
case WS_STATE_MASK: {
|
||||
if (cl->available() < 4) return;
|
||||
for (int i = 0; i < 4; i++) c->ws_mask[i] = (uint8_t)cl->read();
|
||||
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_PAYLOAD; break;
|
||||
}
|
||||
default: { // WS_STATE_PAYLOAD
|
||||
uint8_t b = (uint8_t)cl->read() ^ c->ws_mask[c->ws_payload_read % 4];
|
||||
if (c->comp_rx_len < MAX_FRAME_SIZE) c->comp_rx_buf[c->comp_rx_len++] = b;
|
||||
c->ws_payload_read++;
|
||||
if (c->ws_payload_read >= c->ws_payload_len) {
|
||||
if ((c->ws_opcode == 0x01 || c->ws_opcode == 0x02) && c->comp_rx_len > 0) { // text/binary = a command line
|
||||
uint16_t n = c->comp_rx_len;
|
||||
while (n > 0 && (c->comp_rx_buf[n - 1] == '\r' || c->comp_rx_buf[n - 1] == '\n')) n--;
|
||||
if (n > 0) m.pushTermCmd((const char*)c->comp_rx_buf, n);
|
||||
} else if (c->ws_opcode == 0x08) { disconnectClient(idx); return; }
|
||||
c->ws_state = WS_STATE_HEADER_0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Web mesh terminal: shuttle text both ways for /term clients (independent of the
|
||||
// framebuffer mirror; a client is never both). Called from serviceMirror on the stream
|
||||
// task with _client_mtx already held. writeBinaryFrame's per-client tx_buf keeps it
|
||||
// non-blocking; the browser TextDecodes the reply bytes.
|
||||
void WebSocketCompanionServer::serviceTerminalClients(WebMirror& m) {
|
||||
int tc = 0;
|
||||
bool all_idle = true;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
WSClientState* c = &_clients[i];
|
||||
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_term) continue;
|
||||
tc++;
|
||||
drainClientTx(i);
|
||||
if (!c->in_use) continue; // reaped mid-drain
|
||||
drainTermInput(i, m);
|
||||
if (c->tx_len != 0) all_idle = false;
|
||||
}
|
||||
m.noteTermClients(tc);
|
||||
if (tc == 0 || !all_idle || !_mirror_txbuf) return; // wait until every client drained its previous chunk
|
||||
// Each frame is [type][payload]: type 0x01 = a complete JSON data message (Contacts/Chats),
|
||||
// 0x00 = a terminal text chunk. Prefer data (discrete + responsive) over the text stream.
|
||||
_mirror_txbuf[0] = 0x01;
|
||||
size_t n = m.popTermData(_mirror_txbuf + 1, WS_MIRROR_CLIENT_TXBUF - 8);
|
||||
if (n == 0) { _mirror_txbuf[0] = 0x00; n = m.popTermReply(_mirror_txbuf + 1, 2048); }
|
||||
if (n == 0) return;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
WSClientState* c = &_clients[i];
|
||||
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_term) continue;
|
||||
writeBinaryFrame(i, _mirror_txbuf, n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Called every loop: refresh the client count for the producer gate, send each
|
||||
// client its one-time screen-size meta, drain remote pointer input, and broadcast
|
||||
// queued framebuffer bands. Strictly non-blocking (socketWritableNow-gated) so it
|
||||
// never stalls the mesh loop (the beta_32 Wi-Fi-freeze discipline).
|
||||
void WebSocketCompanionServer::serviceMirror(WebMirror& m) {
|
||||
WsClientsLock _lk(_client_mtx); // held for the whole (quick, non-blocking) pass
|
||||
if (!_mirror_txbuf) { // shared scratch — also used by serviceTerminalClients for data frames
|
||||
_mirror_txbuf = (uint8_t*)heap_caps_malloc(WS_MIRROR_TXBUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!_mirror_txbuf) _mirror_txbuf = (uint8_t*)malloc(WS_MIRROR_TXBUF);
|
||||
}
|
||||
serviceTerminalClients(m); // web mesh terminal + Contacts/Chats data (independent of the framebuffer)
|
||||
const int mc = mirrorClientCount();
|
||||
m.noteClients(mc);
|
||||
if (mc == 0 || !_mirror_txbuf) return;
|
||||
|
||||
// Per-client: first push any bytes still pending from the previous frame (non-blocking),
|
||||
// then queue the one-time size meta + keyboard-focus changes (only while idle, so on-wire
|
||||
// frame order is preserved), then drain remote input.
|
||||
bool kf = false;
|
||||
const bool kb_changed = m.kbTake(&kf);
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
WSClientState* c = &_clients[i];
|
||||
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
|
||||
drainClientTx(i);
|
||||
if (!c->in_use) continue; // a wedged client may have just been reaped
|
||||
if (c->tx_len == 0 && !c->meta_sent) {
|
||||
uint8_t meta[6] = { 0x02,
|
||||
(uint8_t)(m.screenW() & 0xFF), (uint8_t)(m.screenW() >> 8),
|
||||
(uint8_t)(m.screenH() & 0xFF), (uint8_t)(m.screenH() >> 8),
|
||||
(uint8_t)(m.remote() ? 1 : 0) }; // byte5 bit0 = remote mode -> browser shows the Rotate button
|
||||
if (writeBinaryFrame(i, meta, 6) == 6) { c->meta_sent = true; m.requestFullRepaint(); }
|
||||
}
|
||||
if (kb_changed && c->tx_len == 0) { uint8_t km[2] = { 0x03, (uint8_t)(kf ? 1 : 0) }; writeBinaryFrame(i, km, 2); }
|
||||
drainMirrorInput(i, m);
|
||||
}
|
||||
|
||||
// Broadcast queued display bands: pop the next band only once EVERY mirror client has
|
||||
// fully drained its previous frame (keeps all clients byte-synced + backpressures to the
|
||||
// slowest peer). writeBinaryFrame buffers + pushes non-blocking, so a slow link just
|
||||
// drains across later calls instead of blocking the loop or dropping the socket.
|
||||
size_t budget = WS_MIRROR_TX_BUDGET;
|
||||
while (budget > 0) {
|
||||
bool any = false, all_idle = true;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
WSClientState* c = &_clients[i];
|
||||
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
|
||||
any = true;
|
||||
if (c->tx_len != 0) { all_idle = false; break; }
|
||||
}
|
||||
if (!any || !all_idle) break;
|
||||
size_t n = m.popFrame(_mirror_txbuf, WS_MIRROR_TXBUF);
|
||||
if (n == 0) break;
|
||||
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
|
||||
WSClientState* c = &_clients[i];
|
||||
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
|
||||
writeBinaryFrame(i, _mirror_txbuf, n);
|
||||
}
|
||||
budget = (n < budget) ? (budget - n) : 0;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <WiFi.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
// Same type rebind as TCPCompanionServer.h (every TU must see one consistent class layout).
|
||||
// NB: begin() is never called on the P4 — ESP-AT has a single listening port, so a first-byte
|
||||
// router in MultiTransportCompanionInterface accepts everything on the companion port and
|
||||
// hands HTTP/WS connections here via adoptClient() (_port stays 0; pause/resumeListen no-op).
|
||||
#include <C6WifiShim.h>
|
||||
#include <C6Socket.h>
|
||||
#define WiFiClient C6Client
|
||||
#define WiFiServer C6Server
|
||||
#endif
|
||||
|
||||
class WebMirror; // web-UI mirror bridge (WebMirror.h); included in the .cpp
|
||||
|
||||
#ifndef WS_COMPANION_MAX_CLIENTS
|
||||
#define WS_COMPANION_MAX_CLIENTS 2
|
||||
@@ -36,6 +51,17 @@ struct WSClientState {
|
||||
uint16_t comp_rx_len;
|
||||
uint8_t comp_rx_buf[MAX_FRAME_SIZE];
|
||||
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
|
||||
|
||||
bool is_mirror; // this client is a web-UI mirror (GET /mirror), not a companion peer
|
||||
bool is_term; // this client is a web mesh terminal (GET /term)
|
||||
bool meta_sent; // mirror: the one-time screen-size meta frame has been sent
|
||||
|
||||
// Mirror TX buffer: one WS frame (header + payload) queued for this client, drained
|
||||
// NON-BLOCKING across loop iterations. The loop never spins on a socket write and a
|
||||
// transient stall never drops the client — it just drains at the link's pace.
|
||||
uint8_t* tx_buf; // lazily-allocated PSRAM frame buffer (null until first mirror use)
|
||||
uint16_t tx_len; // bytes queued in tx_buf (0 = idle, ready for the next frame)
|
||||
uint16_t tx_sent; // bytes already pushed to the socket
|
||||
};
|
||||
|
||||
// WebSocket server for companion protocol. Same logical protocol as TCP; transport is RFC 6455 (plain WS).
|
||||
@@ -53,14 +79,28 @@ public:
|
||||
size_t pollRecvFrame(uint8_t dest[], int* client_index_out);
|
||||
/** Accept new clients and prune disconnects; call from main loop for timely handshakes. */
|
||||
void tickHandshake();
|
||||
/** Slot-insert an already-accepted connection (evicts the oldest handshaking client if
|
||||
* full). Lets an external router (T-Display P4: one shared AT listener) hand us
|
||||
* HTTP/WS clients that were accepted on the companion port. */
|
||||
void adoptClient(WiFiClient& incoming);
|
||||
|
||||
size_t writeToClient(int client_index, const uint8_t src[], size_t len);
|
||||
size_t writeToAllClients(const uint8_t src[], size_t len);
|
||||
|
||||
bool isClientConnected(int client_index) const;
|
||||
int connectedCount() const;
|
||||
int connectedCount() const; // companion clients only (mirror excluded)
|
||||
void disconnectClient(int client_index);
|
||||
|
||||
// ---- Web UI mirror (see WebMirror) ----
|
||||
int mirrorClientCount() const; // handshaken GET /mirror clients
|
||||
/** Drain remote pointer input + push queued framebuffer frames to mirror
|
||||
* clients. Non-blocking (socketWritableNow-gated); call each loop. */
|
||||
void serviceMirror(WebMirror& m);
|
||||
/** WebSocket binary frame with len up to 65535 (no MAX_FRAME_SIZE cap) — used
|
||||
* for mirror display bands. Same freeze-safe write + wedged reaper as the
|
||||
* companion path. */
|
||||
size_t writeBinaryFrame(int client_index, const uint8_t src[], size_t len);
|
||||
|
||||
private:
|
||||
WiFiServer _server;
|
||||
mutable WSClientState _clients[WS_COMPANION_MAX_CLIENTS];
|
||||
@@ -71,4 +111,16 @@ private:
|
||||
void acceptNewClients();
|
||||
void pruneDisconnected();
|
||||
bool doHandshake(int idx);
|
||||
|
||||
uint8_t* _mirror_txbuf; // lazily-allocated PSRAM buffer for popped mirror frames
|
||||
void drainMirrorInput(int idx, WebMirror& m);
|
||||
void drainClientTx(int idx); // push a mirror client's pending tx_buf bytes, non-blocking
|
||||
void serviceTerminalClients(WebMirror& m); // web mesh terminal: reply text out + command text in
|
||||
void drainTermInput(int idx, WebMirror& m); // parse a term client's WS frames -> m.pushTermCmd
|
||||
|
||||
// The _clients array is now touched by TWO cores: the main loop (accept/handshake +
|
||||
// companion RX/TX, core 1) and the dedicated mirror stream task (serviceMirror, core 0).
|
||||
// A recursive mutex serialises every _clients access; held only for the quick,
|
||||
// non-blocking work, so cross-core contention is negligible.
|
||||
SemaphoreHandle_t _client_mtx;
|
||||
};
|
||||
@@ -5,6 +5,11 @@
|
||||
#include "SdNvsPrefs.h" // NVS, or SD /meshcomod fallback when NVS is unusable (Launcher)
|
||||
#include <WiFi.h>
|
||||
#include <cstring>
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
// T-Display P4: rebind WiFi.* to the c6_at AT-over-SDIO facade — Arduino's real WiFi.mode()/begin()
|
||||
// re-init esp_hosted (the C6 runs ESP-AT, not an esp-hosted slave) and panic the P4. P4-only header.
|
||||
#include <C6WifiShim.h>
|
||||
#endif
|
||||
|
||||
static const char *WIFI_CONFIG_NAMESPACE = "meshcomod";
|
||||
static const char *WIFI_CONFIG_SSID_KEY = "wifi_ssid";
|
||||
|
||||