Compare commits

..
141 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 1863ebdd2c touch: beta_53 release notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:39:18 +02:00
Kaj SchittecatandClaude Opus 4.8 4942fcf5aa touch: configurable per-chat history limit, default 250, warns when turned off
Second half of the "large message history slows the interface down" report. The
first half (4058f8d) removed the repeated ring scan; this bounds how much history
a single chat can accumulate in the first place, which is what was asked for in
the channel: a configurable limit, with a lower default, and a warning.

Until now the message store was one shared ring with no per-chat bound, so a busy
public channel could occupy essentially all of it. That both starved every other
chat of its history and kept the ring permanently full, which is the condition the
slow paths were suffering under. The reporter had 3800 messages in one channel.

  - New pref hist_per_chat (touch-cfg v42), default 250 messages per chat. Chosen
    to be deep enough to scroll back through a conversation while staying far below
    the 5000-record SD ring, so no single chat can dominate it.
  - Enforced on append: enforceHistoryCap() blanks that chat's oldest records until
    it is back within the limit. It walks from the oldest end, so for the chat that
    is actually over the cap (the one filling the ring) the records it wants are
    found immediately rather than after a full scan.
  - Settings -> Chats: "Keep per chat (messages)" — 100 / 250 / 500 / 1000 / 2000 /
    No limit, with a line explaining that older messages are dropped past the limit.
  - "No limit" is a deliberate choice, not a silent one: it raises a confirmation
    saying a busy channel can then fill the whole store and make things slower. The
    dropdown reverts first and only commits from the confirm handler, so dismissing
    the dialog leaves the previous limit in place.

The thread-history cache from 4058f8d becomes a per-thread COUNT rather than a
bool, since the cap needs to know how many records a chat holds; "has history" is
just count > 0, so the O(1) inbox behaviour is unchanged.

Existing installs pick up the 250 default and will trim on the next message in an
over-long chat. Anyone who wants the old unbounded behaviour can select No limit.

Builds clean on V4 + T-Deck; flashed to the T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:27:32 +02:00
Kaj SchittecatandClaude Opus 4.8 b846dc62fc touch: chat-list preview stuck on an old message (T-Display P4)
Reported on the P4: the preview line under a channel in the chats list showed an
older message instead of the latest, and never moved on.

getThreadLastMessage() chose the message with the highest m.ts, and m.ts is the
ESP32 SYSTEM clock. That clock is not monotonic across reboots — it restarts
from ESP32RTCClock::begin()'s power-on seed on every boot and then only climbs
with uptime until a real time source arrives. So a message received hours into
an earlier boot carries a LARGER timestamp than one received a minute into this
boot, wins the max(ts) comparison forever, and pins the preview to itself.

It showed up on the P4 because that board has an RTC chip, so the core wrote
time to the chip and never to the system clock, which sat on the seed
indefinitely (fixed separately in fe87ff0 by mirroring the mesh RTC into the
system clock). Any board can hit a milder version of this after a reboot with no
time source, so the selection itself is what needed fixing.

Now the newest message is taken by RING ORDER — walk back from the head and take
the first match. Ring order is the real arrival order and cannot be skewed by a
wrong clock. It is also faster: it exits on the first hit instead of scanning
every slot in the ring, which matters on the same busy-channel devices as the
inbox-scan fix in 4058f8d.

Builds clean on V4 + T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:22:16 +02:00
Kaj SchittecatandClaude Opus 4.8 4058f8dcd9 touch: stop the inbox rescanning the whole message ring per thread
Field report: a public channel that had accumulated 3800 messages made the
interface "slow down drastically", and clearing it restored normal speed. The
reporter also noticed it was still noticeable at only ~300 messages and asked
whether something loops over every message. It does.

threadHasMessageHistory() answered "does this thread have any stored message?"
by walking the ring via getThreadMessageIndexes(). That scan stops early once it
finds a match, so a thread WITH recent messages is cheap — but a thread with
none walks every record in the ring doing a strncmp per record, and "has no
history" is precisely what the callers are testing for. getCombinedInboxCount()
and getUnreadTotal() both call it once PER THREAD, so a full ring turned every
inbox refresh into threads x messages string compares. That is the reported
slowdown, and it explains why it is felt well below a full ring.

Now cached per thread:
  - an append sets the owning thread's flag directly, so the common path never
    scans at all;
  - anything that REMOVES messages (ring eviction, clearThreadHistory,
    removeThread, any history load) marks the cache dirty, and the next reader
    rebuilds every thread's flag in ONE ring pass instead of one pass per
    thread, stopping as soon as all live threads are resolved.

clearThreadHistory clears just its own flag (no other thread is touched), so the
common "clear one chat" action stays O(ring) once rather than forcing a rebuild.

Behaviour is unchanged; this only removes repeated work. The separate request
from the same thread — a configurable per-channel history limit with a lower
default and a warning — is NOT in this commit.

Builds clean on V4 + T-Deck; flashed to the T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:13:05 +02:00
Kaj SchittecatandClaude Opus 4.8 3705a66288 touch: P4 — opt-in flush tracer for whole-screen colour-flash reports (#167)
Diagnostic scaffolding kept from chasing the "screen flashes teal on every
message" report, behind -DTDP4_FLUSH_TRACE so it is compiled out of normal
builds (verified: no FLUSHTRACE string in the shipped binary).

It logs any large, mostly-single-colour flush band with its RGB565 value. That
answers the one question that was otherwise unanswerable from the outside: is
the flash something LVGL painted, or something below it? A UI-painted flash
appears as a burst of bands carrying that colour; if the screen visibly flashes
and nothing logs, the cause is in the panel/DSI layer and hunting through UI
widgets is wasted effort.

Two notes recorded in the comment because both cost time here. One LVGL band on
this board is 284x24 = 6816 px, so a size gate has to sit well below that — an
earlier 8000 px threshold silently disabled the whole tracer and looked like
"nothing is drawing". And the per-band uniformity scan measurably slows the
flush path, so it can mask a timing-sensitive bug: any fix must be confirmed on
a build with the tracer OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:07:38 +02:00
Kaj SchittecatandClaude Opus 4.8 7aaff80742 touch: P4 — move the flush upscale scratch out of PSRAM (#167 teal flash on RX)
Both P4 SKUs render at half resolution and nearest-neighbour-upscale each LVGL band
into a scratch buffer before handing it to the panel. That scratch was allocated with
MALLOC_CAP_SPIRAM, which put it on the worst possible bus.

These are DPI/DSI panels with num_fbs = 1: the ~1.4 MB framebuffer can only live in
PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. Every draw_bitmap is a
copy INTO that framebuffer, so with the scratch also in PSRAM a single flush put three
streams on one bus at once — the scratch read, the framebuffer write, and the DSI's own
read. Anything else wanting PSRAM in that window can starve the DSI read, and a DPI
underrun presents as a whole-screen colour flash for one frame.

That is the reported "screen flashes teal on every RX" (#167 flicker): it happens on
ANY screen and was there from the start of the port, because it is a display-bus
problem and has nothing to do with what is being drawn. The RX correlation is the
priority-10 "lora_rx" drain task, which wakes on every received packet and is ON by
default (rx_queue defaults to 1).

Allocating the scratch in internal DMA RAM removes the scratch read from the PSRAM bus
entirely. The cost is bounded and small: one LVGL band is LV_DRAW_BUF_LINES = 24 rows,
so AMOLED 284x24 -> 568*48*2 = ~53 KB and LCD 270x24 -> 540*48*2 = ~51 KB, against a
348 KiB internal region. PSRAM stays as the fallback so a fragmented heap degrades to
the previous behaviour rather than dropping to the unscaled draw path.

Not done here: lowering the drain task's priority. It is created in the vendored core
(RadioLibWrappers.cpp), which is not tracked in this repo, so a change there cannot
ship from wadamesh — it needs a monorepo core change plus a core-* tag bump. It is also
the weaker lever: the core pins that task to core 0 while LVGL and loop run on core 1,
so it is not preempting the UI thread, and the contention this fixes is the memory bus.

Builds clean and boot-verified on the device (T-Display P4, MAC ...e1:c2:a7); no
allocation failures, only rst:0x1 (POWERON).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:35:09 +02:00
Kaj SchittecatandClaude Opus 4.8 471a522d8f touch: put Airtime + Snake on the Spectrum app-page pattern
Both were built from the same template and carried the same three defects, none of
which were really about their own code: they hand-rolled the page chrome that every
other app page gets from the shared machinery.

New src/ui-touch/AppPage.h exports that machinery (appPageCreateRoot / Begin / End /
DeleteRootAsync) so a self-contained module can build the same page the in-file tool
pages do, and both modules now use it.

  1. GEOMETRY. Both positioned their overlay against a local `kTopBar = 22`.
     STATUSBAR_H is a RUNTIME value: SC(22) once the UI scale is above 100%, and
     SB_TOP_PAD + SB_ROW*2 on the T-Display P4's two-row bar. On those boards the
     page sat too high and its own title + close button landed UNDERNEATH the real
     status bar, which is what "the airtime app has issues" looked like. Geometry now
     comes from the live bar height, so a page cannot disagree with the bar.

  2. NAVIGATION. Neither installed s_apppage_title / s_apppage_close, so each needed
     its own case in statusBarTapCb and its own clause in the bar-foreground gate, and
     neither reached statusBarReaderBackCb — the PRESSED fallback that exists because
     the cap-touch swipe detector can drop the lone CLICKED. On a touch-only board
     that left no reliable exit at all. They now install the standard hook, get the
     tall "< Airtime" / "< Snake" bar, and both special cases in UITask are deleted.

  3. TEARDOWN. closeCb ran `lv_obj_del(root_)` and `delete this` synchronously from a
     child button's own event callback, freeing the object LVGL was still dispatching
     to. Teardown is now async throughout: lv_obj_del_async for the root, lv_async_call
     for the instance, one single dismiss() path. Snake's canvas buffer moves to a
     destructor so it is freed after the queued root delete released the canvas.

Also fixes what the rework exposed: applySwipeGesture had swallow-guards for Snake,
sliders, chats, popups, the control centre and settings sheets, but none for app pages
— so a horizontal swipe inside Airtime reached the tab switcher and jumped to the Map
(reported on device). An opaque CLICKABLE root only blocks CLICKS; that detector reads
the touch hardware directly and never consults LVGL hit-testing. One guard now covers
every full-screen page, Spectrum and the rest included, which had the same bug.

Bar titles are copied into module-owned storage rather than holding TR()'s pointer:
TR returns the static table cell for a plain key but a recycled 4-slot ring for an
icon-prefixed one, and s_apppage_title keeps the pointer for the page's lifetime.

Builds clean on V4, T-Deck and the P4 IDF target; flashed + boot-verified on the P4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:08:18 +02:00
Kaj SchittecatandClaude Opus 4.8 ecb506694d touch: P4 antenna — FIX INVERTED POLARITY, internal is HIGH not LOW
b066c37 guessed the antenna mapping from field symptoms and guessed it BACKWARDS,
so "Internal" was selecting the external socket and the boot park pinned the
external connector — the exact hazard the change was meant to remove. Corrected
against LilyGo's own sources, which document this pin explicitly:

  lilygo_device_driver  src/device/t_display_p4/t_display_p4_config.h
    inline constexpr auto kSky13453Vctl = cpp_bus_driver::Xl95x5::Pin::kIo1;
  lilygobox-espidf      main/hal/device/t_display_p4/t_display_p4_device.cpp
    const uint8_t antenna_level = config.antenna == AntennaType::kExternal ? 0 : 1;
    GpioWrite(gpio::xl9535::kSky13453Vctl, antenna_level);
    ... logged as   kExternal ? "RF2" : "RF1"

So HIGH = RF1 = on-board antenna, LOW = RF2 = external socket. INTERNAL_LEVEL
flips false -> true. The one-constant escape hatch written into b066c37 is what
made this a one-line correction.

Two things this settles beyond the polarity. The vendor's AntennaType enum is
literally kInternal/kExternal, so the line IS an antenna select and not a TX/RX
path — the architectural half of the earlier reasoning was right. And LilyGo
preloads VCTL = 1 in their own XL9535 init as their stated safe state, so
booting on the on-board antenna is the vendor's choice too, not just ours.

It also explains the field report properly: the legacy per-TX toggle idled LOW
to receive (= EXTERNAL, where the reporter's antenna is fitted, hence a healthy
+12 dB inbound) and drove HIGH to transmit (= the on-board antenna, hence ~22 dB
down at the repeater). It transmits internal and listens external, which is the
opposite of what a3550d2 claimed. So auto is NOT a PA hazard after all — its
transmit lands on the permanently attached on-board antenna — it is just
guaranteed lopsided. Its confirmation text now says that instead of warning
about damage, and only External keeps the damage warning. UI strings say
"external antenna socket" rather than "MMCX", since the board has two MMCX
connectors and only one of them is the LoRa path.

Verified on hardware (T-Display P4, MAC ...e1:c2:a7): clean boot, only
rst:0x1 (POWERON), antenna park runs, SNTP -> rtc 1785413952.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:19:48 +02:00
Kaj SchittecatandClaude Opus 4.8 a3550d2190 touch: P4 antenna — gate the legacy auto mode behind the same warning
Auto was still one tap away with no prompt, which contradicts the safety model
in b066c37: the legacy per-TX toggle writes the external level on EVERY single
transmit, so it keys the PA into the MMCX socket more often than "External"
does, not less. Both non-internal modes now require an explicit confirmation,
with wording that says what each one actually does to the transmit path.

Internal stays promptless (it is the safe state), and the revert-then-reapply
shape is unchanged, so a dismissed dialog still leaves the UI and the hardware
where they were. The pending mode moves into a static because showConfirm takes
a bare callback with no user data.

Verified on the device (T-Display P4, MAC ...e1:c2:a7): clean boot, only
rst:0x1 (POWERON) in the log, "[XL9535] power-on sequence done" so the antenna
park runs, and "[C6-AT] SNTP -> rtc 1785412930" decoding to 2026-07-30
12:02:10 UTC, which also confirms the ClockFloorRTC system-clock mirror from
fe87ff0 firing on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:02:52 +02:00
Kaj SchittecatandClaude Opus 4.8 b066c379c0 touch: P4 antenna — internal on every boot, confirm before going external
Reworks the P4 antenna setting so the failure mode it protects against is
impossible by default. Transmitting into an external connector with nothing
fitted is what destroys a PA, so the state you get for free after any power
cycle, crash or OTA has to be the on-board antenna.

  - The on-board antenna is forced at EVERY boot, in two places: the park in
    Xl9535::powerOnSequence() (before the radio object even exists) and a
    re-assert in UITask::begin() in case anything touched IO1 in between.
  - The choice is now session-only and is never written to flash. That is the
    whole point: if "external" could survive a reboot, the safety property is
    gone. touchPrefsGet/SetP4Antenna are removed; the p4_antenna byte stays in
    the struct as reserved so TOUCH_CFG_VER does not have to rewind.
  - Selecting External raises a confirmation naming the MMCX socket and the
    risk. showConfirm has no cancel hook, so the dropdown reverts immediately
    and is only re-selected from the confirm handler — a dismissed dialog
    therefore leaves both the UI and the hardware on internal.
  - The dropdown reads the live xl9535.antennaMode() rather than a stored pref,
    so it can never claim an antenna the hardware is not actually on.

Also renames the modes from the opaque Auto/Pinned A/Pinned B to Internal /
External / Auto (legacy). The reasoning for treating IO1 as an antenna select
rather than a TX/RX path switch is written up on Xl9535.h: a per-transmit
switch must settle within microseconds of the PA ramping, and an I2C expander
write on a bus shared with touch, the RTC and the fuel gauge cannot do that,
while the SX1262 has DIO2 for exactly that job. Which LEVEL is which antenna is
still unconfirmed against LilyGo's schematic, but the SAFE level is known from
the field: the legacy mode sits LOW to receive and every P4 receives fine at
+12 dB, so LOW demonstrably radiates into something connected, while HIGH was
only ever used for transmit and is the suspect. One named constant
(INTERNAL_LEVEL) flips the mapping if the schematic ever says otherwise.

Builds clean on the P4 IDF target plus the V4 and T-Deck S3 envs (the shared
TouchPrefsStore change); compile-verified only, the P4 is off the USB bus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:51:15 +02:00
Kaj SchittecatandClaude Opus 4.8 fe87ff02c5 touch: fix the P4 clock — mirror the mesh RTC into the ESP32 system clock
The UI reads the ESP32 *system* clock for every displayed timestamp (status
bar, chat bubbles) while protocol timestamps come from ClockFloorRTC. On a
board with no RTC chip those are the same clock, so they cannot disagree. The
T-Display P4 (and the ThinkNode M9) carry a PCF8563, and
AutoDiscoverRTCClock::setCurrentTime is an if/else chain: with a chip present
it writes ONLY the chip and never reaches the ESP32RTCClock fallback. So the
system clock kept ESP32RTCClock::begin()'s power-on seed forever.

That seed is 1715770351 == exactly ClockFloorRTC::MIN_VALID_EPOCH, which
explains both halves of the P4 field report:
  - the UI showed 15 May 2024 while sent messages carried the correct time
  - "Sync clock from system" then poisoned the good clock: it fed that seed
    back in as a real set and cleared the MIN check by being precisely equal
    to it (the old `t < 100000` guard only caught a clock counting up from 0)

Fixed in the one funnel every time source already goes through:
  - ClockFloorRTC::setCurrentTime mirrors every ACCEPTED value into the system
    clock, so NTP-via-C6, GPS and the phone app all correct the display too.
    Validation runs first, so 1902/2043-class garbage never reaches it either.
  - new seedSystemClock(), called at boot right after seedFloor(): a
    battery-backed chip already knows the time, so the UI reads correctly with
    no network and no GPS fix. After seedFloor, so a dead chip yields the
    persisted floor rather than the seed.
  - the sync button rejects an untouched seed and now reports honestly instead
    of always claiming "Clock synced".

No-op on the seven chipless boards: the fallback already wrote the same value
through settimeofday. GPS re-syncs are rate-capped to ~30 min, so the mirror
adds no measurable work. Builds clean on all 8 S3 envs + the P4 IDF target;
compile-verified only, the P4 is still off the USB bus pending a power cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:39:08 +02:00
Kaj SchittecatandClaude Opus 4.8 d6c0321472 touch: P4 LoRa antenna control, to settle the outbound-signal deficit
Field reports say a T-Display P4 transmits far weaker than it receives: one user's
repeater hears his P4 at -10 dB while the P4 hears the repeater at +12, and his
T-Deck is +12/+12. Decisively, the SAME P4 under other P4 firmware gives symmetric
numbers, so it is not his hardware, and that firmware also offers an
internal/external antenna choice which we do not.

Reading the variant, our whole RF path rests on one never-validated assumption.
XL9535 IO1 is labelled "SKY13453 VCTL (LoRa TX/RX path)" with the comment "polarity
TBD on-device", and we flip it around every transmit. But this expander has NO other
antenna-select line -- all 16 IOs are power rails, screen, touch, ethernet, C6, SD
and the SX1262 reset/DIO1. If IO1 is actually the internal/external ANTENNA select,
then toggling it per transmit means we send on one antenna and listen on the other,
which is exactly a large outbound-only deficit on a unit with an external antenna
fitted. A second user reporting symmetric +11.8/+12.0 does not refute it: that was a
two-floor link where SNR saturates in both directions and would mask the loss.

I will not gamble on that assumption: if IO1 really is a TX/RX switch, pinning it
would break transmit or receive outright. So this adds a Radio setting (P4 only):
  Auto (switch per transmit)  - the existing behaviour, and the DEFAULT, so an
                                untouched device is bit-for-bit unchanged
  Pinned A / Pinned B         - hold the line in one state for both TX and RX
Whichever pinned option gives symmetric Trace SNR both ways is the correct antenna,
and that result also tells us what IO1 actually is. The labels are deliberately A/B
rather than Internal/External because we do not yet know which is which.

Applied live on change and re-applied at boot, next to the V4 FEM-LNA precedent.
Stored as touch-cfg v41, a trailing field defaulting to 0, so existing installs
migrate with no behaviour change.

Builds clean on all 8 S3 envs plus the T-Display P4. Cannot be verified here: no P4
is on USB, and the answer needs an on-air SNR comparison anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:19:13 +02:00
Kaj SchittecatandClaude Opus 4.8 162ec43bfa touch: cut the advert-driven SPIFFS churn behind the multi-second mesh stalls
Two reporters on card-less Heltec V4s saw the whole UI freeze for 5-60 s at a time
with BLE, WiFi and GPS all off and no user activity, and BOTH of a reporter's two
devices froze at the same moment. About -> "Loop stalls" named the culprit
unambiguously: tag `mesh`, with peaks of 11451 / 12175 / 14592 / 18063 / 19113 ms.
That is inside the_mesh.loop(), i.e. the advert receive path, and the cost is SPIFFS
garbage collection on a partition kept permanently churned by writes that happen per
advert. The simultaneity across two units is the giveaway: the trigger is a flooded
advert both of them receive, after which each runs the same deterministic write.

This is the residue of beta_40's "card-less contacts-save stall fix", which capped how
OFTEN the contacts rewrite happens without reducing its cost, and left the
highest-frequency writer untouched.

Two frequency reductions, both keeping writes ATOMIC:

1. putBlobByKey now persists an advert blob once per key per boot. The core calls it on
   EVERY advert (outside the auto-add block, so for already-known contacts too) and each
   call is a full create+truncate+write. With ~200 known nodes re-advertising that is a
   flash write every few seconds, forever, purely to keep the raw advert packet fresh for
   the Share-contact feature. The content is effectively static, and getBlobByKey still
   reads the file we already wrote, so there is no cache to keep coherent. Steady state
   on the packet path becomes zero flash I/O. Costs 2 KB of DRAM.

2. A save triggered by an add/remove now has its own 30 s floor. It previously bypassed
   the 5-minute refresh window entirely, so on a growing mesh every newly-heard node
   forced its own full 30 KB rewrite (200 contacts x 152 B). A burst of new nodes now
   coalesces into one rewrite. Kept short on purpose: a new contact still needs to reach
   flash promptly, and losing one to an unclean power cut only costs a rediscovery on that
   node's next advert.

Deliberately NOT done: making the contacts save incremental (seek + one 152-byte record).
It is tempting since the file is a flat fixed-stride array, and it would cut churn far
harder, but it abandons the tmp+rename atomicity that beta_46 added to fix real contact
data loss. Both reporting devices show "Last reset: Brownout", so non-atomic in-place
writes on that hardware would trade a stall for corrupted contact records. Frequency
first, atomicity intact.

Also seen in those same screenshots and NOT addressed here: ui:gps stalls of 3077 /
3769 / 3848 ms on a device whose owner says GPS is switched off, and ui:lvgl stalls up
to 2227 ms. Separate issues, worth their own investigation.

Verifiable by the same diagnostic that found it: the `mesh` entries in About -> Loop
stalls should stop appearing.

Builds clean on Heltec V4 TFT and T-Deck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 12:47:20 +02:00
Kaj SchittecatandClaude Opus 4.8 8ae0df9400 touch: stop the deferred-login branch eating the companion app's responses (#124)
Partial fix for "no ping and telemetry response" reported from the device AND the
phone app. onContactResponse's deferred-login branch runs before every pending_*
matcher and, while armed, consumed ANY response from that contact and returned. Its
comment justifies this with "we have sent no REQ to this contact, so any RESPONSE
here is the login reply" -- true of the UI, but the companion app can independently
have a STATUS / TELEMETRY / BINARY request in flight to the same node. There is also
no distinct login-failure code on the wire (a failure is just "not
RESP_SERVER_LOGIN_OK"), so a non-OK frame is indistinguishable from the app's reply,
and the app's response was swallowed with the phone left waiting.

A LOGIN_OK is unambiguously ours, so it is still always consumed. Otherwise the
branch now falls through when the app has a request pending on that same contact,
letting its matcher see the frame; the UI's own reply deadline disarms us, which is
all the early disarm here ever provided. Behaviour is unchanged whenever the app is
not waiting, which is the common case.

Deliberately NOT changed: sendStatusPingForUI / sendTelemetryRequestForUI zero the
app's pending_status / pending_telemetry on entry. That looks like deliberate
arbitration so a UI-initiated request routes to the UI rather than the phone, and
removing it could double-handle a response. Wants its own change and a bench test.

Note this is unlikely to be the whole of #124: the leading theory remains the
repeater's per-client replay high-water mark, which no client-side change can undo.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:38:36 +02:00
Kaj SchittecatandClaude Opus 4.8 8e6a6d5f64 touch: stop the web mirror silently dropping typed characters
VE2CCK reported losing "a lot of characters" while typing into the web mirror from
a phone, and put it down to his phone's autocomplete not travelling over the link.
It is actually our bug. Browser keystrokes land in a 32-entry SPSC ring pushed from
the network thread, pushKey DISCARDS the key when that ring is full (silently, no
counter, no log), and the UI drained only 16 per loop iteration. A loop iteration
can be tens of ms on a T-Deck (LVGL frame, SD write, radio), and an autocomplete or
a paste delivers a whole word at once, so a burst overflowed 32 easily and the rest
vanished. Typing slowly worked, which is why it read as an autocomplete quirk.

Ring 32 -> 256 (512 bytes; the ceiling while _khead/_ktail remain uint8_t, noted in
the comment so nobody raises it further without widening them), and both drain sites
16 -> 64 so a burst clears in one pass instead of trickling while newer keys push the
tail along. No API or protocol change, and nothing else reads this ring.

This matters more than it looks: he is using the mirror to screen-record how-to
guides for his radio club, so typing fidelity is the feature.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:31:34 +02:00
Kaj SchittecatandClaude Opus 4.8 e5d541c170 touch: blocking someone in a room no longer silences the whole room
Found while fixing #177. A room thread is a CONTACT thread, so it took the DM
branch of ignoreSenderInActiveThread, which blocks the thread contact's pubkey.
For a room that contact is the SERVER, not the person who posted, and
newRoomMsgFromPubWithMeta drops on exactly that pubkey. So blocking one noisy
member silently killed every message from the entire room, with nothing in the UI
explaining why. Room posts carry only an author display name (passed to newMsgImpl
as the sender override), so the author is now blocked BY NAME and the room itself
is left alone. Tapping a sender whose name is the room's own name still falls
through to the pubkey block, so muting a whole room from inside it still works,
and the contacts-list block is untouched.

Second half of the same bug: the name filter in newMsgImpl was gated on `channel`,
which is false for room posts, so a room name-block was stored and then never
enforced. It now also applies when a sender override is present, which is exactly
the room case. A plain DM has neither flag, so it keeps using the pubkey filter and
is deliberately unaffected: a DM contact who happens to share a display name with a
blocked room bot must not disappear.

Builds clean on T-Deck and Heltec V4 TFT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:16:04 +02:00
Kaj SchittecatandClaude Opus 4.8 acdfd0a45b touch: revive three parked UI fixes from the beta_32 stash (#97, #106, #109)
These three had fixes written back at beta_32 but the batch was parked and never
shipped. The stash no longer applies (twenty betas of drift; ClockFloorRTC.h and
UITask.cpp both conflict), so they are ported by hand onto current main. The five
other fixes in that stash are deliberately NOT ported: #98/#100/#116 already
shipped in beta_48 and #93/#113 are closed, so re-applying them would duplicate
or conflict.

#97 the crash-restart dialog's text ran down over its own Cancel/OK buttons.
showConfirm used a fixed PSC(160) card with a freely-wrapping label, so any long
message overflowed; the crash-report prompt is the worst case. The card height now
follows the wrapped text, the message gets its own box sized to exactly the space
above the buttons (so it physically cannot reach them) and scrolls when the card
hits the screen cap. Keeps the old 160 as a floor, so every short dialog renders
exactly as before and only the broken long-text case changes. Added a clamp the
original port did not have, so the message area cannot invert at Large/Huge UI
scale.

#109 the settings sheet opened from the Discovered screen looked truncated and
could not be scrolled: centring the card and then lifting it a fixed -46 pushed
its top off-screen on the 240px-tall T-Deck. Height is clamped to what the screen
has and the lift to the leftover slack.

#106 room servers were indistinguishable from companion contacts in the contacts
list (same person icon). Rooms now get their own glyph.

Not ported: #112's RX-activity dot. The logic is trivial but the status bar has
since grown a second placement pass for the tall two-row layout (P4 /
round-corners) that the stash predates, so the dot would strand itself at its
single-row position there, and its fixed offset collides with the BLE icon's
hardcoded narrow-screen position. Needs a device to eyeball, not a blind port.

Verified: all 8 S3 envs plus the T-Display P4 (which exercises the
CAP_LARGE_SCREEN branch of the reworked dialog) build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:09:17 +02:00
Kaj SchittecatandClaude Opus 4.8 17651bcd90 touch: fix blocked senders, muted notification sound, and a latched Wi-Fi scan gate
Three community-reported bugs, two of which turned out to share one cause.

#177 blocking a channel sender did nothing. Group packets carry no per-sender
pubkey (MyMesh's channel branch passes from_pub = nullptr), so the prefix ignore
list is never consulted for channel traffic and the only live filter is the name
one. But the block path stored a PUBKEY whenever the sender happened to be a saved
contact, so the block was recorded, shown in the blocked list, and filtered
nothing. Channel blocks now always write the name entry; a saved contact also
keeps its pubkey entry so DMs stay blocked as before. Unblock is now paired, so
clearing one entry clears its twin instead of leaving the sender half-blocked.

#184 lost notification sound until a restart. s_tile_fetch_pending gates all four
chime paths and is only ever ++/--, never reset, so one leaked increment mutes the
device for the rest of the boot. queueTileForFetch ignored
ensureTileFetchTaskRunning()'s result, and that function creates the QUEUE before
it can still fail on the worker's 8 KB contiguous-DRAM stack: queue alive, worker
dead, every send incremented a counter nothing could drain. Honor the result.
Not a beta_50 regression, the sound path was untouched in that release.

#171 Wi-Fi never reconnecting after a reboot. Same worker. Opening the Wi-Fi page
drops the link and raises the scan gate, which is lowered only when the tile
worker reports s_wifiscan_done. If that worker never starts, the gate latches:
autoreconnect stays off and main.cpp suppresses its reconnect retry, so Wi-Fi is
dead until a reboot. Added a 30 s recovery deadline, far longer than any real
sweep, so it can only fire when the normal completion path is genuinely lost.

Also corrected the stale comment that described the signal probe as a flood. It
is a zero-hop NODE_DISCOVER_REQ that repeaters answer directly and never
re-broadcast, and it is skipped entirely when a direct neighbour was heard in the
poll window. That wording is where issue #80's "spams the mesh" concern came from.

Verified: all 8 S3 envs plus Tanmatsu build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 10:54:20 +02:00
Kaj Schittecat 8fff2811ed release-notes: beta_52 (RISKY - #179 storage rework, needs testing) 2026-07-29 17:08:50 +02:00
33306d9fa9 Merge #179: SD failure recovery + segmented message store (Dan Vybiral / @Vybo)
Lands @Vybo's storage-stack rework from #179 — runtime SD wedge/remount recovery,
chat-store health diagnostics, and a segmented, append-friendly message store that
replaces the whole-ring file rewrite (crash-atomic migration, per-segment
corruption quarantine) — plus a fix for the segment-table-cap latch that made SD
I/O fail after a few hours of uptime: at the cap, segBuildJob emitted a create-append
segCommitJob could not table, freezing the durability watermark into a permanent
write-spin that leaked orphan segment files until the card filled.

Full credit to Dan Vybiral (@Vybo) for the design and implementation. The cap-latch
fix is the only change on top of his branch. Landed directly (his fork branch could
not carry the fix), so #179 is closed rather than merged.

Co-Authored-By: Dan Vybiral <dan.vybiral@greencode.cz>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:16:09 +02:00
Kaj SchittecatandClaude Opus 4.8 dd7ba1f9f9 touch: fix segmented-store latch at the segment-table cap (SD I/O dies after hours)
Root cause of the reported "SD writes fail after a few hours of uptime": when the
segment table is full (s_seg_count == k_ui_seg_max), segBuildJob still emits a
CREATE-append, but segCommitJob only tables a create while s_seg_count < the cap.
The new segment file is written but never recorded, the durability watermark
(s_seg_flushed_seq) never advances, so the same append re-arms every flush cycle
(permanent write-spin). As the ring evicts the frozen records the target first_seq
shifts, so a new orphan seg_*.bin is written each cycle until the card fills and
every SD write fails; records evicted before they are credited are lost.

It surfaces only after hours because s_seg_count creeps from its ~20 steady state
(5000/256) up to the 24 cap slowly, via delete-driven fragmentation and
step-1-append starving step-2 retirement.

Fix: at the cap, defer the create-append instead of emitting an untableable one.
segBuildJob skips step 1 (before the gather/watermark advance, so the watermark is
never moved past un-written records) and falls through to step 2, which retires the
oldest segment first. At the cap the oldest 256 records have aged out of the ring
(24*256 > 5000), so that segment is fully evicted -> compact_dirty -> unlinked,
freeing a slot with no live-record loss; the deferred records are the newest (above
the watermark, evicted last) and land next cycle. saveMsgsToStorage's early
"armed == 0 -> durable" return is guarded with segMoreWorkPending so a deferred
append is reported as a failure, not a false success. Below the cap: no-op.

Builds green on T-Deck + Heltec V4 TFT. Needs a multi-hour on-device soak to
confirm against the original repro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:07:53 +02:00
Kaj Schittecat 077bc12f24 chore: refresh Mesh America catalog for beta_50 2026-07-29 15:11:46 +02:00
Kaj Schittecat 0a51e7e504 release-notes: beta_51 2026-07-29 14:32:47 +02:00
Kaj e4aa702251 Merge pull request #176 from jacobpretorius/channel-util-tool
feat(ui-touch): add Channel Utilization tool to Apps drawer
2026-07-29 14:16:06 +02:00
Kaj 6d1d38196e Merge pull request #182 from scratchdiver/steve/touch-chat-timestamp-top
touch: move chat bubble timestamp to top row
2026-07-29 14:15:48 +02:00
Kaj 19107bf7e0 Merge pull request #181 from Yazutsu/fix/lockscreen-wallpaper-cache
ui-touch: invalidate LVGL img cache before freeing lock wallpaper buf.  Fixes #127
2026-07-29 14:15:37 +02:00
Kaj SchittecatandClaude Opus 4.8 e26b406e02 touch: P4 honours the UI-size pref again (text size was a dead no-op)
beta_49 hard-pinned s_ui_fscale=100 on the P4 to stop >100% chrome overflow, but
the "UI size" dropdown stays shown (CAP_LARGE_SCREEN), so changing it did nothing
(reported: "changing text size doesn't work on the P4"). Honour the saved pref like
the Tanmatsu does. Large/Huge can still make some P4 chrome that uses unscaled dims
tight; that's a per-screen SC() follow-up, not a reason to disable scaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:15:17 +02:00
Dan Vybiral f2c1b73636 touch: tell the map when SD storage comes back
After a reinsert the chat store re-landed but the map stayed blank: the
tile layer was never told the storage changed. It keeps decoded tiles in
s_map_tiles across renders and only renders on pan / zoom / tab-open, so
a card that came back produced no reload; a boot with no card never
adopted a later-inserted one as the tile cache at all.

mapNoteStorageChanged() now runs from every mount-state transition
(wedge-recovery remount, 30 s reinsert watch, file-manager insert poll,
post-format, and both card-lost paths), right next to the existing
flushHistorySoon(). It adopts the card as the tile backend when this
boot resolved none, drops the decoded tiles (a swapped card holds
different content), clears the fetch dedup ring so downloads that failed
during the outage are retried instead of being remembered as in-flight
forever, and re-renders immediately when the map is the visible tab.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 15:28:25 +02:00
Dan Vybiral 5627c90bb3 touch: chat-store readouts show the save TIME, not an age
The home chip now carries the clock time of the last successful save
(HH:MM, 12/24h-aware, with a '-<N>D' suffix once it is a day or more
old) instead of the segment count and byte total, and the About page's
'last save' line switches from 'OK 12s ago' / 'FAIL xN (12s ago)' to the
same timestamp form. A clock time answers 'is my history safe right
now?' directly, and the day suffix stops a store that quietly stopped
saving yesterday from reading as fresh. Segment count, byte total and
the failure stage + errno stay on the About panel.

Backed by new wall-clock stamps taken alongside the existing millis
ones in uiMsgsWriteResult; they read 0 (rendered '--:--') when the
system clock wasn't set at save time, since no meaningful timestamp
exists then.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 14:07:27 +02:00
Dan Vybiral 41eb4a559f touch: close eleven crash/failure-path holes in the segmented store
A second adversarial review pass (11 confirmed findings, 2 critical) hit
the crash and failure paths the on-device happy path never touches. Six
root causes, fixed at the root rather than per-symptom:

1. Migration was not crash-atomic (CRITICAL). Chunks landed one rename
   at a time, so a power cut mid-migration left a valid oldest-first
   PREFIX that the loader preferred over the still-intact old file --
   permanently showing only the oldest few hundred messages. There is
   now a commit marker (/msgs/store.ok) written only after full
   verification: its absence means the segment set is provisional, and
   the loader wipes those segments and re-reads the old file. The 60 s
   migration retry on a sick card therefore no longer risks history.

2. Migration assumed a linear ring (CRITICAL) but its retry paths run
   mid-session, after appendMessage may have wrapped it -- producing
   segments in slot order (first_seq > last_seq, scrambled history that
   per-segment read-back could not detect) before deleting the old
   file. It now walks the ring chronologically, skips tombstones (which
   it used to persist as ghost records), and verification additionally
   requires strictly increasing seqs within and across segments.

3. A partially-failed append was retried by appending the same batch
   again, duplicating records (or misaligning every later record if the
   failure split one). Segments now carry a rewrite_open flag meaning
   "file content untrusted": the scheduler REPAIRS such a segment with
   a full rewrite (header + records, tmp+rename) before any append can
   target it, and the repair absorbs the unflushed tail.

4. Post-resync appends targeted deleted files (headerless segments) and
   could push a chunk's range past the 256-record gather cap, silently
   dropping records the watermark already called durable. Fixed by the
   same repair-first rule: retable marks every chunk rewrite_open.

5. Resync deleted every on-disk segment up front, so a same-card wedge
   recovery left the whole history RAM-only for minutes. It no longer
   deletes anything: same-key files are replaced by each repair's
   rename, and leftovers are swept only once the re-land is complete.

6. A stale worker descriptor could be committed twice after
   persistHistoryNow drained past a busy worker (double-counted totals,
   duplicate table entries), and a leaked redirty flag could make the
   drain re-compact one segment ~28 times and then report failure.
   uiHistWaitWorkerIdle now always drops the descriptor and the flag,
   marking the abandoned job's segment for repair instead. The drain
   also refuses to run at all while a worker is still writing -- with
   FF_FS_LOCK=0, removing or renaming a path under a stalled open
   handle can cross-link clusters and corrupt the volume.

Also: the loader applies a strictly-monotonic seq filter, so duplicate
or out-of-order records from any interrupted write are dropped at load
and the affected segment is rewritten; append commits credit their own
segment by key instead of blindly crediting the last table entry.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 12:29:41 +02:00
Dan Vybiral 8bb2b47cd7 touch: chat-store chip on the Commander legend row
Adds a compact store-status chip sharing the TX/RX legend row above the
home chart, parked on the right half of the chart width (fixed width +
right-aligned + CLIP, so it can never grow into the traffic counts at
any UI scale). Small font (g_font_12), not clickable — the legend still
owns this row's taps.

Shows a floppy glyph plus, in order of precedence: 'FAIL xN' in red
while saves are failing, 'migrating' in amber while an old-format store
hasn't converted yet, else the segment count and total bytes ('9s 512K'
/ '21s 1.1M') in the normal subdued colour. Refreshed independently of
the TX/RX chart so the big-screen scaled layout (which drops the chart)
still shows it, and re-flowed with the legend on Expansion-Kit boards.
The full diagnosis (backend, failure stage + errno) stays on the About
page's Chat store panel.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-27 11:30:14 +02:00
Dan Vybiral eeefa47fa2 touch: fix seven review findings in the segmented-store scheduler
An adversarial review pass over the refactor surfaced these; the first
is a silent-permanent-loss bug:

- CRITICAL: the finished-job check ran AFTER the failure branch had
  already reset s_hist_flush_ok, so a FAILED worker job read as ok and
  got committed — advancing the durability watermark over records that
  never reached disk. The completion check now runs strictly first, and
  a failed job just drops its descriptor (data stays pending).
- A message deleted while its APPEND job was in flight resurrected on
  reboot: segMarkSeqDirty skipped it (seq above the watermark = 'never
  reaches disk') but the in-flight job then put it on disk. After a
  committed append the ring's tombstones inside the newly-flushed range
  now get their compaction marks.
- segCommitJob cleared compact_dirty unconditionally, erasing marks for
  deletes that landed WHILE the compact job was writing (its snapshot
  predates them). A redirty flag keeps the segment dirty for a
  follow-up pass.
- The sync drain shared the worker's job descriptor, snapshot buffer
  and req flag: a worker stalled past the 9 s idle-wait cap could read
  a torn buffer, and the raised req let the worker steal and
  double-execute a job the drain was running inline. segBuildJob is now
  pure (fills a caller buffer + local SegJob); only the async path arms
  the worker statics, and the drain owns a separate buffer.
- An in-session FAT32 format erased all segments while the table (and
  watermark) still claimed them — old records permanently lost and the
  next append targeted a missing file. The format path now runs the
  same resync as a card swap (retable + re-land off-thread).
- uiSegAppendRecords with create=true used FILE_APPEND, so a stale
  same-key file (crash residue) got a second header mid-file; create
  now opens truncating.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 23:37:43 +02:00
Dan Vybiral 1f01a267dd touch: segmented-store integration + lifecycle fixes (phase 5)
- About 'Chat store' shows the segment count + writer-maintained byte
  total (zero file I/O at 1 Hz — the old size probe blocked on the FatFs
  volume lock behind a sick write) and flags a pending migration.
- uiDataEnsureDirs() runs at boot (before the loader scans) and on all
  three SD remount paths, so a fresh replacement card gets the segment
  dir immediately.
- sdEnsureMeshcomodFolders (post-FAT32-format) now also recreates
  /meshcomod and /meshcomod/msgs — an in-session format used to break
  every chat/telemetry write with ENOENT until a reboot.
- The boot SPIFFS->SD adoption creates /meshcomod/msgs so flat-named
  SPIFFS segments ('/msgs/seg_*.bin') copy onto the FAT card instead of
  being silently skipped (missing parent dir).
- sdRestoreApply ('Copy internal data to SD') persists chat history
  BEFORE the copy — it used to reboot without persisting, silently
  dropping everything since the last lazy flush.
- Dead s_ui_data_resolved removed; stale writer comments updated.

Builds verified: all five SD-relevant touch envs.

Per .notes/segmented-store-plan.md phase 5.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 23:03:37 +02:00
Dan Vybiral 2c7ea98d42 touch: segmented-store flush scheduler (phase 4)
The flush pipeline now moves one bounded JOB at a time instead of the
whole ring: an append batch into the active segment (a few hundred
bytes per message burst) or a one-segment compaction rewritten from the
RAM ring. The loop task builds and COMMITS jobs (single table writer);
the core-1 hist_flush worker only executes the armed job and reports —
same busy/req protocol, same failure ladder (retry backoff, warning
toast, hist_sync_after sync-fallback), all ported verbatim.

- Durability watermark (s_seg_flushed_seq): ring records above it are
  the pending-append backlog. Records deleted before ever flushing
  advance it with no write. Job snapshot buffer is one segment (~80 KB
  PSRAM) replacing the whole-ring 1.3 MB snapshot.
- markMsgsDirty clamps drop from 30 s/10 s (whole-ring-rewrite pacing)
  to 2 s SD / 5 s SPIFFS — the hard-cut loss window shrinks ~15x.
- Deletes (message/thread/purge/web) mark owning segments compact-dirty;
  eviction unlinks a segment only when ALL its records aged out (no
  per-message rewrite churn; a partially-evicted boundary segment is
  trimmed by the next boot's loader).
- saveMsgsToStorage is now the synchronous drain (shutdown/reboot/
  delete flows/sync-fallback): it retries a failed migration, honors
  resync, and never uses FILE_APPEND (a worker stalled past the 9 s
  idle wait could hold an append handle on the same file — the active
  segment is rewritten via the .tm2 namespace instead, rename-over
  wins with each candidate internally consistent).
- flushHistorySoon (remount paths) now sets s_seg_resync: the segment
  table is rebuilt from the ring, stale/foreign on-disk segments are
  removed, and the full history re-lands one segment at a time
  off-thread — replacing the old 'full-ring write fixes everything'
  property without the >30 s UI freeze.
- Retired: uiWriteMsgsFile, uiHistWorkerFlush, the ring-sized snapshot.

Builds verified: all five SD-relevant touch envs.

Per .notes/segmented-store-plan.md phase 4.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:59:14 +02:00
Dan Vybiral fb0346e249 touch: segmented-store loader + verify-then-delete migration (phase 3)
loadHistoryFromStorage now prefers segments over the split v6 file over
the legacy combined file. The segment loader reads oldest-first into the
ring, quarantines corrupt segments INDIVIDUALLY (one bad record used to
discard the entire history), truncates crash-ragged tails at the record
boundary, keeps the newest <= cap records when the disk set outgrows
this boot's ring (three-reversal rotation restores the linear-ring
invariant), reconciles the segment table (fully-dropped segments deleted,
the boundary segment marked compact-dirty), restores real seqs and seeds
the generator, and sweeps orphaned segment tmps.

When an old format supplied the ring, migrateRingToSegments() converts
it: segments written from the ring, every one read back and verified
(count + seq bounds) before the old msgs file is deleted; any failure
rolls the whole segment set back so the intact old file stays
authoritative next boot, and s_seg_store_ready stays false so the flush
scheduler (phase 4) retries the migration instead of writing segments
that would shadow it.

The companion message counter rides the threads-file header now (the
segments don't store it); the mesh re-asserts it per message anyway.

Builds all five envs (loader wired; writers wire in phase 4).

Per .notes/segmented-store-plan.md phase 3.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:48:05 +02:00
Dan Vybiral 3d487aefcf touch: segmented store core primitives (phase 2)
The file-layer building blocks for the segmented chat store, no callers
yet: UiSegHeader/UiSegMsg on-disk format (v6 record + appended seq,
self-describing rec_size), segment naming (/msgs/seg_<first_seq>.bin,
real subdir on FAT backends, legal flat name on SPIFFS), uiDataEnsureDirs,
chunked internal-RAM record writer, active-segment append (header-first
on create, retry-once after ensuring dirs, ragged-tail crash semantics),
one-segment compact (tmp+rename with the .tmp/.tm2 dual-namespace
discipline, n==0 removes the file), retired-segment delete, validated
open + size-agnostic record reader (File::size()-free — the Tanmatsu FFat
metadata layer lies), and segment discovery (dir listing on FAT, flat
prefix scan on SPIFFS, orphaned-tmp sweep, sorted ascending).

All ops report through uiMsgsWriteResult/uiMsgsWriteFail so the About
diagnostics, failure toasts, sync-fallback and SD-wedge arbitration work
unchanged; new stage codes a/c/d/s join chatSaveFailText.

Builds all five envs (expected unused-function warnings until the
scheduler wires these in).

Per .notes/segmented-store-plan.md phase 2.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:40:23 +02:00
Dan Vybiral be6e1f20ff touch: per-record sequence numbers for the segmented chat store (phase 1)
Every ring record gets a monotonic uint32 seq at append time, and the
loader backfills seqs in chronological order for records loaded from the
pre-segment formats (which carry none on disk), seeding the generator
past them. seq is the segmented store's record key: segment membership
is a [first_seq, last_seq] range, stable across per-segment compaction.

Deliberately a dedicated counter (_ui_seq_next), NOT _msgcount: the
companion protocol can overwrite _msgcount (msgRead), so it cannot be a
unique key.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 22:34:41 +02:00
Steve GlennerandCursor f5d8cfa390 touch: put bubble timestamp on top for DMs and rooms too
Extend the channel top-row timestamp layout to every bubble-style
thread so DMs and room messages match channels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:40:05 -07:00
Dan Vybiral fdaf48402c touch: sync-fallback for failing chat saves, with a configurable threshold
EIO persisted on the core-1 flush task, which narrows the failure to
shared-SPI interleave rather than core-0 starvation alone: the one
always-working configuration is the LOOP TASK writing (reboot persist)
-- when it writes, nothing else interleaves radio traffic between the
SD transactions on the shared bus. (The pager port notes flagged
exactly this: 'keep an eye on SD-write + RX overlap during history
flush'.)

The flush now self-heals: after N consecutive failed background writes
it retries synchronously on the loop task (one UI hitch per flush while
degraded), and a single success flips it back to the async task. N is a
new setting (Settings -> General -> 'Chat save fallback', dropdown
Off/1/2/3/5/8, default 2; Off = background retries only). Stored as
TouchCfg v40 trailing field hist_sync_after with getter/setter.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-26 18:37:46 +02:00
Steve Glenner 3b1a07dd93 Revert "touch: add Display Settings toggle for channel timestamp on top"
This reverts commit 31cb4c38a8.
2026-07-26 08:38:11 -07:00
Steve GlennerandCursor 31cb4c38a8 touch: add Display Settings toggle for channel timestamp on top
Make the shorter channel-bubble layout optional via a persisted
"Timestamp on top" switch (default ON), so classic footer layout remains available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 16:35:40 -07:00
Steve Glenner 8dc02a2b23 Move message timestamp to top of bubble to fit more messages on screen 2026-07-25 12:02:57 -07:00
Yaz 47ba9e010c ui-touch: invalidate LVGL img cache before freeing lock wallpaper buf
s_lock_wall_dsc is a static lv_img_dsc_t (stable address). LVGL's image
cache is keyed by that src pointer, not by dsc->data, so re-decoding a
custom lockscreen wallpaper into a fresh PSRAM buffer without first
invalidating the cache entry can leave LVGL drawing from a stale,
already-freed pointer on the next lockscreenShow(). The freed PSRAM is
commonly reclaimed soon after by the map's 128 KB tile buffers, so the
stale draw shows up as colorful RGB565 noise instead of the wallpaper --
exactly the symptom in #127. Only reboot 'fixed' it because the stale
LVGL cache entry otherwise persists indefinitely.

Same bug class already fixed for map tiles in freeMapTileSlot() (see
its comment); this applies the same lv_img_cache_invalidate_src() call
to the two places that free s_lock_wall: lockscreenShow() (re-decode)
and lockscreenHide() (teardown).

Fixes #127
2026-07-25 13:32:03 +02:00
Dan Vybiral 16f18a6a11 touch: run the chat-history flush on core 1; never unmount under a live flush
Two failure signatures from on-device testing, both now explained and
closed:

- EIO ('card I/O error') on the periodic autosave while the identical
  full-ring write succeeded from the reboot path: the flush rode the
  core-0 tile_fetch worker -- the Wi-Fi core -- where the driver's
  prio-23 tasks starve a prio-1 task for hundreds of ms. sd_diskio's
  busy-waits measure WALL time, so timeouts expired while the task
  simply wasn't scheduled, and healthy writes surfaced as EIO
  (intermittently: the write sometimes won the timing, matching the
  'succeeded after a few auto tries' observation). The flush now runs
  on its own small task pinned to core 1 at the loop task's priority --
  equal-priority time-slicing keeps the UI serviced during a write, and
  chat persistence has no Wi-Fi affinity to lose.

- EBADF ('bad file number') after a shrink: FatFs FR_INVALID_OBJECT --
  a handle whose volume was remounted underneath it. The file-manager
  removal poll could probe + SD.end() while the flush held its open
  temp file. All unmount paths now hold off under an in-flight flush
  (the FM poll joins the sdHealthTick guard).

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:44:05 +02:00
Dan Vybiral 75883a62a2 touch: spell out the chat-save failure in alerts and the About readout
Replaces the '@o e23' shorthand with a human-readable diagnosis
(chatSaveFailText): stage (open/header/write/rename) plus a plain-text
errno ('too many open files', 'card full', 'card I/O error', ...,
strerror fallback), e.g. 'open failed: too many open files (e23)'.
Shown as a second line on the About Chat-store section, on the repeated
autosave-failure alert, and on the reboot-time last-chance-save alert.
Diagnostic vocabulary stays English on purpose -- it is what ends up in
bug reports verbatim.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:30:58 +02:00
Dan Vybiral f22c9decc5 touch: raise SD max_files 3 -> 6; report chat-save failure stage + errno
Field evidence reframed the chat-save failures: a soft reboot wrote the
FULL 515 KB ring to the same card without trouble, while the periodic
worker autosave could not even create the file. That rules out a
size/bad-region card problem and points at contention: the SD VFS was
mounted with max_files=3 (set when the card only served the file
manager), but the card now serves telemetry log rewrites (2 handles),
battery log rewrites (2), the history flush, tile reads/writes, WAV
chimes and the About readout concurrently -- the flush's open("w") loses
that race with ENFILE while the card itself is fine, and at reboot
(everything quiesced) the same write sails through. All four mount
sites now use max_files=6 (~+1.7 KB while mounted).

To make the next such failure self-explaining on a device with no
readable serial, the About Chat-store line now includes the failure
stage and errno ('FAIL x3 @o e23'): o/h/b/r = open/header/body/rename;
errno 23/24 = VFS file table full, 28 = card full, 5 = card I/O error.
The header-write failure path also joins the failure accounting (it
returned early without counting before) and cleans up its temp file.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:19:51 +02:00
Dan Vybiral 99a76a4569 touch: compact deleted messages out of the ring file
Deleting messages tombstones them in place (thread[0]='\0'; the ring
cannot memmove — slot indexes are live UI handles), but the writer
emitted tombstones as full blank records: delete 1000+ messages and the
file never shrank, and every flush kept rewriting the high-water size.
The writer now skips tombstones (the file compacts to live records on
the next flush), and the loader accepts a legitimately empty header-only
file instead of quarantining it as corrupt.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-25 00:15:58 +02:00
Dan Vybiral 999dde93a3 touch: stop a failing chat store from freezing the UI
Field trace with the new Chat-store diagnostics (FAILING x1, then an
escalating UI freeze on every ~30 s retry) exposed three stall sources
around a card that mounts fine but times out on large writes:

- The About page's Chat-store readout opened the msgs file at 1 Hz on
  the UI task; that open blocks on the FatFs volume lock behind the
  worker's slow failing write, so just WATCHING the diagnostics froze
  the UI. The file size is now cached, refreshed at most every 30 s and
  never while a flush is in flight.

- The persist-after-remount ran the full ring write synchronously on
  the UI task -- >30 s freeze on a write-sick card. Replaced with
  flushHistorySoon(): arms an immediate flush without the min-delay
  clamp, actual write stays on the core-0 worker.

- Retries against a hopelessly failing store ran every 30 s forever,
  each costing seconds of bus stalls. After 5 consecutive failures the
  retry backs off to 5 min; the RAM ring keeps everything meanwhile,
  the on-screen warning already fired, and one success resets the
  counter.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-24 23:47:50 +02:00
Dan Vybiral f1e2bc375b touch: surface chat-history save failures; harden the reboot-time persist
Digging into 'messages still lost after a soft reboot': the loader is
sound (well-formed files load fully; a quarantine discards the whole
load, never a suffix) and the chat detail applies no time filter — so a
conversation missing only its recent messages means the ui_msgs file
itself is stale. Two ways that happens silently today:

- Persistent ring-write failures. uiWriteMsgsFile failing (full card,
  FAT damage from hard cuts/card yanks, wedge) was 100% invisible: the
  small threads file keeps succeeding, so the thread list shows fresh
  last-message times while messages quietly stop persisting — exactly
  the reported UX. Now every ring-write outcome is recorded
  (uiMsgsWriteResult), repeated failures raise an on-screen warning, a
  failure on the SD store stamps the SD health probe, and the About page
  gets a 'Chat store' section showing the resolved backend (also exposes
  the boot-with-unmountable-card trap where history silently goes to
  internal flash), the msgs file size, and the last save result.

- The reboot-time persist had a hole and a corruption hazard. A worker
  still busy past uiHistWaitWorkerIdle's 9 s cap left _msgs_dirty
  cleared, so rebootDevice skipped the final sync save entirely; and
  when the sync save DID overlap a stalled worker write, both truncated
  the SAME temp file — interleaved garbage, and whichever rename landed
  last installed a corrupt file the next boot quarantines (total
  history loss). A still-busy worker now counts as unwritten data, the
  synchronous writer uses its own temp (last-rename-wins, each candidate
  internally consistent), a failed last-chance save shows a warning
  before the reset, and boot reclaims orphaned temps.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-24 23:29:29 +02:00
Dan Vybiral f29728bdf8 touch: persist the chat ring immediately after an SD remount
Message-loss analysis from testing: messages live in a RAM ring and the
SD file is written lazily (>=30 s coalesce for the SD-sized ring), with a
synchronous persist only on SOFT reboot/shutdown. A hard power cut
(power switch, reset button, reflash) therefore loses everything since
the last SUCCESSFUL flush -- and while the card is out, flushes fail for
the whole outage, so the loss window grows to the entire card-out span
plus up to 30 s after remount. This also explains the confusing UX where
the thread list still shows the correct last-message time while the
conversation lacks the recent messages: thread metadata
(ui_threads_v1.bin, ~1.5 s coalesce) and the message ring
(ui_msgs_v1.bin, >=30 s) are separate files with different freshness.

All three remount paths (wedge recovery, reinsert watch, FM insert poll)
now call persistHistoryNow() right after a successful mount, so the
entire RAM ring -- including every message received while the card was
out -- lands on the card within the same second it returns. They also
SD.mkdir(/meshcomod) first: a fresh replacement card has no data root,
and without it every history flush failed silently forever.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-24 23:14:41 +02:00
Dan Vybiral fd8e835cf3 touch: remount a reinserted card so history writes resume (messages were lost)
On-device test: after a failed remount ('SD card lost', SD.end() run) a
REINSERTED card looked recovered -- battery stats quietly fall back to
SPIFFS -- but chat history kept writing into the unregistered SD VFS and
silently failing: new messages were never persisted again until reboot.
Nothing outside the file manager ever re-attempted a mount, and
sdHealthTick early-returned while unmounted.

- sdHealthTick now runs a reinsert watch while unmounted: one light
  single-attempt SD.begin (no ladder; an empty slot fails fast) every
  30 s while awake. A healthy warm card mounts on the first 4 MHz
  attempt -> 'SD card remounted', and the armed history flush retries
  then land (messages received while the card was out are still in the
  RAM ring and flush too). The FM poll / SD-row tap keep the full ladder
  for stubborn cards; the watch skips while the FM is open or the pager
  card-detect says the slot is empty.

- Hot paths no longer walk the mount ladder at all: map tile lookups
  (loadTileJpeg / tileExistsAt) check s_sd_mounted only -- with the card
  out, the post-backoff re-ladder read as recurring multi-second UI
  freezes on the map. The notify-task WAV open also drops its
  fmSdTryMount(): laddering SD.end()/SD.begin() from a second task races
  the loop task's SD use. Mounting is owned by boot adoption, the
  reinsert watch, the FM paths, and the SD-tiles toggle.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-24 21:19:15 +02:00
Jacob Pretorius 0b1349aa00 feat(ui-touch): add Channel Utilization tool to Apps drawer
Adds a self-contained "Airtime" app that measures how busy the LoRa
channel actually is, so lost-message reports can be checked against real
data instead of assumed congestion.

It separates the two things that get conflated:
  - Is the spectrum busy?  channel utilization % = (RX airtime + TX
    airtime) / wall-clock, sampled at 1 Hz, plotted over time, with a
    session average and a congestion-threshold reference line.
  - Are packets actually being lost at the radio?  CRC errors
    (collisions), packets heard but read too late (getRxEvents -
    packetsRecv - recvErrors), and RX-queue drops.

A plain verdict combines both axes so a field tester can report
a concrete conclusion. Reads existing counters via the_mesh (Dispatcher
airtime/packet accumulators) and radio_driver (RadioLib wrapper stats);
no MeshCore core changes.
2026-07-23 18:37:50 +01:00
Dan Vybiral 3b773986f0 touch: pause the SD background probe in the sleep regime
The 30 s background liveness probe now skips while touchSleep has the
device parked (screen off, loop throttled): nobody is watching for the
toast, and the periodic SEND_STATUS would keep the card from settling
into its own power-down. A card yanked while asleep is caught by the
first background probe after wake. The failure-driven probe stays active
during sleep on purpose -- scheduled telemetry keeps logging to SD while
parked, and a wedge there must still recover.

Builds verified: all five SD-relevant touch envs.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-23 19:29:01 +02:00
Dan Vybiral c3e0cdb8d8 touch: SD wedge recovery — catch idle removal, un-freeze the map, backoff the ladder
On-device testing of the previous commit showed three gaps:

- No 'SD card lost' when the card is pulled while nothing writes:
  detection was purely failure-driven, and the battery path never stamps
  (battLogFs's SD.exists fails -> silently falls back to SPIFFS, which is
  exactly the 'battery stats fail to load' symptom). sdHealthTick now
  also runs a 30 s background probe (one sub-ms SEND_STATUS on a healthy
  card), so idle removal is noticed and toasted; battLogFs stamps when
  cardType() claims present but real I/O fails.

- Map froze the whole UI when the card was pulled with the map open:
  every tile lookup ran up to five SD ops, each riding a full sd_diskio
  timeout + re-init cycle, serializing the render pass into a
  multi-minute freeze. Tile paths (loadTileJpeg / tileExistsAt / the
  SD-backed tile cache) now short-circuit while a dead card is suspected,
  and sdReadFailedCardDead() distinguishes a missing tile (healthy card:
  cheap, silent) from a dead card (stamp + bail) on a failed read.

- Even after an unmount, hot paths calling fmSdTryMount() directly would
  re-walk the multi-second mount ladder per call. fmSdTryMount() now
  fast-fails inside the post-failure backoff window; the explicit user
  retry (tap the SD row in the file manager) clears the backoff first.

Also renames sdNoteWriteFailure -> sdNoteIoFailure (it now covers reads).

Builds verified: LilyGo_TDeck_companion_radio_touch,
heltec_v4_tft_companion_radio_usb_tcp_touch,
heltec_v4_r8_tft_companion_radio_usb_tcp_touch,
tlora_pager_sx1262_companion_radio_touch,
ThinkNode_M9_companion_radio_touch.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-23 19:08:32 +02:00
Dan Vybiral 9b30dad9e8 touch: recover a wedged microSD at runtime instead of playing dead until reboot
A runtime SD failure is a one-way door in arduino-esp32: one timed-out
command latches STA_NOINIT in sd_diskio (and a data-phase error leaves
every later op failing FR_DISK_ERR without even setting NOINIT, so FatFs
never re-inits the card). Meanwhile SD.cardType() returns the type CACHED
at mount -- only an explicit SD.end() ever makes it CARD_NONE again -- so
every 'cardType() != CARD_NONE' gate keeps passing, s_sd_mounted stays
true, fmSdTryMount() no-ops, and the file-manager removal poll (which
compared against CARD_NONE) could literally never fire. Net effect: after
one bad moment on the bus (heavy clustered writes -- scheduled telemetry
log rewrites, battery log, core-0 history flush / tile writes -- on the
shared rail) the card silently plays dead until a reboot power-cycles it.
Reported as: SD behaves as not present until the next reboot.

Fix, three parts:

- sdNoteWriteFailure(): any writer on any task (loop, core-0 worker)
  stamps a volatile flag when an SD write open fails while the card is
  supposedly mounted (telemetry log, battery log, wardrive CSV, chat
  history/threads store, tile cache).

- sdHealthTick() in UITask:➿ verifies the stamp with real I/O --
  sdProbeAlive() opens the root dir, whose opendir runs f_opendir ->
  disk_status = a real SEND_STATUS transaction (and one re-init handshake
  on a NOINIT'd card, so a recoverable card self-heals right there). If
  the card stopped answering, drop the mount and re-run the full mount
  ladder: exactly what the next reboot would have done, minus the reboot.
  Success => 'SD card remounted' toast and every feature resumes
  transparently (same VFS mountpoint, cached fs pointers stay valid).
  Failure => honest 'SD card lost' with cardType() now reading CARD_NONE,
  so features degrade gracefully and the existing remount paths (FM poll,
  row tap, feature use) keep retrying with the usual backoff. Held off
  while the core-0 worker owns the card (hist flush / FAT scan / firmware
  download) and while a format is pending; probes rate-limited to 1/5 s.

- The FM removal poll now probes with real I/O instead of the dead
  'SD.cardType() == CARD_NONE' check, so physically yanking the card is
  finally detected too.

Builds verified: LilyGo_TDeck_companion_radio_touch,
heltec_v4_tft_companion_radio_usb_tcp_touch,
heltec_v4_r8_tft_companion_radio_usb_tcp_touch,
tlora_pager_sx1262_companion_radio_touch,
ThinkNode_M9_companion_radio_touch.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
2026-07-23 18:40:48 +02:00
Kaj Schittecat 2b554940af release-notes: beta_50 2026-07-22 22:48:39 +02:00
Kaj SchittecatandClaude Opus 4.8 3779724000 touch: correct per-board OTA_BIN_NAME for all dual-slot boards
OTA_BIN_NAME only branched T-Deck vs everything-else -> wadamesh-heltec-v4-tft, so
V4-R8, ThinkNode M9, RAK Tap V2, both T-LoRa Pagers and Attaky would self-update by
downloading the V4-TFT bin -- flashing the wrong firmware onto the wrong board.
All are dual-slot OTA-capable (CAP_OTA=1 + app0/app1 partitions + FIRMWARE_OTA_ENV),
so map each to its own release bin. Pager splits on USE_LR1121. Tanmatsu stays out
(AppFS/launcher, CAP_OTA=0, never reaches this file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:47:54 +02:00
Kaj SchittecatandClaude Opus 4.8 71dff36904 touch: wire on-device OTA / beta toggle for the T-Display P4
The update-channel UI (the "Get test builds (beta)" toggle + the Install-update
button) is gated on a non-empty FIRMWARE_OTA_ENV. That is set per-env in
platformio.ini for the 8 S3 boards but was never set for the P4 IDF build, so the
whole update UI was hidden — P4 testers had no way to opt into beta and stayed
stuck reading the stable channel (the beta_49 "no update, 48 up to date" report).

- tdisplay_p4 CMakeLists: define FIRMWARE_OTA_ENV so the UI is shown.
- UITask.cpp: add a P4 branch to OTA_BIN_NAME so the download URL points at
  wadamesh-tdisplay-p4[-lcd].bin, not the V4-TFT bin. AMOLED vs LCD split on
  HAS_TDP4_LCD.

The C6 WiFi shim already routes HTTPClient/Update.h and the P4 partition table has
dual ota_0/ota_1 slots, so the OTA path itself was wired; this only unhides and
correctly targets it. Builds green on P4 (AMOLED) + V4-TFT. On-device toggle/OTA
test still pending (P4 dropped off USB, needs a power cycle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:52:43 +02:00
Kaj 35d0daf203 Merge pull request #169 from Attaky-Module/attaky-mesh-series
attaky_mesh_series: on-screen keyboard via detachable module, POWER button, and board fixes
2026-07-22 19:43:04 +02:00
Kaj 4df3dce481 Merge pull request #168 from codemonkeybr/fix-pager-lock-screen
pager: fix lock screen (peek gesture, native layout, focus-invert black text)
2026-07-22 19:42:28 +02:00
Tesso M Costa e7c7df65fa pager: fix lock screen (peek gesture, native layout, focus-invert black text)
- Backspace tap now peeks the lock screen (reveal without unlocking),
  mirroring the T-Deck's trackball-click behavior; holding Backspace still
  fully unlocks.
- New native 480x222 lock wallpaper asset so the icon/wordmark aren't
  crushed by the shared 320x240 art's cover-fill on this wide/short panel.
- Lock text forced to pure white and repositioned for this layout: clock
  top-left over the icon, unread count + "Screen locked" stacked
  bottom-right directly above the unlock hint.
- Fixed lock text flipping from white to black a moment after every reveal:
  the lock overlay is CLICKABLE (to absorb taps), so the keyboard/encoder
  nav rebuild was collecting it as a focus target -- navFocusCb's focus
  highlight then reverse-videoed it, flipping every label's text to dark.
  Tagged the overlay NAV_SKIP so it's never a nav target and never inverted.
- serviceLockscreen() now runs in the pager's loop branch too, so the lock
  clock/unread badge keep refreshing after the first reveal instead of
  freezing there.

All changes gated to TLORA_PAGER; no other board's behavior changes.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-21 16:15:03 -06:00
Kaj SchittecatandClaude Opus 4.8 4e6a9e0108 touch: beta_49 - T-Display P4 fixes (exit traps, screen timeout, UI scale, GPS, control center)
P4-focused beta. Non-P4 boards only pick up minor shared exit-trap code, nothing user-facing.

- Snake + full-screen apps (Monitor/Spectrum/Discover/Web mirror/Advert) exit on status-bar tap
- Screen timeout / standby / burn-in dimming wired for the AMOLED (setBrightness path)
- UI scale pinned to 100% on the P4 (text overflow above 100%)
- No hard-lock on screen-off (P4 has no lock screen yet)
- GPS: L76K detect at 9600 baud, skip boot-detect race, 4096 RX buffer
- Control center dropdown taller on tall screens so the GPS line clears the toggle buttons
- P4 LCD SKU: HI8561 backlight PWM on GPIO51 (fixes the black screen)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:34:17 +02:00
Attaky 2d6a88bd1f attaky_mesh_series: include this board in the landscape home-chart width 2026-07-21 11:32:12 -07:00
Attaky b1ad449147 attaky_mesh_series: board fixes - crystal clock, GPS acquisition, sensor addresses 2026-07-21 11:32:12 -07:00
Attaky b1b689c48f attaky_mesh_series: keep remote landscape upright on this panel 2026-07-21 11:32:12 -07:00
Attaky b525f75335 attaky_mesh_series: summon the on-screen keyboard with the module's '#' key 2026-07-21 11:32:12 -07:00
Attaky 3d6e6f0963 attaky_mesh_series: read front buttons and toggle the panel with POWER_BTN 2026-07-21 11:32:12 -07:00
Kaj SchittecatandClaude Opus 4.8 5e4f9a221e touch: beta_48 fixes - crash, boot hang, MQTT save, message times, keyboard, more
Chat typing crash (#98), SD boot hang after update (#142, #148), MQTT
settings not saving (#128), wrong message times (#100, #152), Dutch keys
1 and ! typing ij (#144, #164), compose length limit (#119), clipboard
truncation (#120), quick messages scroll (#151), Join screen name glyph
(#116), Tanmatsu emoji picker nav (#141).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:24:31 +02:00
Kaj SchittecatandClaude Opus 4.8 57f206220d meshamerica: add LilyGo T-Lora Pager tile (both radio variants)
The pager folds into Mesh America's existing 'LilyGo T-Lora Pager' tile
(exact name match). Generator now supports per-radio 'variants' — emits one
gui firmware entry per build under a single tile, [LR1121]/[SX1262] title
prefix matching their stock pager labelling. Stable (beta_46) bins verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:26:02 +02:00
Kaj SchittecatandClaude Opus 4.8 756bb9f821 site: group device selector by manufacturer + add search + P4-LCD card
Rework the homepage device picker into collapsible per-manufacturer boxes
(LilyGo / Heltec / Elecrow / RAKwireless / Nicolai Electronics / Attaky) with
a live search that filters by device name and manufacturer and auto-expands
matching groups. Add the T-Display P4 TFT-LCD (HI8561) card, wired to a
client-built ESP32-P4 manifest + the immutable per-tag BETA archive bin (no
feed change needed), and relabel the existing P4 card (AMOLED). All existing
install-button + download + channel-toggle wiring preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:30:40 +02:00
Kaj SchittecatandClaude Opus 4.8 99423e5bf8 touch: beta_47 — Discover app (find/list/map nearby nodes + wardriving), Attaky release wiring, P4 LCD
- New Discover app: active NODE_DISCOVER sweep -> signal-ranked nearby list, tap-to-add-contact, GPS wardriving log (SD CSV) + signal-coloured coverage overlay on the map. Board-agnostic.
- T-Display P4 TFT-LCD (HI8561) variant support (WADA_P4_LCD build; AMOLED bin untouched).
- Wire the Attaky Core board (#158, @attakygit) into the release matrix + web flasher.

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

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

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

* attaky_mesh_series: Control_Keyboard_(Bottom) dual-AW9523 keyboard

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

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

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

* attaky_mesh_series: use reliable 115200 upload speed

* attaky_mesh_series: use core default GPS baud

* attaky_mesh_series: restore GPS_BAUD setting

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

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

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

TLORA_PAGER_SHORTCUTS.md documents the new chat-screen behavior.

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

* fix scroll

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-10 10:40:25 -06:00
Tesso M Costa 4bbfcb5550 pager: let the rotary encoder scroll an open dropdown's list
updatePagerEncoder() never special-cased an open lv_dropdown, so a
plain turn always sent LV_KEY_NEXT/PREV -- but lv_dropdown's own key
handling only understands LV_KEY_UP/DOWN to move the highlighted row
when its list is open, and ignores NEXT/PREV entirely. Those instead
got consumed by the focus group to move focus off the dropdown, so
turning the encoder never moved the highlight and the list stayed
stuck on whatever was already selected (reported: opens fine, but
can't scroll to a choice, in Radio & Mesh's Community Preset and
other dropdowns).

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 13:59:19 -06:00
Tesso M Costa d59cf5d9ae pager: fix control-center card overflowing the 222px screen
Adding the Sound chip's HAS_UI_SOUND to the pager built put 6 chips
into a row whose width was hardcoded for 5 (a leftover from when the
row only served the Heltec V4), and the chip's gap constant didn't
match the row's own pad_column, so the extra chip wrapped onto a
clipped 2nd line. Chip width is now derived from the actual chip
count instead of a fixed divisor.

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

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

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

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

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-08 11:02:38 -06:00
Tesso M Costa dc4ba96825 stop tracking the M7 hardware debug log, same as the other pager trackers
TLORA_PAGER_M7_HW_DEBUG_LOG.md is a blow-by-blow hardware bring-up log,
not a published project doc -- same treatment already applied to
TLORA_PAGER_PORT.md/TLORA_PAGER_PORT_MILESTONES.md and CLAUDE.md. File
stays on disk locally, just out of version control going forward.

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 16:52:44 -06:00
Tesso M Costa b3c638842e pager: fix LR1121 TX power default and RX-boosted-gain gating
Boot TX power was left at a conservative 10dBm (Heltec V4's pattern) with
no way to raise it, unlike the T-Deck which boots straight at the chip's
real 22dBm ceiling. On hardware this was silently degrading outbound
range: adverts/DMs sent from the pager were unreliable while inbound
reception was fine, since the far node's own TX power was unaffected.
22dBm is confirmed as this chip's actual sub-GHz HP-PA ceiling via
RadioLib's LR1120::checkOutputPower() (LR1121 inherits it) and matches
trail-mate's own working config for this board.

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 15:50:07 -06:00
Tesso M Costa 05b24f01ad pager: fix encoder wake-selects-Skip, and confirm-before-advance on region pick
Two related first-boot-wizard bugs found on hardware:

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

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

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

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

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

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

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 11:21:57 -06:00
Tesso M Costa fe4ed029f5 pager: make rotary long-press a real universal back gesture
Two gaps left "hold to go back" not actually working anywhere but inside
a popup:

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

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

User-verified fixed on hardware.

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

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

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

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

User-verified fixed on hardware.

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

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

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

User-verified fixed on hardware.

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

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

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

User-verified fixed on hardware.

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 10:25:10 -06:00
Tesso M Costa d4ca18335e pager: keep home TX/RX chart clear of the right-hand button column
makeHome()'s chart_w gate only widened the chart's cleared strip for
HAS_TDECK_GT911/HAS_TANMATSU, so the pager (landscape, same right-hand
Advert/Terminal/Apps/Control column) fell into the full-width else branch
and the chart border/Sig chip ran under the buttons. Add TLORA_PAGER to
the gate, matching the other landscape boards.

User-verified fixed on hardware.

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-07 09:44:36 -06:00
Tesso M Costa 641700fad0 touch: wire T-LoRa Pager into UITask (M6)
Adds the pager as a first-class non-touch UI target: new device_caps.h
block (no touch, hw keyboard + rotary-encoder nav, 480x222, GPS/OTA/lock
screen; SD/filesystem left off since the mount code is still hardcoded to
HAS_TDECK_GT911, not genuinely CAP_SD-generic), KEYPAD indev registration
reusing the Tanmatsu keypad plumbing, forced 480x222 landscape rotation,
keyboard/encoder input drain in the main loop, and AW9364-correct
brightness wiring ahead of the existing PWM backlight branch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-06 13:43:24 -06:00
175 changed files with 26078 additions and 859 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+73 -2
View File
@@ -73,6 +73,11 @@
.mini{background:#16191d; border-radius:12px; padding:7px; border:1px solid #e9ece7}
.mini img{display:block; width:100%; height:auto; border-radius:6px}
.mini .cap{margin-top:6px}
.webgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(178px,1fr)); gap:18px; margin-top:22px}
figure.phone{margin:0; background:#16191d; border-radius:20px; padding:8px; border:1px solid #e9ece7; box-shadow:0 18px 44px -26px rgba(21,24,30,.5)}
figure.phone img{display:block; width:100%; height:auto; border-radius:13px}
figure.phone figcaption{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.66rem; color:var(--mut); margin-top:8px; letter-spacing:.04em}
.subh{font-size:1.06rem; font-weight:700; color:var(--ink); margin:30px 0 2px}
/* Settings-page detail cards: real screenshot + what the page does + its controls */
.sgrp{font-family:'JetBrains Mono',monospace; font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); margin:30px 0 0; padding-top:4px}
@@ -126,6 +131,8 @@
<a href="#settings">Settings</a>
<div class="grp">With your phone</div>
<a href="#companion">Companion mode</a>
<a href="#remote">Remote &amp; web app</a>
<a href="#web">Web browser</a>
<div class="grp">Reference</div>
<a href="#updates">Updates &amp; test builds</a>
<a href="#settings-pages">Settings pages</a>
@@ -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 &amp; web app</h2>
<div class="row">
<div class="prose">
<p>Over Wi-Fi, a WADAMESH device can put its screen &mdash; or a whole browser app &mdash; on your phone or laptop. There is nothing to install: the device serves a web page you open in any browser on the same network. Turn it on from <b>Home &rarr; Remote</b> (or the Remote screen in Settings), pick a mode, and open the address it shows.</p>
<h3>Three ways to connect</h3>
<ul>
<li><b>Remote app</b> (recommended) &mdash; a full control panel, native to the browser: <b>Chats</b>, <b>Contacts</b>, a <b>Terminal</b> and <b>Settings</b>, with live message notifications. Run the device entirely from your phone.</li>
<li><b>Screen mirror (VNC)</b> &mdash; streams the device's live screen to the browser and sends your taps and key presses back, so you see and drive exactly what is on the device, in real time.</li>
<li><b>Remote UI</b> &mdash; renders the interface off-screen at desktop size (a wide landscape layout) and streams that instead of the small panel, for a roomier view on a big screen.</li>
</ul>
<p>Screen mirror and Remote UI both show the same interface you already know from the device &mdash; mirrored, or drawn larger. The <b>Remote app</b> is different: a browser-native panel built for a phone, shown below.</p>
<h3>Turning it on</h3>
<p>Connect the device to Wi-Fi first, then open <b>Home &rarr; Remote</b>. The screen shows the address to type into your browser (like <code>http://192.168.1.42</code>) and a switch for each mode. Only one streaming mode runs at a time, and the phone and the device must be on the same Wi-Fi network.</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/app_remote.png" alt="Remote access screen on the device"></div><div class="cap">Remote &mdash; turn it on, then open the address</div></div>
</div>
<div class="subh">The remote app in a browser</div>
<div class="prose" style="max-width:70ch"><p>The remote app is phone-first. It carries the same status icons as the device (signal, Wi-Fi, Bluetooth, battery), pops up a notification when a message arrives, and refreshes an open chat on its own as replies come in &mdash; so you rarely need to touch the device itself.</p></div>
<div class="webgrid">
<figure class="phone"><img src="docs-img/web_chats.png" alt="Chats tab in the web app"><figcaption>Chats &mdash; DMs, channels, unread badges</figcaption></figure>
<figure class="phone"><img src="docs-img/web_chat.png" alt="A conversation in the web app"><figcaption>A conversation, with delivery ticks</figcaption></figure>
<figure class="phone"><img src="docs-img/web_contacts.png" alt="Contacts tab in the web app"><figcaption>Contacts &mdash; search, sort, filter</figcaption></figure>
<figure class="phone"><img src="docs-img/web_contact_sheet.png" alt="Contact actions in the web app"><figcaption>Tap a contact for actions</figcaption></figure>
<figure class="phone"><img src="docs-img/web_discovered.png" alt="Discovered nodes in the web app"><figcaption>Discovered nodes &mdash; add with a tap</figcaption></figure>
<figure class="phone"><img src="docs-img/web_settings.png" alt="Settings in the web app"><figcaption>Settings &mdash; including live radio</figcaption></figure>
<figure class="phone"><img src="docs-img/web_terminal.png" alt="Terminal in the web app"><figcaption>Terminal &mdash; the MeshCore CLI</figcaption></figure>
</div>
</section>
<section class="doc" id="web">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span> Web browser</h2>
<div class="row">
<div class="prose">
<p>WADAMESH can read the web on its own screen. Open the <b>Web</b> app from the home launcher, type an address, and the device fetches the page and shows it as plain, readable text &mdash; articles, wikis, docs, simple sites. There is no proxy in the middle and nothing is tracked: it pulls the page straight from the site and strips it down to the words.</p>
<ul>
<li><b>Text only</b> &mdash; no JavaScript, no images, no ads. Pages load small and read clean.</li>
<li><b>Tappable links</b> &mdash; follow a link to the next page, with <b>back</b>, <b>forward</b> and <b>refresh</b> in the toolbar.</li>
<li><b>Direct &amp; private</b> &mdash; the device connects to the site itself over Wi-Fi (HTTPS); no server of ours sees what you read.</li>
</ul>
<p>Reading the web on-device takes more memory than the smallest board carries, so the Web app is on the <b>T-Deck</b> and <b>Tanmatsu</b>. On the 2&nbsp;MB Heltec&nbsp;V4 it is hidden &mdash; that board can't complete the secure handshake &mdash; but you can still bounce any link to your phone (below).</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/app_web.png" alt="The on-device Web app"></div><div class="cap">Web &mdash; open an address, read it as text</div></div>
</div>
<div class="subh">Links in chat</div>
<div class="prose" style="max-width:70ch"><p>A link that arrives in a message is now tappable. Tap it for a small menu: <b>Open in web</b> loads the page in the on-device browser (T-Deck and Tanmatsu), and <b>Create QR</b> puts a scannable QR code on screen so you can open the link on your phone. Create QR works on <em>every</em> board, including the Heltec&nbsp;V4 &mdash; so even where the browser isn't available, a link is one tap from your phone.</p></div>
<div class="gallery">
<div class="mini"><img src="docs-img/app_web_links.png" alt="Tapping a link in chat opens a menu"><div class="cap">Tap a link &mdash; Open in web, or Create QR</div></div>
<div class="mini"><img src="docs-img/app_web_qr.png" alt="A QR code for a link from chat"><div class="cap">Create QR &mdash; scan to open on your phone</div></div>
</div>
</section>
<section class="doc" id="updates">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span> Updates &amp; test builds</h2>
<div class="row">
@@ -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 &amp; 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> &mdash; anyone who can read the broker sees the messages, so use one you control, never a public broker</li>
</ul>
</div>
</div>
<div class="sgrp">Time &amp; power</div>
+205 -16
View File
@@ -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,22 +389,102 @@
<div class="step"><div class="num">2</div><div><h4>Pick your board &amp; 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>ThinkNode M9</h4><p>ESP32-S3 QWERTY handheld (LR1110).</p>
<esp-web-install-button id="inst-m9" manifest="https://firmware.wadamesh.com/latest-beta/manifest-thinknode-m9.json"></esp-web-install-button>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board"><h4>RAK WisMesh Tap V2</h4><p>ESP32-S3 touch node.</p>
<esp-web-install-button id="inst-rak" manifest="https://firmware.wadamesh.com/latest-beta/manifest-rak-tap-v2.json"></esp-web-install-button>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board: beta channel only until the next stable.</p></div>
<div class="board"><h4>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">&times;</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 &middot; 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 &middot; 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 &mdash; 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 &middot; 8&nbsp;MB PSRAM &middot; SD card.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. This revision is still being hardware-verified — flash with care and report back.</span></span>
<esp-web-install-button id="inst-v4r8" manifest="https://firmware.wadamesh.com/latest-beta/manifest-heltec-v4-r8-tft.json"></esp-web-install-button>
<a class="dl2" data-dl-board="heltec-v4-r8-tft" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-heltec-v4-r8-tft-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board, not yet hardware-tested &mdash; beta only. Please flash with care and report back.</p></div>
</div>
</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 &middot; 16&nbsp;MB &middot; 8&nbsp;MB PSRAM.</p>
<span class="sup part" tabindex="0"><span class="dot"></span>Partially supported<span class="pop"><b>Partially supported</b>The core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. This community-contributed board (PR #158) isn't hardware-verified by us yet — flash with care and report back.</span></span>
<esp-web-install-button id="inst-attaky" manifest="https://firmware.wadamesh.com/latest-beta/manifest-attaky.json"></esp-web-install-button>
<a class="dl2" data-dl-board="attaky" data-dl-betaonly download href="https://firmware.wadamesh.com/latest-beta/wadamesh-attaky-merged.bin"><span class="dl2-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>Download .bin</span><span class="dlver" data-dl-ver></span></a>
<p data-wm-betaonly style="color:var(--mut);font-size:.78rem;margin:6px 0 0">New board, not yet hardware-tested &mdash; beta only. Please flash with care and report back.</p></div>
</div>
</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>
@@ -377,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&nbsp;T-Deck only.</b> On the Heltec&nbsp;V4, install <a href="#standalone" onclick="choose('standalone');return false">Standalone</a> instead.</p>
<div class="chtoggle" role="group" aria-label="Release channel" style="display:inline-flex;margin:14px 0 0;border:1px solid var(--line);border-radius:10px;overflow:hidden">
<button type="button" data-wm-ch="stable" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;background:#fff;color:var(--mut);cursor:pointer">Stable</button>
<button type="button" data-wm-ch="beta" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;border-left:1px solid var(--line);background:#fff;color:var(--mut);cursor:pointer">Beta · testing</button>
</div>
<p style="margin:8px auto 0;max-width:560px;font-size:.84rem;color:var(--mut)"><b>Stable</b> is the tested default; <b>Beta</b> gets new builds earlier. The download below follows this choice.</p>
<div class="steps" style="margin-top:22px">
<div class="step"><div class="num">1</div><div><h4>Install Launcher first</h4><p>Flash bmorcelli's Launcher to your device, following its own installer — <a href="https://bmorcelli.github.io/Launcher/" target="_blank" rel="noopener">bmorcelli.github.io/Launcher</a>.</p></div></div>
<div class="step"><div class="num">2</div><div><h4>Add the WADAMESH app</h4><p>Grab the WADAMESH <b>app image</b> for your board below, then install it from Launcher (via its web UI / OTA, or copy the <code>.bin</code> to your SD card).</p></div></div>
@@ -384,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>
@@ -545,19 +675,78 @@ 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>' },
+5 -4
View File
@@ -52,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
+678 -12
View File
@@ -213,6 +213,252 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
; ======================================================================
; Heltec WiFi LoRa 32 V4-R8 (ESP32-S3R8, 8 MB OCTAL PSRAM) + Expansion Kit V2.
; Inherits the V4 TFT env and overrides ONLY the R8 deltas. The 8 MB octal PSRAM
; claims GPIO33-37, so Heltec moved every control signal off them and the V2
; expansion put the TFT on a hardware-SPI bus SHARED with a new micro-SD slot:
; Vext 36->40 (now active-LOW) · GPS_EN 34->42 · LED 35->46 · ADC_Ctrl removed
; TFT (LovyanGFX HW-SPI): MOSI=15 SCK=16 MISO=45 CS=47 DC=48 RST=21 BL=44
; touch (CHSC6x, shares board I2C on 17/18) · micro-SD (shared TFT SPI, CS=3)
; The build defines HELTEC_LORA_V4_TFT too, so it reuses ALL the V4 touch-UI code;
; HELTEC_LORA_V4_R8 gates only the deltas. USB stays TinyUSB-CDC (USB_MODE=0) like
; the V4 — HW-CDC regresses the companion link on this family (see the V4 note).
; UNTESTED (no hardware yet) — pin sources: Meshtastic heltec_v4_r8 variant +
; Heltec V4-R8 pinmap + Expansion_board_V2.03 schematic. On-device TODO: confirm
; the CHSC6x shares Wire on 17/18, the SD_CS=3 read, and the V2 PAM8904 buzzer.
; ======================================================================
[env:heltec_v4_r8_tft_companion_radio_usb_tcp_touch]
extends = env:heltec_v4_tft_companion_radio_usb_tcp_touch
board = heltec_v4_r8
build_flags =
${env:heltec_v4_tft_companion_radio_usb_tcp_touch.build_flags}
; --- V4-R8 deltas (later -D wins on the GCC command line) ---
-D HELTEC_LORA_V4_R8
-D P_LORA_TX_LED=46
-D PIN_VEXT_EN=40
-D PIN_VEXT_EN_ACTIVE=LOW
-D PIN_GPS_EN=42
-D PIN_GPS_RESET=-1
-D PIN_ADC_CTRL=-1
-D ADC_MULTIPLIER=5.07
; TFT on hardware SPI (LovyanGFX), shared with the micro-SD
-D DISPLAY_CLASS=LGFXDisplay
-D PIN_TFT_RST=21
-D PIN_TFT_LEDA_CTL=44
-D PIN_TFT_CS=47
-D PIN_TFT_DC=48
-D PIN_TFT_SCL=16
-D PIN_TFT_SDA=15
-D PIN_TFT_MISO=45
; TFT_eSPI mirror (kept in sync though LovyanGFX drives the panel)
-D TFT_MOSI=15
-D TFT_SCLK=16
-D TFT_MISO=45
-D TFT_CS=47
-D TFT_DC=48
-D TFT_RST=21
-D TFT_BL=44
; touch (CHSC6x) + board I2C unified on GPIO17/18; micro-SD chip-select
-D PIN_TOUCH_SDA=17
-D PIN_TOUCH_SCL=18
; RST=-1 ON PURPOSE (tester repro fix): the Expansion V2 wires the CHSC6x reset to GPIO21 — the
; SAME line as the ST7789 reset. The chsc6x driver hard-pulses its RST at init (+3 retry sites),
; which RESET the LCD mid-boot and wiped its init: logo OK -> screen black -> white/garbled rows
; on any redraw. The LGFX display init already resets both chips via pin 21; the touch driver must
; never drive it (-1 = every pulse in chsc6x_platform no-ops).
-D PIN_TOUCH_RST=-1
-D PIN_TOUCH_INT=43
-D PIN_BOARD_SDA=17
-D PIN_BOARD_SCL=18
-D PIN_SD_CS=3
-D HELTEC_V4_BUZZER_PIN=-1
-D FIRMWARE_OTA_ENV='"heltec_v4_r8_tft_companion_radio_usb_tcp_touch"'
lib_deps =
${env:heltec_v4_tft_companion_radio_usb_tcp_touch.lib_deps}
lovyan03/LovyanGFX@1.2.21
; ======================================================================
; Attaky Mesh Series: `pio run -e attaky_mesh_series_companion_radio_touch`.
; ======================================================================
[env:attaky_mesh_series_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
board = attaky_mesh_series
monitor_speed = 115200
board_build.partitions = partitions_tft_touch.csv
extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py
build_flags =
-Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
-D LORA_FREQ=910.525
-D LORA_BW=250
-D LORA_SF=7
-D ENABLE_PRIVATE_KEY_IMPORT=1
-D ENABLE_PRIVATE_KEY_EXPORT=1
-D RADIOLIB_EXCLUDE_CC1101=1
-D RADIOLIB_EXCLUDE_RF69=1
-D RADIOLIB_EXCLUDE_SX1231=1
-D RADIOLIB_EXCLUDE_SI443X=1
-D RADIOLIB_EXCLUDE_RFM2X=1
-D RADIOLIB_EXCLUDE_SX128X=1
-D RADIOLIB_EXCLUDE_AFSK=1
-D RADIOLIB_EXCLUDE_AX25=1
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
-D RADIOLIB_EXCLUDE_MORSE=1
-D RADIOLIB_EXCLUDE_APRS=1
-D RADIOLIB_EXCLUDE_BELL=1
-D RADIOLIB_EXCLUDE_RTTY=1
-D RADIOLIB_EXCLUDE_SSTV=1
-D ESP32_PLATFORM
-D ENV_INCLUDE_GPS=1
; 0x38 is the FT6636 touch controller, not an AHT10/20. The Adafruit probe
; mis-detects it as a sensor and can hang boot on its register 0xBA, so keep off.
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=1
-D ENV_INCLUDE_BMP280=1
-D ENV_INCLUDE_SHTC3=1
-D ENV_INCLUDE_SHT4X=1
-D ENV_INCLUDE_LPS22HB=1
-D ENV_INCLUDE_INA3221=1
-D ENV_INCLUDE_INA219=1
-D ENV_INCLUDE_INA226=1
-D ENV_INCLUDE_INA260=1
; 0x5A is the keyboard module's AW9523 (left half), not an MLX90614.
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=1
-D ENV_INCLUDE_BME680=1
-D ENV_INCLUDE_BMP085=1
-I variants/attaky_mesh_series
-D ATTAKY_MESH_SERIES
-D HAS_ATTAKY_MESH_KEYBOARD=1
-D USE_SX1262
-D ESP32_CPU_FREQ=80
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D P_LORA_DIO_1=21
-D P_LORA_NSS=8
-D P_LORA_RESET=9
-D P_LORA_BUSY=16
-D P_LORA_SCLK=5
-D P_LORA_MISO=7
-D P_LORA_MOSI=6
-D PIN_USER_BTN=0
-D LORA_TX_POWER=10
-D MAX_LORA_TX_POWER=22
-D SX126X_REGISTER_PATCH=1
-D SX126X_DIO2_AS_RF_SWITCH=true
; Ra-01SH clocks the SX1262 from a 32 MHz crystal, not a TCXO, so DIO3 is not a
; TCXO supply. Must be 0 explicitly: std_init() defaults to 1.6 V otherwise.
-D SX126X_DIO3_TCXO_VOLTAGE=0
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D PIN_GPS_RX=18
-D PIN_GPS_TX=17
; EnvironmentSensorManager reads GPS_BAUD_RATE, not GPS_BAUD.
-D GPS_BAUD_RATE=9600
; ATGM336H cold-start can exceed the 1 s probe window. Without this the probe
; gives up and start_gps() never runs, so GPS reads on but nothing is parsed.
-D ENV_SKIP_GPS_DETECT=1
; Keep the base rotation portrait; UITask applies the landscape transform.
-D PIN_BOARD_SDA=2
-D PIN_BOARD_SCL=1
-D DISPLAY_ROTATION=2
-D DISPLAY_SCALE_X=1.0
-D DISPLAY_SCALE_Y=1.0
-D PIN_TFT_RST=10
-D PIN_TFT_VDD_CTL=-1
-D PIN_TFT_LEDA_CTL=14
-D PIN_TFT_LEDA_CTL_ACTIVE=HIGH
-D PIN_TFT_CS=45
-D PIN_TFT_DC=12
-D PIN_TFT_SCL=11
-D PIN_TFT_SDA=13
-I include
-I src
-I src/ui-touch
-D UI_LVGL=1
-D HAS_TOUCH_UI=1
-D FIRMWARE_OTA_ENV='"attaky_mesh_series_companion_radio_touch"'
-D ENABLE_ADVERT_ON_BOOT=0
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
-D LV_INDEV_DEF_SCROLL_THROW=7
-D USER_SETUP_LOADED=1
-D ST7789_DRIVER=1
-D TFT_WIDTH=240
-D TFT_HEIGHT=320
-D TFT_MOSI=13
-D TFT_SCLK=11
-D TFT_CS=45
-D TFT_DC=12
-D TFT_RST=10
-D TFT_MISO=-1
-D SPI_FREQUENCY=80000000
-D TFT_RGB_ORDER=TFT_RGB
-D TFT_INVERSION_ON=1
-D TFT_BL=14
-D TFT_BACKLIGHT_ON=1
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=ST7789LCDDisplay
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
-D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1
-D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
-D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
; The Attaky driver provides the shared touch API.
-<helpers/input/TDeckTouch.cpp>
+<ui-touch/*.cpp>
+<ui-touch/*.c>
+<../variants/attaky_mesh_series/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5
SPI
Wire
jgromes/RadioLib @ ^7.6.0
rweather/Crypto @ ^0.4.0
adafruit/RTClib @ ^2.1.3
melopero/Melopero RV3028 @ ^1.1.0
electroniccats/CayenneLPP @ 1.6.1
h2zero/NimBLE-Arduino @ 1.4.3
adafruit/Adafruit INA3221 Library @ ^1.0.1
adafruit/Adafruit INA219 @ ^1.2.3
robtillaart/INA226 @ ^0.6.4
adafruit/Adafruit INA260 Library @ ^1.5.3
adafruit/Adafruit AHTX0 @ ^2.0.5
adafruit/Adafruit BME280 Library @ ^2.3.0
adafruit/Adafruit BMP280 Library @ ^2.6.8
adafruit/Adafruit SHTC3 Library @ ^1.0.1
sensirion/Sensirion I2C SHT4x @ ^1.1.2
arduino-libraries/Arduino_LPS22HB @ ^1.0.2
adafruit/Adafruit MLX90614 Library @ ^2.1.5
adafruit/Adafruit_VL53L0X @ ^1.2.4
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME680 Library @ ^2.0.4
adafruit/Adafruit BMP085 Library @ ^1.2.4
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
ESP32Async/ESPAsyncWebServer @ 3.10.3
file://arch/esp32/AsyncElegantOTA
lvgl/lvgl @ ^8.3.11
bodmer/TFT_eSPI @ ^2.5.43
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
; ======================================================================
; LilyGo T-Deck / T-Deck Plus — flattened equivalent of meshcomod's
; LilyGo_TDeck_companion_radio_touch. ST7789 via Adafruit (not TFT_eSPI);
@@ -383,18 +629,438 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
; ======================================================================
; Elecrow ThinkNode M9 — ESP32-S3R8 / LR1110 / 2.4" 240x320 ST7789 / QWERTY
; keyboard + d-pad (no touchscreen). Pin map verified against the V1.0
; schematic + Meshtastic boot log during Specter (ESP-IDF) bring-up; the
; panel IC, TCXO voltage, battery divider, and LR1110 RF-switch DIO mapping
; below are all schematic/manufacturer-confirmed (NOT the earlier ILI9341 /
; 2:1-divider / unverified-RF-switch state from a prior attempt). See
; variants/thinknode_m9/M9_PORT.md for the full pin table and the remaining
; hardware-verify checklist (GPS EN polarity/baud, display rotation, RF-switch
; truth-table polarity, SD mount-timing margins) — those items are
; deliberately left unset/unconfirmed below.
; ======================================================================
[env:tlora_pager_lr1121_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
board = lilygo-t-lora-pager
monitor_speed = 115200
board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv
extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py
build_flags =
-Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
-D LORA_FREQ=869.618
-D LORA_BW=62.5
-D LORA_SF=8
-D ENABLE_PRIVATE_KEY_IMPORT=1
-D ENABLE_PRIVATE_KEY_EXPORT=1
-D RADIOLIB_EXCLUDE_CC1101=1
-D RADIOLIB_EXCLUDE_RF69=1
-D RADIOLIB_EXCLUDE_SX1231=1
-D RADIOLIB_EXCLUDE_SI443X=1
-D RADIOLIB_EXCLUDE_RFM2X=1
-D RADIOLIB_EXCLUDE_SX128X=1
-D RADIOLIB_EXCLUDE_AFSK=1
-D RADIOLIB_EXCLUDE_AX25=1
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
-D RADIOLIB_EXCLUDE_MORSE=1
-D RADIOLIB_EXCLUDE_APRS=1
-D RADIOLIB_EXCLUDE_BELL=1
-D RADIOLIB_EXCLUDE_RTTY=1
-D RADIOLIB_EXCLUDE_SSTV=1
; LR11X0 stays IN (that's our radio family); SX126X isn't used by this env at all.
-D RADIOLIB_EXCLUDE_SX126X=1
-D ESP32_PLATFORM
; --- T-LoRa Pager board ---
-I variants/lilygo_tlora_pager
-D TLORA_PAGER=1
-D BOARD_HAS_PSRAM=1
; 0 = silence ARDUHAL [E] spam, same rationale as the other two envs.
-D CORE_DEBUG_LEVEL=0
-D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0
-D USE_LR1121=1
-D RADIO_CLASS=CustomLR1121
-D WRAPPER_CLASS=CustomLR1121Wrapper
; Full sub-GHz HP-PA power on boot, matching the T-Deck's own LORA_TX_POWER=22
; (not Heltec V4's conservative-10-then-raise-in-Settings pattern). +22dBm is
; this chip's real ceiling for freq < 1GHz -- confirmed against RadioLib's
; pinned LR1120::checkOutputPower() (LR1121 inherits it: HP PA auto-selects
; above 14dBm, valid range -9..22) and trail-mate's own
; TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 for this exact board -- not a guess
; carried over from the sibling boards. A weak default here (10dBm, ~16x less
; power) was silently degrading outbound range on real hardware: adverts/DMs
; sent FROM the pager were unreliable while inbound reception was fine, since
; the far node's own TX was unaffected.
-D LORA_TX_POWER=22
-D P_LORA_DIO_1=14
-D P_LORA_NSS=36
-D P_LORA_RESET=47
-D P_LORA_BUSY=48
-D P_LORA_SCLK=35
-D P_LORA_MISO=33
-D P_LORA_MOSI=34
-D ENV_INCLUDE_GPS=1
-D ENV_SKIP_GPS_DETECT=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D DISPLAY_SCALE_X=1.0
-D DISPLAY_SCALE_Y=1.0
; AW9364 backlight enable/pulse pin (ST7796LCDDisplay owns brightness -- see
; the CGRAM_OFFSET comment below for why this isn't a plain PWM pin).
-D PIN_TFT_LEDA_CTL=42
; Swapped vs. trail-mate's raw GPS_RX=4/GPS_TX=12: wadamesh's core calls
; Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX), and HardwareSerial::setPins()
; takes (rxPin, txPin) -- so PIN_GPS_TX must be the ESP's own RX pin (4) and
; PIN_GPS_RX the ESP's own TX pin (12). See TLORA_PAGER_PORT.md Risk 1c.
-D PIN_GPS_RX=12
-D PIN_GPS_TX=4
-D GPS_BAUD_RATE=38400
; --- touch UI / companion stack ---
-I include
-I src
-I src/ui-touch
-D UI_LVGL=1
-D HAS_TOUCH_UI=1
-D FIRMWARE_OTA_ENV='"tlora_pager_lr1121_companion_radio_touch"'
-D HAS_PAGER_KEYBOARD=1
-D HAS_PAGER_ENCODER=1
-D KB_INT=6
-D KB_BACKLIGHT=46
-D ROTARY_A=40
-D ROTARY_B=41
-D ROTARY_C=7
; ES8311 codec I2S pins (NS4150B amp downstream, enabled via XL9555
; AMP_EN -- see TLoraPagerBoard::setAmpEnabled()). Confirmed against the
; canonical arduino-esp32 pins_arduino.h for this board -- see
; TLORA_PAGER_PORT.md's Audio row.
-D PIN_I2S_MCLK=10
-D PIN_I2S_BCK=11
-D PIN_I2S_WS=18
-D PIN_I2S_DOUT=45
-D PIN_I2S_SDIN=17
-D ENABLE_ADVERT_ON_BOOT=0
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide
; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins.
-D LV_INDEV_DEF_SCROLL_THROW=7
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=ST7796LCDDisplay
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
-D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1
-D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
-D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
; --- ST7796 display (TFT_eSPI) ---
-D USER_SETUP_LOADED=1
-D ST7796_DRIVER=1
; REQUIRED: this panel's 222px glass is narrower than the ST7796 controller's
; 320px GRAM. TFT_eSPI's ST7796_Rotation.h only applies the needed 49px
; column/row offset when this is defined (unlike ST7789, it isn't automatic
; for ST7796) -- ST7796LCDDisplay.cpp #errors at compile time without it.
-D CGRAM_OFFSET=1
; REQUIRED on ESP32-S3: TFT_eSPI_ESP32_S3.h's raw register macros (_spi_user,
; _spi_cmd, _spi_mosi_dlen -- used by begin_tft_write()/TFT_WRITE_BITS) take
; SPI_PORT and feed it straight into IDF's SPI_USER_REG(i)/REG_SPI_BASE(i),
; which only resolves to a real peripheral for i>=2 (SPI2/SPI3); i<2 returns
; base address 0. Leaving both USE_HSPI_PORT/USE_FSPI_PORT undefined makes
; that file fall back to SPI_PORT=FSPI, but FSPI is the *Arduino* enum value
; (0 on S3) rather than the raw IDF host index TFT_eSPI's own macros need --
; so _spi_user resolves to address 0x10, and the very first writecommand()
; in ST7796LCDDisplay::begin()/TFT_eSPI::init() hard-crashes with a
; StoreProhibited panic (confirmed on hardware: black screen, boot-looping
; "Guru Meditation Error" at TFT_eSPI::begin_tft_write()). USE_FSPI_PORT
; forces SPI_PORT=2 (real SPI2/"GPSPI2") consistently across both the raw
; macros and the SPIClass(FSPI) object TFT_eSPI constructs internally.
; USE_HSPI_PORT (real SPI3) is deliberately NOT used here instead: the
; radio's own `SPIClass spi` (variants/lilygo_tlora_pager/target.cpp) takes
; its default ctor arg (HSPI), which the Arduino core also maps to real
; SPI3 -- so picking HSPI for the display would put both peripherals on the
; same physical SPI host and conflict.
-D USE_FSPI_PORT=1
-D TFT_WIDTH=222
-D TFT_HEIGHT=480
-D TFT_MOSI=34
-D TFT_SCLK=35
-D TFT_CS=38
-D TFT_DC=37
-D TFT_RST=-1
-D TFT_MISO=33
-D TFT_BL=-1
; trail-mate's kPagerDisplaySpiClockMhz = 80.
-D SPI_FREQUENCY=80000000
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
+<helpers/ui/*.cpp>
+<ui-touch/*.cpp>
+<ui-touch/*.c>
+<../variants/lilygo_tlora_pager/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5
SPI
Wire
jgromes/RadioLib @ ^7.6.0
rweather/Crypto @ ^0.4.0
adafruit/RTClib @ ^2.1.3
melopero/Melopero RV3028 @ ^1.1.0
electroniccats/CayenneLPP @ 1.6.1
h2zero/NimBLE-Arduino @ 1.4.3
adafruit/Adafruit INA3221 Library @ ^1.0.1
adafruit/Adafruit INA219 @ ^1.2.3
robtillaart/INA226 @ ^0.6.4
adafruit/Adafruit INA260 Library @ ^1.5.3
adafruit/Adafruit AHTX0 @ ^2.0.5
adafruit/Adafruit BME280 Library @ ^2.3.0
adafruit/Adafruit BMP280 Library @ ^2.6.8
adafruit/Adafruit SHTC3 Library @ ^1.0.1
sensirion/Sensirion I2C SHT4x @ ^1.1.2
arduino-libraries/Arduino_LPS22HB @ ^1.0.2
adafruit/Adafruit MLX90614 Library @ ^2.1.5
adafruit/Adafruit_VL53L0X @ ^1.2.4
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME680 Library @ ^2.0.4
adafruit/Adafruit BMP085 Library @ ^1.2.4
ESP32Async/ESPAsyncWebServer @ 3.10.3
file://arch/esp32/AsyncElegantOTA
lvgl/lvgl @ ^8.3.11
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
bodmer/TFT_eSPI @ ^2.5.43
adafruit/Adafruit TCA8418 @ ^1.0.2
lewisxhe/SensorLib @ 0.3.3
; Milestone 10: SX1262-variant env for pager units sold with that radio instead
; of LR1121 -- same board JSON, same variant dir, same physical NSS/RESET/BUSY/
; DIO1/SCLK/MISO/MOSI pins (same PCB slot, just a different chip populated —
; see TLORA_PAGER_PORT.md's hardware-facts table). RADIO_CLASS/WRAPPER_CLASS
; swap to the core fork's CustomSX1262/CustomSX1262Wrapper, the exact same
; generic classes the T-Deck already uses -- see target.cpp's radio_init()
; for why no new radio C++ code was needed, just these build flags.
[env:tlora_pager_sx1262_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
board = lilygo-t-lora-pager
monitor_speed = 115200
board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv
extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py
build_flags =
-Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
-D LORA_FREQ=869.618
-D LORA_BW=62.5
-D LORA_SF=8
-D ENABLE_PRIVATE_KEY_IMPORT=1
-D ENABLE_PRIVATE_KEY_EXPORT=1
-D RADIOLIB_EXCLUDE_CC1101=1
-D RADIOLIB_EXCLUDE_RF69=1
-D RADIOLIB_EXCLUDE_SX1231=1
-D RADIOLIB_EXCLUDE_SI443X=1
-D RADIOLIB_EXCLUDE_RFM2X=1
-D RADIOLIB_EXCLUDE_SX128X=1
-D RADIOLIB_EXCLUDE_AFSK=1
-D RADIOLIB_EXCLUDE_AX25=1
-D RADIOLIB_EXCLUDE_HELLSCHREIBER=1
-D RADIOLIB_EXCLUDE_MORSE=1
-D RADIOLIB_EXCLUDE_APRS=1
-D RADIOLIB_EXCLUDE_BELL=1
-D RADIOLIB_EXCLUDE_RTTY=1
-D RADIOLIB_EXCLUDE_SSTV=1
; SX126X stays IN (that's this env's radio family); LR11X0 isn't used at all.
-D RADIOLIB_EXCLUDE_LR11X0=1
-D ESP32_PLATFORM
; --- T-LoRa Pager board (SX1262 variant) ---
-I variants/lilygo_tlora_pager
-D TLORA_PAGER=1
-D BOARD_HAS_PSRAM=1
; 0 = silence ARDUHAL [E] spam, same rationale as the other envs.
-D CORE_DEBUG_LEVEL=0
-D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0
-D USE_SX1262=1
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
; Same 22dBm ceiling as the LR1121 env -- trail-mate's own reference for this
; exact board (~/dev/trail-mate/boards/tlora_pager) uses the identical
; TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 for BOTH its LR1121 and SX1262 envs, and
; it's this chip's well-known real ceiling too.
-D LORA_TX_POWER=22
; SX126X_DIO2_AS_RF_SWITCH=true and SX126X_CURRENT_LIMIT=140 are carried over
; from the T-Deck's own SX1262 config -- NOT because this is the T-Deck, but
; because both are tied to the 22dBm target above, not to board-specific analog
; circuitry: RadioLib's own SX126x.cpp already defaults DIO2-as-switch to true
; internally (same value, just made explicit here), and its default current
; limit (60mA) is what silently capped the T-Deck's real TX power below its
; target before that env raised it to 140mA -- the same physics apply to any
; SX1262 module asked to reach 22dBm, not something unique to the T-Deck's PCB.
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
; Deliberately NOT setting SX126X_DIO3_TCXO_VOLTAGE (unlike the T-Deck's
; 1.8f) -- that IS a board-specific analog value (this board's actual TCXO
; circuit), and trail-mate's own working reference for this exact PCB never
; overrides it either, relying on CustomSX1262::std_init()'s built-in 1.6V
; fallback (RadioLib's own SX1262::begin() default) -- which also already
; auto-retries at 0.0f on an SPI-cmd failure if 1.6V doesn't suit this board's
; real hardware. Revisit with a measured value if bring-up shows otherwise.
-D P_LORA_DIO_1=14
-D P_LORA_NSS=36
-D P_LORA_RESET=47
-D P_LORA_BUSY=48
-D P_LORA_SCLK=35
-D P_LORA_MISO=33
-D P_LORA_MOSI=34
-D ENV_INCLUDE_GPS=1
-D ENV_SKIP_GPS_DETECT=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D DISPLAY_SCALE_X=1.0
-D DISPLAY_SCALE_Y=1.0
; AW9364 backlight enable/pulse pin (ST7796LCDDisplay owns brightness -- see
; the CGRAM_OFFSET comment below for why this isn't a plain PWM pin).
-D PIN_TFT_LEDA_CTL=42
; Swapped vs. trail-mate's raw GPS_RX=4/GPS_TX=12: wadamesh's core calls
; Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX), and HardwareSerial::setPins()
; takes (rxPin, txPin) -- so PIN_GPS_TX must be the ESP's own RX pin (4) and
; PIN_GPS_RX the ESP's own TX pin (12). See TLORA_PAGER_PORT.md Risk 1c.
-D PIN_GPS_RX=12
-D PIN_GPS_TX=4
-D GPS_BAUD_RATE=38400
; --- touch UI / companion stack ---
-I include
-I src
-I src/ui-touch
-D UI_LVGL=1
-D HAS_TOUCH_UI=1
-D FIRMWARE_OTA_ENV='"tlora_pager_sx1262_companion_radio_touch"'
-D HAS_PAGER_KEYBOARD=1
-D HAS_PAGER_ENCODER=1
-D KB_INT=6
-D KB_BACKLIGHT=46
-D ROTARY_A=40
-D ROTARY_B=41
-D ROTARY_C=7
; ES8311 codec I2S pins (NS4150B amp downstream, enabled via XL9555
; AMP_EN -- see TLoraPagerBoard::setAmpEnabled()). Confirmed against the
; canonical arduino-esp32 pins_arduino.h for this board -- see
; TLORA_PAGER_PORT.md's Audio row.
-D PIN_I2S_MCLK=10
-D PIN_I2S_BCK=11
-D PIN_I2S_WS=18
-D PIN_I2S_DOUT=45
-D PIN_I2S_SDIN=17
-D ENABLE_ADVERT_ON_BOOT=0
-D LV_CONF_PATH=lv_conf.h
-D LV_CONF_INCLUDE_SIMPLE=1
; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide
; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins.
-D LV_INDEV_DEF_SCROLL_THROW=7
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=ST7796LCDDisplay
-D ADMIN_PASSWORD='"password"'
-D MULTI_TRANSPORT_COMPANION=1
-D WADAMESH_FORK_BUILD=1
-D TCP_PORT=5000
-D WS_PORT=8765
-D BLE_PIN_CODE=123456
-D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1
-D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
-D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
; --- ST7796 display (TFT_eSPI) ---
-D USER_SETUP_LOADED=1
-D ST7796_DRIVER=1
; REQUIRED: this panel's 222px glass is narrower than the ST7796 controller's
; 320px GRAM. TFT_eSPI's ST7796_Rotation.h only applies the needed 49px
; column/row offset when this is defined (unlike ST7789, it isn't automatic
; for ST7796) -- ST7796LCDDisplay.cpp #errors at compile time without it.
-D CGRAM_OFFSET=1
; REQUIRED on ESP32-S3: see the LR1121 env's identical comment above --
; same display, same panel, same constraint, unrelated to the radio chip.
-D USE_FSPI_PORT=1
-D TFT_WIDTH=222
-D TFT_HEIGHT=480
-D TFT_MOSI=34
-D TFT_SCLK=35
-D TFT_CS=38
-D TFT_DC=37
-D TFT_RST=-1
-D TFT_MISO=33
-D TFT_BL=-1
; trail-mate's kPagerDisplaySpiClockMhz = 80.
-D SPI_FREQUENCY=80000000
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
+<helpers/ui/*.cpp>
+<ui-touch/*.cpp>
+<ui-touch/*.c>
+<../variants/lilygo_tlora_pager/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5
SPI
Wire
jgromes/RadioLib @ ^7.6.0
rweather/Crypto @ ^0.4.0
adafruit/RTClib @ ^2.1.3
melopero/Melopero RV3028 @ ^1.1.0
electroniccats/CayenneLPP @ 1.6.1
h2zero/NimBLE-Arduino @ 1.4.3
adafruit/Adafruit INA3221 Library @ ^1.0.1
adafruit/Adafruit INA219 @ ^1.2.3
robtillaart/INA226 @ ^0.6.4
adafruit/Adafruit INA260 Library @ ^1.5.3
adafruit/Adafruit AHTX0 @ ^2.0.5
adafruit/Adafruit BME280 Library @ ^2.3.0
adafruit/Adafruit BMP280 Library @ ^2.6.8
adafruit/Adafruit SHTC3 Library @ ^1.0.1
sensirion/Sensirion I2C SHT4x @ ^1.1.2
arduino-libraries/Arduino_LPS22HB @ ^1.0.2
adafruit/Adafruit MLX90614 Library @ ^2.1.5
adafruit/Adafruit_VL53L0X @ ^1.2.4
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME680 Library @ ^2.0.4
adafruit/Adafruit BMP085 Library @ ^1.2.4
ESP32Async/ESPAsyncWebServer @ 3.10.3
file://arch/esp32/AsyncElegantOTA
lvgl/lvgl @ ^8.3.11
densaugeo/base64 @ ~1.4.0
knolleary/PubSubClient @ ^2.8.0
bodmer/TFT_eSPI @ ^2.5.43
adafruit/Adafruit TCA8418 @ ^1.0.2
lewisxhe/SensorLib @ 0.3.3
[env:ThinkNode_M9_companion_radio_touch]
platform = platformio/espressif32@6.11.0
framework = arduino
+15
View File
@@ -0,0 +1,15 @@
# beta_44 (TEST) - an on-device text web browser, tappable links in chat, and a French-keyboard spacebar fix.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: on-device web browser ---
New "Web" app: open a URL and read the page as plain text - articles, wikis, docs, plain sites - right on the device. No proxy, no tracking, no JavaScript or images; just the words. Links are tappable, and there are back, forward and refresh buttons.
The Web app needs the memory only the 8 MB boards have (T-Deck, Tanmatsu), so it is hidden on the 2 MB Heltec V4 - that board can't complete an on-device HTTPS handshake.
# --- New: links in chat ---
Links in a chat are now tappable. Tap one for a small menu: "Open in web" loads the page in the on-device browser, and "Create QR" shows a scannable QR code so you can open the link on your phone.
Create QR works on every board, including the Heltec V4 - so even where the browser isn't available you can still bounce a link to your phone.
# --- Fixed ---
French (AZERTY) on-screen keyboard: the spacebar is back in the normal letter view (along with the OK and keyboard-switch keys). You no longer have to switch to the symbols page just to type a space.
Heltec V4: web page fetching and map tiles are more resilient when memory is tight.
Home signal & traffic box: the "tap for details" cue is back at the top of the box.
+29
View File
@@ -0,0 +1,29 @@
# beta_45 (STABLE) — everything since the last stable (beta_40): browser-based remote control, an on-device web browser, three new boards, and a batch of fixes.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: control wadamesh from a browser (over Wi-Fi — no app, account or cloud) ---
New web control panel: run the device from a phone or laptop browser over Wi-Fi, with full Chats, Contacts, Discovered, Terminal and Settings tabs — served by the device itself. Turn on the VNC app, then open http://<device-ip>:8765/ from any browser on the same network.
VNC app: mirrors the live device screen into the browser and lets you drive it — taps and typing in the browser control the device, and anything that changes on the device shows up in the browser.
REMOTE app: a headless mode that renders the whole UI at a larger, browser-friendly resolution over Wi-Fi while the device screen shows the address to open; a Rotate button flips between desktop (landscape) and phone (portrait) layouts.
Web Contacts: search, sort and filter contacts, see each one's distance, coordinates and last-heard age, and get the same per-contact actions as on the device (favourite, block, reset path, remove). Discovered nodes appear too, each with a one-tap "Add".
Web Chats: pop-up notifications (with an optional beep) when a message arrives, the open chat auto-refreshes as replies land, and acknowledgements + repeats-heard show for messages you send from the browser.
# --- New: on-device web browser + links in chat ---
New "Web" app (8 MB boards — T-Deck, Tanmatsu): open a URL and read the page as plain readable text — no proxy, tracking, JavaScript or images — with tappable links and back/forward/refresh.
Links in a chat are now tappable: "Open in web" loads the page in the on-device browser, and "Create QR" shows a scannable QR to open the link on your phone (works on every board, including the Heltec V4).
# --- New boards ---
LilyGo T-LoRa Pager (ESP32-S3): a QWERTY-keyboard handheld with a rotary encoder and no touchscreen, fully driven from the keyboard + encoder; both radio variants (LR1121 and SX1262) supported. Contributed by @codemonkeybr (PR #149) — thank you!
LilyGo T-Display P4 (ESP32-P4): an AMOLED touchscreen handheld. Early port ("partially supported") — LoRa mesh, chat, map, GPS and the phone-app companion over Wi-Fi/USB work; Bluetooth pairing isn't available on this board (stock radio-coprocessor limitation — pair over Wi-Fi or USB).
Heltec WiFi LoRa 32 V4-R8 (8 MB PSRAM) with Expansion Kit V2 (microSD): "partially supported" while hardware testing finishes.
# --- Fixed ---
Heltec V4: map tiles now fill the whole map instead of only the top — the 2 MB-PSRAM V4 was running out of contiguous memory for the tile grid, so tiles now reuse a small fixed pool and only load what's on screen.
Heltec V4-R8: fixed random hard-freezes that needed a reset (the touch controller was wedging the shared I2C bus).
Tanmatsu and other keyboard-only boards: you can now select and edit the Wi-Fi name and password fields in Wi-Fi settings.
T-Display P4: the clock sets itself over Wi-Fi even where NTP is blocked, and won't get stuck on a bad date from the hardware clock.
French (AZERTY) on-screen keyboard: the spacebar is back in the normal letter view.
REMOTE mode now works on the Heltec V4 TFT (and other 2 MB-PSRAM boards); keyboard-less boards can leave REMOTE by holding the screen ~3 s, and the web page has an "Exit remote" button.
# --- Also ---
The flasher site shows a support badge on every board (fully vs partially supported); hover it to see what works.
+12
View File
@@ -0,0 +1,12 @@
# beta_46 — control-center redesign, Do Not Disturb, contact safety + board fixes
# New
Redesigned control center: round quick-toggle buttons that spread to fill the panel, a frosted-glass look, real lock/bell icons, and a confirmation popup on every toggle.
Do Not Disturb: set quiet hours in Settings > Sound to silence the incoming-message chime during a daily window, with a bell in the status bar while it's active. (community contribution by Tesso Codemonkey)
Do Not Disturb quick-toggle in the control center — a bell that gains a slash while quiet hours are on.
# Fixed
Contacts no longer go missing between reboots on microSD devices: saves are now atomic (written then swapped in) and the card is mounted far more reliably at boot.
Heltec V4-R8: the microSD card now mounts, and the freeze when waking the device from sleep is fixed.
LilyGo T-Pager: the map no longer shows a "storage error" under the launcher — it caches tiles to the microSD card, like the T-Deck.
Heltec V4 in portrait: the Bluetooth icon no longer overlaps the clock in the status bar, and the control-center buttons no longer clip when tapped.
+17
View File
@@ -0,0 +1,17 @@
# beta_47 — Discover app (find + map nearby nodes, wardriving), a new Attaky board, pager + P4 additions
# One user-facing note per non-blank, non-# line; # lines are section comments.
# New
New Discover app: actively sweep for nearby nodes and list everything that answers — repeaters, companions and room servers — ranked by signal, with live RSSI/SNR, a "direct" badge for anything in RF range, and a last-heard age. It's in the app drawer (the ⟳ tile, next to Map).
Tap any node in Discover to add it straight to your contacts — it keeps the right type (repeater, companion or room).
Wardriving: with a GPS fix, Discover logs every node it hears against your position to a CSV on the SD card as you move, and plots your RF coverage on the map as a signal-coloured track (green = strong, red = weak) — a standalone, on-device coverage mapper, no phone needed.
# New boards
Attaky Core (ESP32-S3, 16 MB flash / 8 MB PSRAM): a new board, contributed by @attakygit (#158). Experimental — on the beta channel while it finishes hardware verification.
LilyGo T-Display P4 (TFT-LCD screen variant): early support for the LCD screen model (the AMOLED variant was already supported). Experimental — if you have the LCD screen, grab the wadamesh-tdisplay-p4-lcd build from the release assets.
# Fixed
LilyGo T-LoRa Pager: the rotary encoder now keeps its focus inside the chat message list, and the composer navigation keys are remapped so moving around and typing a message no longer fight each other. (community contribution by @codemonkeybr, #162)
# Boards
T-Deck · Heltec V4 TFT · Heltec V4-R8 · ThinkNode M9 · RAK TAP v2 · T-LoRa Pager (LR1121 / SX1262) · Attaky Core · T-Display P4.
+14
View File
@@ -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.
+13
View File
@@ -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.
+13
View File
@@ -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.
+14
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
# beta_52 - major SD chat-history storage rework (community) - RISKY, NEEDS TESTING
# One user-facing note per non-blank, non-# line; # lines are section comments.
WARNING - RISKY TEST BUILD. This beta reworks how chat history is stored on the SD card. It has NOT been tested over long uptime yet and may have data-loss bugs.
WARNING - The chat-history format CHANGES and the change is ONE-WAY. If you flash an older firmware after running this, your saved chat history is lost. Back it up first if it matters to you.
WARNING - Do not run this as your only or daily device unless you are OK risking your chat history. We specifically need people to run it for hours or days and report any SD errors or history-not-saving problems.
# What changed
Major SD storage rework, contributed by the community (Vybo): the device now recovers from an SD card that wedges or is pulled at runtime instead of needing a reboot; the About page shows chat-store health (backend, save time, failures); and chat history is now written as small append-friendly segment files instead of rewriting the whole file on every save.
Fixed the follow-on bug where SD writes started failing after a few hours of uptime once the segment store's table filled up.
# Everything from beta_51 is also included
Chat bubbles show the time on the top row, the new Airtime channel-utilization tool, the lockscreen wallpaper corruption fix, and the T-Display P4 text-size fix.
+36
View File
@@ -0,0 +1,36 @@
# beta_53 - speed with a big chat history, blocking that actually works, T-Display P4 fixes
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Read this first -------------------------------------------------------------------
HEADS UP - chats now keep the newest 250 messages each by default. If you have a chat with more than that, the older ones are dropped the next time it receives a message. Change it or turn it off under Settings > Chats > "Keep per chat (messages)" BEFORE you let a busy channel run, if you want to keep a deep backlog.
# --- Speed ----------------------------------------------------------------------------
Fixed the big one: a large chat history no longer slows the whole interface down. With a busy channel the chat list was re-scanning the entire message store once for every chat, every time it refreshed - so the more history you had, the slower everything got. A device with 3800 messages in one channel was crawling. That work is now done once instead of once per chat.
New setting: "Keep per chat (messages)" under Settings > Chats. Choose 100, 250, 500, 1000, 2000 or No limit. The default is 250. Older messages in a chat are dropped past the limit, which stops one busy channel filling the whole message store and starving your other chats of history.
Turning the limit off asks you to confirm first, because an unlimited history is exactly what made things slow.
# --- Blocking -------------------------------------------------------------------------
Blocking someone now actually works. Three separate bugs: blocking a sender in a channel never saved properly so it did not stick, blocking a person inside a room server silenced the ENTIRE room instead of that one person, and blocked names were only filtered in channels so room posts still came through. Thanks to D-Melhede for reporting.
Unblocking now fully clears a block. Blocks were stored in two places and only one was being removed, so someone could stay silently blocked after you unblocked them.
# --- Chats ----------------------------------------------------------------------------
Fixed the chat list preview showing an old message and never updating. It picked the "newest" message by timestamp, which goes wrong on a device whose clock restarts at boot - so a message from an earlier session could outrank everything new forever. It now uses the actual arrival order. Mostly seen on the T-Display P4.
Notification sound works again. A failed map-tile fetch could leave an internal counter stuck, and that counter was silently muting every chime. Thanks to ropafiro for reporting (#184).
# --- Stability + connection ------------------------------------------------------------
Fixed multi-second freezes on Heltec V4 units in a busy mesh. Every advert heard was triggering a redundant write to internal flash, and contact saves were not rate-limited, so the interface could lock up for seconds at a time with nothing obviously happening. Thanks to Raph for the detailed report and video.
Wi-Fi scanning no longer gets stuck off. If the connection dropped at the wrong moment the scan gate stayed shut for good; it now clears itself and re-enables auto-reconnect (#171).
The phone app gets its replies again. Ping and telemetry responses could be swallowed before reaching the app (#124).
Typing into the web control panel no longer drops characters. Thanks to VE2CCK for reporting.
# --- Apps -----------------------------------------------------------------------------
The Airtime and Snake apps now work like the Spectrum app: a proper "< back" title bar, and you can no longer accidentally tap or swipe through to the screen behind them. They also no longer risk a crash when closing.
Swiping inside any full-screen app (Airtime, Spectrum, RF Monitor, Discover, Web, VNC, Remote) no longer jumps you to another tab.
Fixed three older reports from dreirund: the crash-restart dialog text overlapping its buttons (#97), the settings sheet opened from "Discovered" being cut off (#109), and room servers now showing their own icon in the contacts list (#106).
# --- T-Display P4 ----------------------------------------------------------------------
The clock is fixed. The P4 showed the wrong time on screen while sent messages carried the correct one, and using "Sync clock from system" then broke the good clock too. Its real-time-clock chip was being set but the system clock never was.
New: LoRa antenna setting under Settings > Radio. The on-board antenna is selected on every boot, and switching to the external socket asks you to confirm an antenna is actually connected first - transmitting into an empty connector can damage the radio.
If sending has been much weaker than receiving on your P4, this is why: the firmware was transmitting on the on-board antenna while listening on the external one. Pick the antenna you actually have fitted.
Reduced a source of the whole-screen flicker (#167) by keeping the display's working buffer off the shared memory bus. Please report whether you still see it.
Text size and the control-centre layout fixes from earlier betas also apply.
+1
View File
@@ -36,6 +36,7 @@ SINGLE = [
(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
+13 -6
View File
@@ -32,19 +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-thinknode-m9.json": ("wadamesh — ThinkNode M9", "wadamesh-thinknode-m9-merged.bin"),
"manifest-rak-tap-v2.json": ("wadamesh — RAK WisMesh Tap V2", "wadamesh-rak-tap-v2-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
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Representative demo data for the wadamesh web-control-panel doc harness.
Field names + shapes here MUST match the firmware's webPush* emitters in
src/ui-touch/UITask.cpp. See serve.py for the @-command -> payload mapping.
Timestamps are seconds since the Unix epoch. Everything is expressed relative to
`NOW` (filled in at serve time) so "last heard" ages stay believable regardless
of when the screenshots are taken. Coordinates are *1e6 fixed-point integers
(la/lo), matching c.gps_lat / c.gps_lon on the device.
The self node sits in Amsterdam; located contacts are scattered a few km around
it so the Distance sort/filter produce nice values.
"""
# Self node position (Amsterdam center) as 1e6 fixed-point, echoed in status.sla/slo.
SELF_LAT = 52_372_800 # 52.3728
SELF_LON = 4_893_400 # 4.8934
# Node identity / radio config shown in the Settings modal + header.
NODE_NAME = "Kaj T-Deck"
RADIO = {
"freq": 869.525, # MHz
"bw": 250.0, # kHz
"sf": 11,
"cr": 5,
"tx": 22, # dBm
}
# ---------------------------------------------------------------------------
# Contacts (webPushContacts: fields i,t,rp,rm,fav,ign,dir,la,lo,adv,n)
# i = contact index (used by @m/@oc/@ic/@fv/@bk/@pr/@xc)
# t = UI thread index if a chat exists, else -1
# rp = 1 if repeater, rm = 1 if room server
# fav = favourite, ign = blocked, dir = direct (out_path_len==0)
# la/lo = gps *1e6 (0 if unknown), adv = last_advert_timestamp (epoch s)
# n = name
# `_dlat/_dlon` below are offsets in degrees from the self node; converted to the
# absolute 1e6 la/lo at build time. Contacts with no GPS use la=lo=0.
# ---------------------------------------------------------------------------
_CONTACTS = [
# name thread type fav ign dir age(s) dlat dlon
dict(n="Sanne", t=0, kind="", fav=1, ign=0, dir=1, age=95, dlat=0.021, dlon=-0.015),
dict(n="Diego", t=1, kind="", fav=0, ign=0, dir=0, age=1500, dlat=-0.034, dlon=0.052),
dict(n="Marta", t=2, kind="", fav=0, ign=0, dir=1, age=320, dlat=0.0, dlon=0.0),
dict(n="Hilltop Repeater", t=-1, kind="rp", fav=0, ign=0, dir=0, age=210, dlat=0.145, dlon=0.088),
dict(n="Harbor Room", t=3, kind="rm", fav=0, ign=0, dir=0, age=640, dlat=-0.011, dlon=0.031),
dict(n="Noah", t=-1, kind="", fav=0, ign=0, dir=0, age=18000, dlat=0.0, dlon=0.0),
dict(n="Femke", t=-1, kind="", fav=0, ign=0, dir=1, age=86400*3, dlat=-0.062, dlon=-0.044),
dict(n="Old Node", t=-1, kind="", fav=0, ign=1, dir=0, age=86400*9, dlat=0.0, dlon=0.0),
]
# Group channels (webPushContacts channels[]: i=slot, t=thread, n=name).
_CHANNELS = [
dict(slot=0, t=-1, n="Public"),
dict(slot=1, t=-1, n="Emergency"),
]
# ---------------------------------------------------------------------------
# Messages per thread (webPushMessages: msgs[] fields o,ts,ds,pl,sn,rs,rp,s,x)
# o = outgoing (1) / incoming (0)
# ts = epoch s
# ds = delivery state for outgoing: 0 none,1 sent,2 delivered,3 failed
# pl = path length (hops); 255 shows as "Direct" in msg info
# sn = SNR dB (incoming), rs = RSSI dBm (incoming)
# rp = repeats heard (outgoing)
# s = sender name (shown only in channel threads), x = text
# Ordered oldest..newest here; serve.py emits newest-first (as the firmware does)
# and the browser reverses again for display.
# ---------------------------------------------------------------------------
_MESSAGES = {
# Sanne (direct peer) — thread 0
0: [
dict(o=0, ago=5400, x="Morning! You around for the hike this weekend?", pl=0, sn=8, rs=-64),
dict(o=1, ago=5300, x="Yeah! Saturday works. Meshing the whole way up.", ds=2, pl=0, rp=1),
dict(o=0, ago=5200, x="Perfect. I'll bring the spare T-Deck as a relay.", pl=0, sn=7, rs=-66),
dict(o=1, ago=280, x="Just tested range from the ridge — 6 km line of sight.", ds=2, pl=0, rp=2),
dict(o=0, ago=95, x="Nice, that covers the valley. See you at 8.", pl=0, sn=9, rs=-61),
],
# Diego (2-hop peer) — thread 1
1: [
dict(o=0, ago=2000, x="Did the firmware update land on your node?", pl=2, sn=2, rs=-98),
dict(o=1, ago=1900, x="Flashing now. beta looks solid so far.", ds=2, pl=2, rp=3),
dict(o=0, ago=1600, x="Great. Ping me once the repeater picks you up.", pl=2, sn=1, rs=-101),
dict(o=1, ago=1500, x="Will do 👍", ds=1, pl=2, rp=0),
],
# Marta (direct) — thread 2
2: [
dict(o=1, ago=900, x="Map tiles are caching fine on the new build.", ds=2, pl=0, rp=1),
dict(o=0, ago=700, x="Awesome — offline maps at the campsite then.", pl=0, sn=6, rs=-70),
dict(o=1, ago=360, x="Exactly. Sending you my GPS pin now.", ds=3, pl=0, rp=0),
dict(o=0, ago=320, x="Didn't come through, try again?", pl=0, sn=5, rs=-72),
],
# Harbor Room (room server channel) — thread 3, has unread
3: [
dict(o=0, ago=1200, s="Diego", x="Anyone near the north dock?", pl=1, sn=3, rs=-92),
dict(o=0, ago=1100, s="Sanne", x="I'm 300 m out, what's up?", pl=1, sn=7, rs=-68),
dict(o=1, ago=1000, s=NODE_NAME, x="Repeater on the crane looks healthy from here.", ds=2, pl=1, rp=2),
dict(o=0, ago=700, s="Femke", x="Copy. Battery at 82% on the solar node.", pl=2, sn=1, rs=-104),
dict(o=0, ago=640, s="Diego", x="Meeting at the boathouse in 10.", pl=1, sn=4, rs=-88),
],
}
# ---------------------------------------------------------------------------
# Threads list (webPushThreads: threads[] fields i,ch,u,ts,n,last)
# Derived from _MESSAGES so "last" + "ts" always match the open chat.
# u = unread count (only a couple non-zero), ch=1 for channel/room threads.
# ---------------------------------------------------------------------------
_THREAD_META = {
0: dict(ch=0, u=0, n="Sanne"),
1: dict(ch=0, u=1, n="Diego"),
2: dict(ch=0, u=0, n="Marta"),
3: dict(ch=1, u=2, n="Harbor Room"),
}
# ---------------------------------------------------------------------------
# Discovered (heard-not-added) nodes (webPushDiscovered: nodes[] i,rp,rm,pl,adv,la,lo,n)
# ---------------------------------------------------------------------------
_DISCOVERED = [
dict(n="Lars", kind="", pl=2, age=140, dlat=0.012, dlon=0.077),
dict(n="Ridge Repeater", kind="rp", pl=1, age=380, dlat=0.191, dlon=-0.02),
dict(n="Cafe Beacon", kind="", pl=3, age=900, dlat=-0.008, dlon=0.006),
dict(n="Yuki", kind="", pl=2, age=2600, dlat=0.0, dlon=0.0),
]
def _abs_coord(base, delta_deg):
"""Contact coord (1e6 fixed-point) from a degree offset; 0 stays 0 (unknown)."""
if delta_deg == 0:
return 0
return int(round(base + delta_deg * 1_000_000))
def contacts_payload(now):
out = []
for i, c in enumerate(_CONTACTS):
la = _abs_coord(SELF_LAT, c["dlat"])
lo = _abs_coord(SELF_LON, c["dlon"])
out.append({
"i": i, "t": c["t"],
"rp": 1 if c["kind"] == "rp" else 0,
"rm": 1 if c["kind"] == "rm" else 0,
"fav": c["fav"], "ign": c["ign"], "dir": c["dir"],
"la": la, "lo": lo,
"adv": now - c["age"],
"n": c["n"],
})
channels = [{"i": ch["slot"], "t": ch["t"], "n": ch["n"]} for ch in _CHANNELS]
return {"t": "c", "dc": len(_DISCOVERED), "contacts": out, "channels": channels}
def _thread_last(tidx):
msgs = _MESSAGES.get(tidx, [])
return msgs[-1] if msgs else None
def threads_payload(now):
out = []
for tidx, meta in _THREAD_META.items():
last = _thread_last(tidx)
ts = now - last["ago"] if last else 0
text = last["x"] if last else ""
out.append({
"i": tidx, "ch": meta["ch"], "u": meta["u"],
"ts": ts, "n": meta["n"], "last": text,
})
return {"t": "t", "threads": out}
def messages_payload(now, tidx):
meta = _THREAD_META.get(tidx)
msgs = _MESSAGES.get(tidx, [])
ch = meta["ch"] if meta else 0
name = meta["n"] if meta else "Chat"
# firmware emits newest-first; the browser reverses for display
arr = []
for m in reversed(msgs):
arr.append({
"o": m["o"],
"ts": now - m["ago"],
"ds": m.get("ds", 0),
"pl": m.get("pl", 0),
"sn": m.get("sn", 0),
"rs": m.get("rs", 0),
"rp": m.get("rp", 0),
"s": m.get("s", ""),
"x": m["x"],
})
return {"t": "m", "i": tidx, "ch": ch, "n": name, "msgs": arr}
def discovered_payload(now):
out = []
for i, d in enumerate(_DISCOVERED):
out.append({
"i": i,
"rp": 1 if d["kind"] == "rp" else 0,
"rm": 1 if d["kind"] == "rm" else 0,
"pl": d["pl"],
"adv": now - d["age"],
"la": _abs_coord(SELF_LAT, d["dlat"]),
"lo": _abs_coord(SELF_LON, d["dlon"]),
"n": d["n"],
})
return {"t": "dc", "nodes": out}
def contact_info_payload(now, cidx):
"""webPushContactInfo: fields i,rp,rm,fav,ign,pl,adv,la,lo,k,n."""
if cidx < 0 or cidx >= len(_CONTACTS):
return {"t": "ci", "i": cidx, "rp": 0, "rm": 0, "fav": 0, "ign": 0,
"pl": -1, "adv": 0, "la": 0, "lo": 0, "k": "000000000000", "n": "Contact"}
c = _CONTACTS[cidx]
la = _abs_coord(SELF_LAT, c["dlat"])
lo = _abs_coord(SELF_LON, c["dlon"])
# a stable pseudo pubkey-prefix (12 hex chars) so the Info card's ID looks real
k = "%02x%02x%02x%02x%02x%02x" % tuple((0x2a + cidx * 7 + j * 31) & 0xff for j in range(6))
return {
"t": "ci", "i": cidx,
"rp": 1 if c["kind"] == "rp" else 0,
"rm": 1 if c["kind"] == "rm" else 0,
"fav": c["fav"], "ign": c["ign"],
"pl": 0 if c["dir"] else (2 if c["kind"] != "rp" else 3),
"adv": now - c["age"],
"la": la, "lo": lo, "k": k, "n": c["n"],
}
def status_payload(now):
"""webPushStatus: batt,chg,wifi,rssi,ble,clk,snr,unr,mi,sla,slo,nm."""
unread = sum(m["u"] for m in _THREAD_META.values())
return {
"t": "st",
"batt": 78, "chg": 0,
"wifi": 1, "rssi": -58,
"ble": 1,
"clk": now,
"snr": 8, # good last-heard signal -> 4 bars
"unr": unread,
"mi": 0, # km, not miles
"sla": SELF_LAT, "slo": SELF_LON,
"nm": NODE_NAME,
}
def settings_payload(now):
"""webPushSettings: freq,bw,sf,cr,tx,wifi,ble,blecap,tcp,name."""
return {
"t": "sg",
"freq": RADIO["freq"], "bw": RADIO["bw"],
"sf": RADIO["sf"], "cr": RADIO["cr"], "tx": RADIO["tx"],
"wifi": 1, "ble": 1, "blecap": 1, "tcp": 1,
"name": NODE_NAME,
}
# ---------------------------------------------------------------------------
# Terminal (0x00 text) canned replies for the most common CLI commands, so the
# Terminal tab shows believable output in screenshots. Anything not listed gets
# a generic acknowledgement.
# ---------------------------------------------------------------------------
def terminal_reply(now, cmd):
import datetime
c = cmd.strip()
cl = c.lower()
clk = datetime.datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S")
if cl in ("help", "?"):
return (
"commands:\n"
" help ? this list\n"
" ver firmware version\n"
" clock RTC time\n"
" status node status\n"
" get radio params\n"
" advert send an advert\n"
" set <k> <v> name/freq/bw/sf/cr/tx\n"
" wifi|ble|tcp ... connectivity\n"
" list contacts channels\n"
" to <name> / send <text> / public <text>\n"
" reboot bootloader\n"
)
if cl == "ver":
return "wadamesh " + FIRMWARE_VERSION + " (demo)\n"
if cl == "clock":
return clk + "\n"
if cl == "status":
return ("node: %s\nbattery: 78%% (discharging)\nwifi: connected -58 dBm\n"
"ble: on tcp: on\nmesh: 4 bars contacts: %d\n"
% (NODE_NAME, len(_CONTACTS)))
if cl == "get":
return ("freq: %.3f MHz\nbw: %.0f kHz\nsf: %d\ncr: %d\ntx: %d dBm\n"
% (RADIO["freq"], RADIO["bw"], RADIO["sf"], RADIO["cr"], RADIO["tx"]))
if cl == "list" or cl == "list contacts" or cl == "contacts":
lines = ["contacts:"]
for i, c2 in enumerate(_CONTACTS):
tag = " [RPT]" if c2["kind"] == "rp" else (" [ROOM]" if c2["kind"] == "rm" else "")
lines.append(" %2d %s%s" % (i, c2["n"], tag))
return "\n".join(lines) + "\n"
if cl == "channels":
return "channels:\n" + "\n".join(" %d %s" % (c2["slot"], c2["n"]) for c2 in _CHANNELS) + "\n"
if cl == "advert" or cl == "advert.zerohop":
return "advert sent.\n"
if cl.startswith("wifi"):
return "wifi: connected ssid=meshnet -58 dBm 10.0.0.42\n"
if cl.startswith("ble"):
return "ble: advertising as \"" + NODE_NAME + "\"\n"
if cl.startswith("tcp"):
return "tcp: listening on :5000 (phone app)\n"
if cl.startswith("set "):
return "ok\n"
if cl in ("reboot", "bootloader"):
return "(demo) ignored: " + c + "\n"
return "ok\n"
# firmware version string surfaced by `ver` (kept generic; edit if you want an
# exact tag in the screenshot).
FIRMWARE_VERSION = "v1.x-touch"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Extract the wadamesh web control-panel SPA from the firmware C source.
The page is the C string literal `WS_HTML_TERMINAL_PAGE[]` in
src/helpers/esp32/WebSocketCompanionServer.cpp
built from many adjacent "..." fragments. This re-joins the fragments,
un-escapes the C escapes, strips the leading HTTP/1.1 response headers, and
writes a standalone scripts/doc/web-demo/index.html.
Re-run whenever the page changes:
python3 scripts/doc/web-demo/extract.py
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
SRC = os.path.join(REPO, "src", "helpers", "esp32", "WebSocketCompanionServer.cpp")
OUT = os.path.join(HERE, "index.html")
DECL = "static const char WS_HTML_TERMINAL_PAGE[] ="
# C escape sequences that appear in the literal. Order matters: handle the
# backslash-escapes we know about; leave any others (\uXXXX etc. don't occur here)
# untouched. We decode via a single regex pass so a literal "\\n" (escaped
# backslash then n) is NOT turned into a newline.
_C_ESCAPES = {
"\\n": "\n",
"\\r": "\r",
"\\t": "\t",
'\\"': '"',
"\\\\": "\\",
"\\'": "'",
"\\a": "\a",
"\\b": "\b",
"\\f": "\f",
"\\v": "\v",
"\\0": "\0",
}
_ESC_RE = re.compile(r"\\(?:[abfnrtv0'\"\\]|x[0-9A-Fa-f]{2})")
def _unescape(s):
def repl(m):
tok = m.group(0)
if tok.startswith("\\x"):
return chr(int(tok[2:], 16))
return _C_ESCAPES.get(tok, tok)
return _ESC_RE.sub(repl, s)
def _extract_string_fragments(block):
"""Yield the decoded content of every "..." fragment in `block`, in order.
Parses char-by-char so escaped quotes (\") inside a fragment don't end it,
and text between fragments (whitespace / newlines in the C source) is ignored.
"""
out = []
i, n = 0, len(block)
in_str = False
frag = []
while i < n:
ch = block[i]
if not in_str:
if ch == '"':
in_str = True
frag = []
elif ch == ";":
break # end of the C statement
i += 1
continue
# inside a string literal
if ch == "\\" and i + 1 < n:
frag.append(block[i:i + 2])
i += 2
continue
if ch == '"':
out.append(_unescape("".join(frag)))
in_str = False
i += 1
continue
frag.append(ch)
i += 1
return "".join(out)
def extract(src_path=SRC):
with open(src_path, "r", encoding="utf-8") as f:
text = f.read()
start = text.find(DECL)
if start < 0:
raise SystemExit("could not find %s in %s" % (DECL, src_path))
start += len(DECL)
# The statement ends at the first ';' that terminates the literal. Because
# fragments can contain ';' we cannot naively split; instead let the fragment
# parser stop at the first ';' found OUTSIDE a string.
joined = _extract_string_fragments(text[start:])
# Strip the HTTP response head: everything up to and including the blank line
# (\r\n\r\n) that precedes the HTML body.
marker = "\r\n\r\n"
idx = joined.find(marker)
if idx >= 0 and joined[:idx].startswith("HTTP/"):
joined = joined[idx + len(marker):]
else:
# fall back: chop to the doctype if the header shape ever changes
d = joined.find("<!DOCTYPE")
if d > 0:
joined = joined[d:]
return joined
def main():
html = extract()
with open(OUT, "w", encoding="utf-8") as f:
f.write(html)
print("wrote %s (%d bytes)" % (OUT, len(html)))
# sanity: the reconstructed page must have the key anchors
for needle in ("<!DOCTYPE html>", "WADAMESH", "/term", "id=w", "showTab"):
if needle not in html:
print(" WARNING: expected %r not found in output" % needle, file=sys.stderr)
if __name__ == "__main__":
main()
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html><html><head><meta charset=utf-8>
<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1'>
<title>wadamesh</title>
<style>
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#0b0b0c;color:#d7dbdd;font-family:-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px;-webkit-text-size-adjust:100%}
#w{display:flex;flex-direction:column;height:100%}
#hd{padding:8px 10px;border-bottom:1px solid #1c1c1f;display:flex;align-items:center;gap:8px}#hdl{flex:1}
#hd b{color:#19d6c2;font-weight:700;letter-spacing:2px}#st{font-size:12px}
#tabs{display:flex;border-bottom:1px solid #1c1c1f}
#tabs button{flex:1;background:none;border:none;color:#7f868c;padding:11px 4px;font:inherit;font-size:13px;border-bottom:2px solid transparent;cursor:pointer}
#tabs button.on{color:#19d6c2;border-bottom-color:#19d6c2}
.pane{flex:1;min-height:0;display:flex;flex-direction:column}
.list{flex:1;overflow-y:auto}
.row{display:flex;padding:11px 13px;border-bottom:1px solid #141416;cursor:pointer;gap:10px;align-items:center}
.row:active{background:#141416}.rmain{flex:1;min-width:0}
.rname{font-weight:600;color:#e8e8ea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rsub{font-size:12px;color:#7f868c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
.rtime{font-size:11px;color:#63696e;white-space:nowrap}
.badge{background:#19d6c2;color:#04201d;border-radius:9px;padding:0 6px;font-size:11px;margin-left:6px;font-weight:700}
.sec{padding:9px 13px 4px;font-size:11px;color:#63696e;letter-spacing:1px}
.empty{padding:26px 13px;color:#63696e;text-align:center}
#cview{flex:1;min-height:0;flex-direction:column}
#cvh{display:flex;align-items:center;gap:6px;padding:8px 8px;border-bottom:1px solid #1c1c1f}
#cvback{background:none;border:none;color:#19d6c2;font-size:24px;line-height:1;cursor:pointer;padding:0 6px}
#cvname{font-weight:600}
#cvmsgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px}
.b{max-width:80%;padding:6px 9px;border-radius:11px;word-break:break-word}
.b.in{align-self:flex-start;background:#17181b}.b.out{align-self:flex-end;background:#123f3a}
.bname{font-size:11px;color:#19d6c2;margin-bottom:2px}.btext{white-space:pre-wrap}
.bmeta{font-size:10px;color:#8a9095;text-align:right;margin-top:2px}
.cb{display:flex;border-top:1px solid #1c1c1f;padding:8px;gap:8px}
.cb input{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:18px;padding:9px 14px;font:inherit;outline:none}
.cb input:focus{border-color:#19d6c2}
.cb button{background:#19d6c2;color:#04201d;border:none;border-radius:18px;padding:0 16px;font-weight:700;cursor:pointer}
#o{flex:1;overflow-y:auto;padding:10px 12px;white-space:pre-wrap;word-break:break-word;line-height:1.4;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px}
#tbar{display:flex;border-top:1px solid #1c1c1f;padding:8px 10px;gap:8px;align-items:center}
#p{color:#19d6c2;font-weight:700}
#i{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:8px 10px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;outline:none}
#i:focus{border-color:#19d6c2}
.e{color:#7f868c}.c{color:#e8b84b}.ok{color:#19d6c2}.err{color:#ff6b6b}
#hdl{display:flex;align-items:center;gap:6px;min-width:0}#hnm{color:#9aa0a6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#dot{font-size:11px;color:#63696e}#hdr{display:flex;align-items:center;gap:8px;font-size:12px;color:#9aa0a6;white-space:nowrap}
#hdr svg{vertical-align:middle;display:block}#hdr>span{display:inline-flex;align-items:center}.bpct{font-size:11px;margin-left:3px;color:#9aa0a6}
.bars{letter-spacing:-1px;color:#19d6c2}.bars i{color:#3a3f44;font-style:normal}
.bgrn{color:#19d6c2}.byel{color:#e8b84b}.bred{color:#ff6b6b}
#ctools{display:flex;gap:6px;padding:7px 8px;border-bottom:1px solid #1c1c1f}
#csearch{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:14px;padding:6px 12px;font:inherit;font-size:13px;outline:none;min-width:0}
#ctools button{background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:14px;padding:0 10px;font:inherit;font-size:12px;white-space:nowrap;cursor:pointer}
.tag{font-size:10px;padding:1px 5px;border-radius:6px;margin-left:6px;vertical-align:middle}.trep{background:#243b52;color:#7fc0ff}.troom{background:#3b2a52;color:#c69cff}.tfav{color:#e8b84b}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:19;display:none}#scrim.on{display:block}
#sheet{position:fixed;left:0;right:0;bottom:0;background:#151619;border-top:1px solid #2a2a2e;border-radius:14px 14px 0 0;transform:translateY(101%);transition:transform .15s;z-index:20;padding:6px 0 max(10px,env(safe-area-inset-bottom));max-height:74vh;overflow-y:auto}
#qcmd{background:#17181b;color:#19d6c2;border:1px solid #2a2a2e;border-radius:6px;padding:0 11px;font-size:16px;line-height:34px;cursor:pointer}
#sheet.on{transform:translateY(0)}#sheet h4{margin:8px 16px 4px;font-size:13px;color:#9aa0a6}
.si{display:block;width:100%;text-align:left;background:none;border:none;color:#d7dbdd;padding:13px 16px;font:inherit;font-size:15px;cursor:pointer;border-top:1px solid #202225}.si:active{background:#1d1f22}.si.dng{color:#ff6b6b}
#modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:21;padding:20px}#modal.on{display:flex}
#mbox{background:#151619;border:1px solid #2a2a2e;border-radius:12px;max-width:340px;width:100%;padding:16px;max-height:82%;overflow:auto}#mbox h3{margin:0 0 10px;color:#19d6c2;font-size:15px;word-break:break-word}
#mbox .kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #202225}#mbox .kv b{color:#9aa0a6;font-weight:400}#mbox .kv span{text-align:right;word-break:break-word}
#mclose{margin-top:12px;width:100%;background:#19d6c2;color:#04201d;border:none;border-radius:8px;padding:10px;font-weight:700;cursor:pointer}
#gear{background:none;border:none;color:#9aa0a6;font-size:17px;cursor:pointer;padding:0 2px;line-height:1}
.sset label{display:block;font-size:11px;color:#63696e;letter-spacing:1px;margin:12px 0 4px}
.sset input[type=text],.sset input[type=number]{background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:7px 9px;font:inherit;outline:none;width:100%}
.srow{display:flex;gap:8px;align-items:center;margin:4px 0}.srow input{flex:1;min-width:0}
.sset .sbtn{background:#19d6c2;color:#04201d;border:none;border-radius:6px;padding:0 12px;font-weight:700;line-height:32px;cursor:pointer;white-space:nowrap}
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.sgrid .f label{margin:2px 0}.sgrid .f{min-width:0}
.stog{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #202225}.stog b{font-weight:400;color:#d7dbdd}
.sw{position:relative;width:40px;height:22px;background:#2a2a2e;border-radius:11px;cursor:pointer;transition:background .15s;flex:none}.sw.on{background:#19d6c2}.sw i{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#e8e8ea;transition:left .15s}.sw.on i{left:20px}
.srow2{display:flex;gap:8px;margin-top:14px}.srow2 button{flex:1;background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:8px;padding:10px;font:inherit;cursor:pointer}.srow2 button.dng{color:#ff6b6b;border-color:#5a2a2a}
#toast{position:fixed;left:8px;right:8px;top:8px;background:#17342f;border:1px solid #19d6c2;border-radius:10px;padding:9px 12px;z-index:30;box-shadow:0 4px 16px rgba(0,0,0,.45);transform:translateY(-150%);transition:transform .2s;cursor:pointer}
#toast.on{transform:translateY(0)}#toast b{color:#19d6c2;display:block;font-size:13px;margin-bottom:1px}
#toast span{font-size:12px;color:#cfeee9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
</style></head><body>
<div id=w>
<div id=hd><div id=hdl><span id=dot>&bull;</span><b>WADAMESH</b><span id=hnm></span></div>
<div id=hdr><span id=hsig></span><span id=hwifi></span><span id=hble></span><span id=hbatt></span><span id=hclk></span></div><button id=gear title=Settings>&#9881;</button></div>
<div id=tabs><button data-t=chats class=on>Chats</button><button data-t=contacts>Contacts</button><button data-t=term>Terminal</button></div>
<div id=pchats class=pane>
<div id=tlist class=list></div>
<div id=cview style=display:none>
<div id=cvh><button id=cvback>&lsaquo;</button><span id=cvname></span></div>
<div id=cvmsgs></div>
<div class=cb><input id=cvi placeholder='Message' autocomplete=off><button id=cvsend>Send</button></div>
</div>
</div>
<div id=pcontacts class=pane style=display:none>
<div id=ctools><input id=csearch placeholder='Search' autocomplete=off autocapitalize=off spellcheck=false><button id=csort>Sort</button><button id=cfilt>All</button></div>
<div id=clist class=list></div>
</div>
<div id=pterm class=pane style=display:none>
<div id=o></div>
<div id=tbar><span id=p>&gt;</span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>&#9889;</button></div>
</div>
</div>
<div id=scrim></div>
<div id=sheet></div>
<div id=modal><div id=mbox></div></div>
<div id=toast></div>
<script>
var WS,dec=new TextDecoder(),curTab='chats',chat=null,threads=[],contacts=[],channels=[];
var csort=0,cfilter=0,csearchq='',self={la:0,lo:0},lpAt=0,useMi=0,discCount=0;
var SORTL=['Recent','A-Z','Distance'],FILTL=['All','Repeaters','Peers','Favorites','Located','Direct'];
function age(ts){if(!ts||ts<1000000000)return'';var s=Math.floor(Date.now()/1000)-ts;if(s<0)s=0;if(s<60)return'now';if(s<3600)return Math.floor(s/60)+'m';if(s<86400)return Math.floor(s/3600)+'h';if(s<604800)return Math.floor(s/86400)+'d';return Math.floor(s/604800)+'w'}
function fmtDist(km){if(km>=1e17)return'';if(useMi){var mi=km*0.621371;return (mi<10?mi.toFixed(1):Math.round(mi))+' mi'}return (km<10?km.toFixed(1):Math.round(km))+' km'}
function E(i){return document.getElementById(i)}
function each(l,f){Array.prototype.forEach.call(l,f)}
function esc(s){return (s==null?'':''+s).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function fmt(t){if(!t||t<1000000000)return'';var d=new Date(t*1000);return('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
function fmtDate(t){return (t>1000000000)?new Date(t*1000).toLocaleString():'--'}
function stat(t,k){var el=E('dot');el.style.color=(k=='ok')?'#19d6c2':((k=='err')?'#ff6b6b':'#63696e');el.title=t}
function wsSend(s){if(WS&&WS.readyState==1){WS.send(s);return true}return false}
function cById(i){for(var k=0;k<contacts.length;k++)if(contacts[k].i==i)return contacts[k];return null}
function showTab(t){curTab=t;['chats','contacts','term'].forEach(function(x){E('p'+x).style.display=(x==t)?'':'none'});
each(document.querySelectorAll('#tabs button'),function(b){b.className=(b.getAttribute('data-t')==t)?'on':''});
if(t=='chats'){if(!chat){closeChat();wsSend('@t')}}else if(t=='contacts'){wsSend('@c')}else if(t=='term'){E('i').focus()}}
function svgSig(l){var b='';for(var i=0;i<4;i++){var h=3+i*3;b+='<rect x='+(i*4)+' y='+(12-h)+' width=3 height='+h+' rx=1 fill='+(i<l?'#19d6c2':'#3a3f44')+' />'}return '<svg width=15 height=12 viewBox="0 0 15 12">'+b+'</svg>'}
function svgWifi(on){var c=on?'#19d6c2':'#3a3f44';return '<svg width=16 height=12 viewBox="0 0 16 12" fill=none stroke='+c+' stroke-width=1.5 stroke-linecap=round><path d="M2.2 4.6a8 8 0 0 1 11.6 0" /><path d="M4.6 7.1a4.6 4.6 0 0 1 6.8 0" /><circle cx=8 cy=9.6 r=0.9 fill='+c+' stroke=none /></svg>'}
function svgBt(){return '<svg width=10 height=14 viewBox="0 0 10 14" fill=none stroke=#7fb0ff stroke-width=1.3 stroke-linejoin=round stroke-linecap=round><path d="M2 4l6 6-3 2.5V1.5l3 2.5-6 6" /></svg>'}
function svgBatt(p,chg){var col=p<=20?'#ff6b6b':(p<=50?'#e8b84b':'#19d6c2');var w=Math.max(1,Math.round(p/100*16));var bolt=chg?'<path d="M12 3l-3 4h2l-1 3 3-4h-2z" fill=#e8b84b />':'';return '<svg width=27 height=13 viewBox="0 0 27 13"><rect x=0.5 y=2 width=21 height=9 rx=2 fill=none stroke=#8a9095 /><rect x=22 y=4.5 width=2.2 height=4 rx=1 fill=#8a9095 /><rect x=2 y=3.5 width='+w+' height=6 rx=1 fill='+col+' />'+bolt+'</svg>'}
function renderStatus(d){E('hnm').textContent=d.nm||'';self.la=d.sla||0;self.lo=d.slo||0;useMi=d.mi?1:0;
var lvl=(d.snr<=-127)?0:(d.snr>=5?4:(d.snr>=0?3:(d.snr>=-7?2:1)));
E('hsig').innerHTML=svgSig(lvl);E('hwifi').innerHTML=d.wifi?svgWifi(1):'';E('hble').innerHTML=d.ble?svgBt():'';
E('hbatt').innerHTML=(d.batt<0)?'':(svgBatt(d.batt,d.chg)+'<span class=bpct>'+d.batt+'%</span>');
E('hclk').textContent=fmt(d.clk);
var cb=document.querySelector('#tabs [data-t=chats]');if(cb)cb.textContent=d.unr>0?('Chats ('+d.unr+')'):'Chats'}
function closeOv(){E('sheet').classList.remove('on');E('modal').classList.remove('on');E('scrim').classList.remove('on')}
function sheet(title,items){var h='<h4>'+esc(title)+'</h4>';items.forEach(function(it,i){h+='<button class="si '+(it.cls||'')+'" data-i='+i+'>'+esc(it.label)+'</button>'});
E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var it=items[+b.getAttribute('data-i')];closeOv();if(it.fn)it.fn()}});
E('sheet').classList.add('on');E('scrim').classList.add('on')}
function modal(title,html){E('mbox').innerHTML='<h3>'+esc(title)+'</h3>'+html+'<button id=mclose>Close</button>';E('modal').classList.add('on');E('scrim').classList.add('on');E('mclose').onclick=closeOv}
function copy(t){try{navigator.clipboard.writeText(t)}catch(_){var a=document.createElement('textarea');a.value=t;document.body.appendChild(a);a.select();try{document.execCommand('copy')}catch(e){}a.remove()}}
function longPress(el,fn){var tmr=null;function s(){tmr=setTimeout(function(){tmr=null;lpAt=Date.now();fn()},480)}function c(){if(tmr){clearTimeout(tmr);tmr=null}}
el.addEventListener('touchstart',s,{passive:true});el.addEventListener('touchmove',c,{passive:true});el.addEventListener('touchend',c);
el.addEventListener('mousedown',s);el.addEventListener('mouseup',c);el.addEventListener('mouseleave',c);el.addEventListener('contextmenu',function(e){e.preventDefault();lpAt=Date.now();fn()})}
function clicked(fn){return function(){if(Date.now()-lpAt<650)return;fn()}}
function contactSheet(c){sheet(c.n||'Contact',[
{label:'Message',fn:function(){if(c.t>=0)wsSend('@m '+c.t);else wsSend('@oc '+c.i)}},
{label:'Info',fn:function(){wsSend('@ic '+c.i)}},
{label:c.fav?'Unfavorite':'Favorite',fn:function(){wsSend('@fv '+c.i)}},
{label:'Reset path',fn:function(){wsSend('@pr '+c.i)}},
{label:c.ign?'Unblock':'Block',fn:function(){wsSend('@bk '+c.i)}},
{label:'Delete',cls:'dng',fn:function(){if(confirm('Delete '+(c.n||'contact')+'?'))wsSend('@xc '+c.i)}}])}
function chatSheet(t){sheet(t.n||'Chat',[
{label:'Mark as read',fn:function(){wsSend('@r '+t.i)}},
{label:'Clear history',fn:function(){if(confirm('Clear history?'))wsSend('@d '+t.i)}},
{label:'Delete chat',cls:'dng',fn:function(){if(confirm('Delete this chat?'))wsSend('@xt '+t.i)}}])}
function msgSheet(m){sheet('Message',[{label:'Copy',fn:function(){copy(m.x)}},{label:'Info',fn:function(){msgInfo(m)}}])}
function msgInfo(m){var pl=(m.pl==255)?'Direct':(m.pl+' hop'+(m.pl==1?'':'s'));
var h='<div class=kv><b>Direction</b><span>'+(m.o?'Sent':'Received')+'</span></div><div class=kv><b>Time</b><span>'+(fmt(m.ts)||'--')+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div>';
if(!m.o)h+='<div class=kv><b>SNR</b><span>'+m.sn+' dB</span></div><div class=kv><b>RSSI</b><span>'+m.rs+' dBm</span></div>';
if(m.o)h+='<div class=kv><b>Delivery</b><span>'+(({0:'--',1:'Sent',2:'Delivered',3:'Failed'})[m.ds]||'--')+'</span></div>';
if(m.o&&m.rp>0)h+='<div class=kv><b>Repeats heard</b><span>'+m.rp+'</span></div>';
modal('Message info',h)}
function showContactInfo(d){var type=d.rp?'Repeater':(d.rm?'Room server':'Contact');var pl=(d.pl<0)?'Unknown':(d.pl==0?'Direct':(d.pl+' hop'+(d.pl==1?'':'s')));
var h='<div class=kv><b>Type</b><span>'+type+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div><div class=kv><b>Favorite</b><span>'+(d.fav?'Yes':'No')+'</span></div><div class=kv><b>Blocked</b><span>'+(d.ign?'Yes':'No')+'</span></div>';
if(d.adv>1000000000)h+='<div class=kv><b>Last heard</b><span>'+fmtDate(d.adv)+'</span></div>';
if(d.la||d.lo){h+='<div class=kv><b>Location</b><span>'+(d.la/1e6).toFixed(5)+', '+(d.lo/1e6).toFixed(5)+'</span></div>';var ds=fmtDist(cdist(d));if(ds)h+='<div class=kv><b>Distance</b><span>'+ds+'</span></div>'}
h+='<div class=kv><b>ID</b><span>'+esc(d.k)+'</span></div>';modal(d.n||'Contact',h)}
var prevUnread={},toastTmr=null,AC=null;
function onThreads(nt){
nt.forEach(function(t){var pu=prevUnread[t.i];if(pu!=null&&t.u>pu&&!(chat&&chat.i==t.i))notify(t.i,(t.ch?'# ':'')+t.n,t.last||'New message')});
prevUnread={};nt.forEach(function(t){prevUnread[t.i]=t.u});threads=nt;renderThreads()}
function notify(ti,title,body){showToast(ti,title,body);
try{if('Notification' in window&&Notification.permission=='granted'&&document.hidden)new Notification(title,{body:body})}catch(e){}
try{beep()}catch(e){}}
function showToast(ti,title,body){var t=E('toast');t.innerHTML='<b></b><span></span>';t.querySelector('b').textContent=title;t.querySelector('span').textContent=body;
t.onclick=function(){t.classList.remove('on');wsSend('@m '+ti)};t.classList.add('on');
if(toastTmr)clearTimeout(toastTmr);toastTmr=setTimeout(function(){t.classList.remove('on')},4200)}
function beep(){AC=AC||new (window.AudioContext||window.webkitAudioContext)();var o=AC.createOscillator(),g=AC.createGain();o.connect(g);g.connect(AC.destination);o.frequency.value=880;g.gain.value=0.05;o.start();g.gain.exponentialRampToValueAtTime(0.0001,AC.currentTime+0.18);o.stop(AC.currentTime+0.2)}
document.addEventListener('click',function(){try{if('Notification' in window&&Notification.permission=='default')Notification.requestPermission()}catch(e){}},{once:true});
var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to <name>',c:'to '},{l:'send <text>',c:'send '},{l:'public <text>',c:'public '},{l:'exit',c:'exit'},
{h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},
{h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name <v>',c:'set name '},{l:'set freq <MHz>',c:'set freq '},{l:'set bw <kHz>',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx <dBm>',c:'set tx '},
{h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},
{h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];
function cmdPicker(){var h='<h4>Commands</h4>';QCMDS.forEach(function(x,i){if(x.h)h+='<div class=sec>'+esc(x.h)+'</div>';else h+='<button class=si data-i='+i+'>'+esc(x.l)+'</button>'});
E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});
E('sheet').classList.add('on');E('scrim').classList.add('on')}
var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];
function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}
function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}
tadd('\n> '+v+'\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\n','err')}
function onData(s){var d;try{d=JSON.parse(s)}catch(_){return}
if(d.t=='t'){onThreads(d.threads||[])}
else if(d.t=='c'){contacts=d.contacts||[];channels=d.channels||[];discCount=d.dc||0;renderContacts()}
else if(d.t=='m'){openChatView(d)}
else if(d.t=='st'){renderStatus(d)}
else if(d.t=='ci'){showContactInfo(d)}
else if(d.t=='dc'){showDiscovered(d.nodes||[])}
else if(d.t=='sg'){showSettings(d)}
else if(d.t=='rx'){wsSend('@t');if(chat&&chat.i>=0)wsSend('@m '+chat.i);if(curTab=='contacts')wsSend('@c')}}
function renderThreads(){var h='';if(!threads.length)h='<div class=empty>No chats yet.<br>Start one from Contacts.</div>';
threads.sort(function(a,b){return (b.ts||0)-(a.ts||0)});
threads.forEach(function(t){var bd=t.u>0?'<span class=badge>'+t.u+'</span>':'';
h+='<div class=row data-ti='+t.i+'><div class=rmain><div class=rname>'+(t.ch?'# ':'')+esc(t.n)+bd+'</div><div class=rsub>'+esc(t.last||'')+'</div></div><div class=rtime>'+fmt(t.ts)+'</div></div>'});
E('tlist').innerHTML=h;each(E('tlist').querySelectorAll('.row'),function(r){var ti=+r.getAttribute('data-ti');
r.onclick=clicked(function(){wsSend('@m '+ti)});var t=null;for(var k=0;k<threads.length;k++)if(threads[k].i==ti)t=threads[k];longPress(r,function(){if(t)chatSheet(t)})})}
function cdist(c){if(!(c.la||c.lo)||!(self.la||self.lo))return 1e18;var R=6371,rad=Math.PI/180,dLa=(c.la-self.la)/1e6*rad,dLo=(c.lo-self.lo)/1e6*rad,l1=self.la/1e6*rad,l2=c.la/1e6*rad;
var a=Math.sin(dLa/2)*Math.sin(dLa/2)+Math.cos(l1)*Math.cos(l2)*Math.sin(dLo/2)*Math.sin(dLo/2);return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}
function cVis(c){if(csearchq&&(c.n||'').toLowerCase().indexOf(csearchq)<0)return false;
if(cfilter==1)return c.rp;if(cfilter==2)return !c.rp;if(cfilter==3)return c.fav;if(cfilter==4)return !!(c.la||c.lo);if(cfilter==5)return c.dir;return true}
function renderContacts(){E('csort').textContent='Sort: '+SORTL[csort];E('cfilt').textContent=FILTL[cfilter];var h='';
if(cfilter==0&&!csearchq){h+='<div class=row id=discrow><div class=rmain><div class=rname>Discovered nodes'+(discCount?' <span class=badge>'+discCount+'</span>':'')+'</div><div class=rsub>heard nearby - tap to add</div></div><div class=rtime>\u203a</div></div>'}
if(cfilter==0&&!csearchq&&channels.length){h+='<div class=sec>CHANNELS</div>';channels.forEach(function(c){h+='<div class=row data-h='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname># '+esc(c.n)+'</div></div></div>'})}
var cs=contacts.filter(cVis);cs.sort(function(a,b){if(csort==1)return (a.n||'').localeCompare(b.n||'');if(csort==2)return cdist(a)-cdist(b);return (b.adv||0)-(a.adv||0)});
h+='<div class=sec>CONTACTS'+(cs.length?' ('+cs.length+')':'')+'</div>';if(!cs.length)h+='<div class=empty>No contacts.</div>';
cs.forEach(function(c){var tag=c.rp?'<span class="tag trep">RPT</span>':(c.rm?'<span class="tag troom">ROOM</span>':'');var fav=c.fav?'<span class="tag tfav">\u2605</span>':'';var blk=c.ign?'<span class=tag style=color:#ff6b6b>\u2298</span>':'';
var sub=[fmtDist(cdist(c)),age(c.adv)].filter(Boolean).join(' \u00b7 ');if(c.dir)sub=sub?(sub+' \u00b7 direct'):'direct';
h+='<div class=row data-c='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname>'+esc(c.n)+tag+fav+blk+'</div>'+(sub?'<div class=rsub>'+sub+'</div>':'')+'</div></div>'});
E('clist').innerHTML=h;var dr=E('discrow');if(dr)dr.onclick=function(){wsSend('@dc')};
each(E('clist').querySelectorAll('.row'),function(r){
if(r.hasAttribute('data-c')){var ci=+r.getAttribute('data-c');var open=function(){var c=cById(ci);if(c)contactSheet(c)};r.onclick=clicked(open);longPress(r,open)}
else if(r.hasAttribute('data-h')){r.onclick=clicked(function(){var t=+r.getAttribute('data-t');if(t>=0)wsSend('@m '+t);else wsSend('@oh '+r.getAttribute('data-h'))})}})}
function showDiscovered(nodes){if(!nodes.length){sheet('Discovered nodes',[{label:'No new nodes heard yet'}]);return}
var items=nodes.map(function(nd){var tag=nd.rp?' [RPT]':(nd.rm?' [ROOM]':'');var meta=[fmtDist(cdist(nd)),age(nd.adv)].filter(Boolean).join(' \u00b7 ');
return {label:(nd.n||'(unnamed)')+tag+(meta?' ('+meta+')':''),fn:function(){wsSend('@ad '+nd.i);showToast(-1,'Added contact',nd.n||'')}}});
sheet('Discovered - tap to add',items)}
function showSettings(d){var g=E;
var h='<div class=sset><label>NODE NAME</label><div class=srow><input type=text id=sfname><button class=sbtn id=sbname>Save</button></div>';
h+='<label>RADIO (applies live)</label><div class=sgrid>';
h+='<div class=f><label>Freq MHz</label><input type=number step=0.001 id=sffreq></div><div class=f><label>BW kHz</label><input type=number id=sfbw></div>';
h+='<div class=f><label>SF</label><input type=number id=sfsf></div><div class=f><label>CR</label><input type=number id=sfcr></div>';
h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label>&nbsp;</label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';
h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';
if(d.blecap)h+='<div class=stog><b>Bluetooth</b><div class=sw id=sble><i></i></div></div>';
h+='<div class=stog><b>TCP (phone app)</b><div class=sw id=stcp><i></i></div></div>';
h+='<div class=srow2><button id=sadvert>Send advert</button><button id=sreboot class=dng>Reboot</button></div></div>';
E('mbox').innerHTML='<h3>Settings</h3>'+h+'<button id=mclose>Close</button>';
g('sfname').value=d.name||'';g('sffreq').value=d.freq;g('sfbw').value=d.bw;g('sfsf').value=d.sf;g('sfcr').value=d.cr;g('sftx').value=d.tx;
function tg(id,on){var el=g(id);if(el){on?el.classList.add('on'):el.classList.remove('on')}}
tg('swifi',d.wifi);tg('sble',d.ble);tg('stcp',d.tcp);
g('sbname').onclick=function(){var v=g('sfname').value.trim();if(v){wsSend('set name '+v);showToast(-1,'Name saved',v)}};
g('sbradio').onclick=function(){wsSend('@sr '+(+g('sffreq').value)+' '+(+g('sfbw').value)+' '+(+g('sfsf').value)+' '+(+g('sfcr').value)+' '+(+g('sftx').value));showToast(-1,'Radio applied','')};
g('swifi').onclick=function(){var on=!g('swifi').classList.contains('on');tg('swifi',on);wsSend('wifi '+(on?'on':'off'))};
if(g('sble'))g('sble').onclick=function(){var on=!g('sble').classList.contains('on');tg('sble',on);wsSend('ble '+(on?'on':'off'))};
g('stcp').onclick=function(){var on=!g('stcp').classList.contains('on');tg('stcp',on);wsSend('tcp '+(on?'on':'off'))};
g('sadvert').onclick=function(){wsSend('advert');showToast(-1,'Advert sent','')};
g('sreboot').onclick=function(){if(confirm('Reboot the device now?'))wsSend('reboot')};
g('mclose').onclick=closeOv;E('modal').classList.add('on');E('scrim').classList.add('on')}
function openChatView(d){var box=E('cvmsgs');var wasBottom=(box.scrollHeight-box.scrollTop-box.clientHeight)<44;var same=chat&&d.i!=null&&d.i>=0&&chat.i==d.i;
chat={i:(d.i!=null?d.i:-1),ch:!!d.ch,n:d.n||'',c:(d.c!=null?d.c:null),h:(d.h!=null?d.h:null)};
showTab('chats');E('tlist').style.display='none';E('cview').style.display='flex';E('cvname').textContent=(chat.ch?'# ':'')+chat.n;
var arr=(d.msgs||[]).slice().reverse();var h='';arr.forEach(function(m,i){var cls=m.o?'b out':'b in';var meta=fmt(m.ts);
if(m.o){meta+=' '+(({1:'\u2713',2:'\u2713\u2713',3:'\u2717'})[m.ds]||'');if(m.rp>0)meta+=' \u21bb'+m.rp}
var nm=(!m.o&&chat.ch)?'<div class=bname>'+esc(m.s)+'</div>':'';
h+='<div class="'+cls+'" data-mi='+i+'>'+nm+'<div class=btext>'+esc(m.x)+'</div><div class=bmeta>'+meta+'</div></div>'});
E('cvmsgs').innerHTML=h;if(!same||wasBottom)E('cvmsgs').scrollTop=E('cvmsgs').scrollHeight;
each(E('cvmsgs').querySelectorAll('.b'),function(b){var m=arr[+b.getAttribute('data-mi')];longPress(b,function(){if(m)msgSheet(m)})})}
function closeChat(){chat=null;E('cview').style.display='none';E('tlist').style.display=''}
function chatSend(){var v=E('cvi').value.trim();if(!v||!chat)return;E('cvi').value='';
if(chat.i>=0)wsSend('@s '+chat.i+' '+v);else if(chat.c!=null)wsSend('@sc '+chat.c+' '+v);else if(chat.h!=null)wsSend('@sh '+chat.h+' '+v)}
function conn(){WS=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/term');WS.binaryType='arraybuffer';
WS.onopen=function(){stat('online','ok');wsSend('@st');if(curTab=='chats')wsSend('@t')};
WS.onclose=function(){stat('reconnecting...','err');setTimeout(conn,1500)};WS.onerror=function(){stat('error','err')};
WS.onmessage=function(e){var b=new Uint8Array(e.data);if(!b.length)return;var ty=b[0],p=dec.decode(b.subarray(1));if(ty==0)tadd(p);else if(ty==1)onData(p)}}
each(document.querySelectorAll('#tabs button'),function(b){b.onclick=function(){showTab(b.getAttribute('data-t'))}});
E('scrim').onclick=closeOv;E('modal').onclick=function(e){if(e.target==E('modal'))closeOv()};E('qcmd').onclick=cmdPicker;E('gear').onclick=function(){wsSend('@sg')};
E('csort').onclick=function(){sheet('Sort by',SORTL.map(function(s,i){return {label:(i==csort?'\u2713 ':'')+s,fn:function(){csort=i;renderContacts()}}}))};
E('cfilt').onclick=function(){sheet('Filter',FILTL.map(function(f,i){return {label:(i==cfilter?'\u2713 ':'')+f,fn:function(){cfilter=i;renderContacts()}}}))};
E('csearch').addEventListener('input',function(){csearchq=E('csearch').value.trim().toLowerCase();renderContacts()});
E('cvback').onclick=function(){closeChat();wsSend('@t')};E('cvsend').onclick=chatSend;
E('cvi').addEventListener('keydown',function(e){if(e.key=='Enter'){chatSend();e.preventDefault()}});
E('i').addEventListener('keydown',function(e){if(e.key=='Enter'){tsend();e.preventDefault()}
else if(e.key=='ArrowUp'){if(hi>0){hi--;E('i').value=hist[hi];e.preventDefault()}}
else if(e.key=='ArrowDown'){if(hi<hist.length-1){hi++;E('i').value=hist[hi]}else{hi=hist.length;E('i').value=''}e.preventDefault()}
else if(e.key=='Tab'){var q=E('i').value.toLowerCase(),m=CMDS.filter(function(c){return c.indexOf(q)==0});
if(m.length==1)E('i').value=m[0];else if(m.length>1)tadd('\n'+m.join(' ').trim()+'\n','e');e.preventDefault()}});
tadd('wadamesh terminal - node config + messaging. help = all commands.\n\n','e');
conn();showTab('chats');
</script></body></html>
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
Offline demo server for the wadamesh web control panel.
Serves the extracted index.html at GET / and speaks the device's /term
WebSocket protocol with hard-coded demo data, so the real SPA renders exactly
as it does on the device — just with clean documentation data. No firmware, no
device, no third-party packages (WebSocket handshake + framing done by hand).
python3 scripts/doc/web-demo/serve.py [port] # default 8791
Then open http://127.0.0.1:<port>/ and screenshot. Runs in the foreground.
Protocol (mirrors WebSocketCompanionServer.cpp + handleWebDataCmd in UITask.cpp):
* Browser opens ws://host/term, sends short text commands.
* '@'-prefixed commands are the data API; the reply is ONE binary frame whose
payload is 0x01 followed by JSON.
* Any other command is a terminal CLI line; the reply is a binary frame whose
payload is 0x00 followed by text.
"""
import base64
import hashlib
import json
import os
import re
import socket
import struct
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import demo # noqa: E402 (local module)
INDEX = os.path.join(HERE, "index.html")
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 magic
# --------------------------------------------------------------------------- #
# Command -> demo payload dispatch. Mirrors handleWebDataCmd() exactly.
# Returns (kind, data): kind 'json' -> data is a dict (framed 0x01),
# kind 'text' -> data is a str (framed 0x00),
# kind None -> no reply (matches the firmware swallowing
# mutation @-commands that only refresh).
# --------------------------------------------------------------------------- #
def dispatch(cmd):
now = int(time.time())
if not cmd:
return (None, None)
# Terminal CLI line (not '@') -> text reply.
if cmd[0] != "@":
return ("text", demo.terminal_reply(now, cmd))
a = cmd[1:]
def is_word(prefix):
# matches handleWebDataCmd's "a[0]==.. && (next==0 || ' ')" tests
return a == prefix or a.startswith(prefix + " ")
def int_arg(after):
# atoi of the tail starting at index `after`
m = re.match(r"\s*(-?\d+)", a[after:])
return int(m.group(1)) if m else 0
if is_word("c"):
return ("json", demo.contacts_payload(now))
if is_word("t"):
return ("json", demo.threads_payload(now))
if is_word("dc"):
return ("json", demo.discovered_payload(now))
if is_word("sg"):
return ("json", demo.settings_payload(now))
if is_word("st"):
return ("json", demo.status_payload(now))
if a.startswith("m "):
return ("json", demo.messages_payload(now, int_arg(2)))
if a.startswith("oc "):
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
if a.startswith("oh "):
# open channel by slot: demo channels have no thread -> empty chat keyed to slot
slot = int_arg(3)
return ("json", _empty_channel_chat(slot))
if a.startswith("ic "):
return ("json", demo.contact_info_payload(now, int_arg(3)))
if a.startswith("sr "):
# apply radio -> echo settings back (firmware calls webPushSettings)
return ("json", demo.settings_payload(now))
# Send / mutate commands: the firmware pushes a refresh (messages/threads/
# contacts). For a static demo we re-emit the relevant view so the UI stays
# coherent if a screenshot script drives a send, but the canned data itself
# doesn't change.
if a.startswith("s "):
return ("json", demo.messages_payload(now, int_arg(2)))
if a.startswith("sc "):
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
if a.startswith("sh "):
return (None, None)
if a.startswith("ad "):
return ("json", demo.discovered_payload(now)) # refresh discovered
if a[:3] in ("fv ", "bk ", "pr ", "xc "):
return ("json", demo.contacts_payload(now)) # refresh contacts
if a.startswith("xt ") or a.startswith("d ") or a.startswith("r "):
return ("json", demo.threads_payload(now)) # refresh threads
# unknown '@' -> swallow (firmware returns true without replying)
return (None, None)
def _thread_for_contact(cidx):
# map a contact index to its thread index using demo data (or -1)
try:
return demo._CONTACTS[cidx]["t"]
except (IndexError, KeyError):
return -1
def _empty_channel_chat(slot):
name = "Channel"
for ch in demo._CHANNELS:
if ch["slot"] == slot:
name = ch["n"]
break
return {"t": "m", "i": -1, "h": slot, "ch": 1, "n": name, "msgs": []}
# --------------------------------------------------------------------------- #
# Minimal WebSocket framing (server -> client is never masked; client -> server
# is always masked per RFC 6455).
# --------------------------------------------------------------------------- #
def ws_encode(payload_bytes, opcode=0x2):
"""Build a single unmasked frame. opcode 0x2 = binary (what the SPA expects)."""
fin_op = 0x80 | opcode
n = len(payload_bytes)
if n < 126:
header = struct.pack("!BB", fin_op, n)
elif n < 65536:
header = struct.pack("!BBH", fin_op, 126, n)
else:
header = struct.pack("!BBQ", fin_op, 127, n)
return header + payload_bytes
def _recv_exact(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def ws_read_frame(sock):
"""Read one client frame. Returns (opcode, payload_bytes) or (None, None) on close."""
hdr = _recv_exact(sock, 2)
if not hdr:
return (None, None)
b0, b1 = hdr[0], hdr[1]
opcode = b0 & 0x0f
masked = b1 & 0x80
length = b1 & 0x7f
if length == 126:
ext = _recv_exact(sock, 2)
if ext is None:
return (None, None)
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = _recv_exact(sock, 8)
if ext is None:
return (None, None)
length = struct.unpack("!Q", ext)[0]
mask = b"\x00\x00\x00\x00"
if masked:
mask = _recv_exact(sock, 4)
if mask is None:
return (None, None)
payload = _recv_exact(sock, length) if length else b""
if payload is None:
return (None, None)
if masked:
payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
return (opcode, payload)
def frame_reply(kind, data):
if kind == "json":
return ws_encode(b"\x01" + json.dumps(data, ensure_ascii=False).encode("utf-8"))
if kind == "text":
return ws_encode(b"\x00" + data.encode("utf-8"))
return None
# --------------------------------------------------------------------------- #
# HTTP / WebSocket handling on a raw socket.
# --------------------------------------------------------------------------- #
def read_http_request(sock):
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
return None, None, None
data += chunk
if len(data) > 65536:
break
head, _, _rest = data.partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
if not lines:
return None, None, None
request_line = lines[0]
parts = request_line.split(" ")
method = parts[0] if parts else ""
path = parts[1] if len(parts) > 1 else "/"
headers = {}
for ln in lines[1:]:
if ":" in ln:
k, v = ln.split(":", 1)
headers[k.strip().lower()] = v.strip()
return method, path, headers
def serve_http_index(sock):
with open(INDEX, "rb") as f:
body = f.read()
resp = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/html; charset=utf-8\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n"
b"Cache-Control: no-store\r\n"
b"\r\n" + body
)
sock.sendall(resp)
def serve_http_404(sock):
body = b"not found"
sock.sendall(
b"HTTP/1.1 404 Not Found\r\nContent-Length: " + str(len(body)).encode()
+ b"\r\nConnection: close\r\n\r\n" + body
)
def do_ws_handshake(sock, headers):
key = headers.get("sec-websocket-key")
if not key:
serve_http_404(sock)
return False
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
resp = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: " + accept + "\r\n"
"\r\n"
)
sock.sendall(resp.encode())
return True
def handle_ws(sock):
# Periodic status pushes, like the device's 3 s refresh, on a background thread.
alive = {"v": True}
lock = threading.Lock()
def push(frame):
if frame is None:
return
with lock:
try:
sock.sendall(frame)
except OSError:
alive["v"] = False
def status_loop():
while alive["v"]:
time.sleep(3.0)
if not alive["v"]:
break
push(frame_reply("json", demo.status_payload(int(time.time()))))
t = threading.Thread(target=status_loop, daemon=True)
t.start()
try:
while alive["v"]:
opcode, payload = ws_read_frame(sock)
if opcode is None:
break
if opcode == 0x8: # close
break
if opcode == 0x9: # ping -> pong
push(ws_encode(payload, opcode=0xA))
continue
if opcode == 0xA: # pong
continue
if opcode not in (0x1, 0x2):
continue
# strip trailing CR/LF like the firmware does
text = payload.decode("utf-8", "replace").rstrip("\r\n")
if not text:
continue
kind, data = dispatch(text)
push(frame_reply(kind, data))
finally:
alive["v"] = False
try:
sock.close()
except OSError:
pass
def handle_client(sock, addr):
try:
method, path, headers = read_http_request(sock)
if method is None:
sock.close()
return
upgrade = headers.get("upgrade", "").lower() == "websocket"
# the SPA connects to /term; accept any path for the WS upgrade to be lenient
if upgrade:
if not do_ws_handshake(sock, headers):
return
handle_ws(sock)
return
if method == "GET" and path.split("?")[0] in ("/", "/index.html"):
serve_http_index(sock)
else:
serve_http_404(sock)
sock.close()
except (OSError, UnicodeDecodeError):
try:
sock.close()
except OSError:
pass
def main():
port = 8791
if len(sys.argv) > 1:
try:
port = int(sys.argv[1])
except ValueError:
print("usage: serve.py [port]", file=sys.stderr)
sys.exit(2)
if not os.path.exists(INDEX):
print("index.html missing — run: python3 %s/extract.py" % HERE, file=sys.stderr)
sys.exit(1)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(16)
url = "http://127.0.0.1:%d/" % port
print("wadamesh web-demo serving at %s" % url)
print(" (Ctrl-C to stop)")
try:
while True:
client, addr = srv.accept()
client.settimeout(30)
threading.Thread(target=handle_client, args=(client, addr), daemon=True).start()
except KeyboardInterrupt:
print("\nstopping.")
finally:
srv.close()
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
// Automated screenshots of the wadamesh web control panel demo, to doc-shots/web_*.png.
// Start the demo server first: python3 scripts/doc/web-demo/serve.py 8791
// Then: NODE_PATH=<playwright node_modules> node scripts/doc/web-demo/shoot.js [port] [outdir]
const { chromium } = require('playwright');
const PORT = process.argv[2] || '8791';
const OUT = process.argv[3] || 'doc-shots';
const URL = `http://127.0.0.1:${PORT}/`;
const path = require('path');
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 390, height: 780 },
deviceScaleFactor: 2, // crisp @2x for docs
});
const shot = async (name) => {
const p = path.join(OUT, `web_${name}.png`);
await page.screenshot({ path: p });
console.log(' wrote', p);
};
await page.goto(URL, { waitUntil: 'networkidle' });
await page.waitForSelector('#tlist .row', { timeout: 8000 });
await sleep(600);
// 1. Chats tab (default)
await shot('chats');
// 2. An open chat (first thread) with bubbles + delivery meta
await page.click('#tlist .row');
await page.waitForSelector('#cview', { state: 'visible', timeout: 5000 });
await page.waitForSelector('#cvmsgs .b', { timeout: 5000 });
await sleep(500);
await shot('chat');
await page.click('#cvback'); await sleep(300);
// 3. Contacts tab
await page.click('#tabs button[data-t=contacts]');
await page.waitForSelector('#clist .row', { timeout: 5000 });
await sleep(400);
await shot('contacts');
// 4. Contact action sheet (tap a contact row)
await page.click('#clist .row[data-c]');
await page.waitForSelector('#sheet.on', { timeout: 5000 });
await sleep(400);
await shot('contact_sheet');
await page.click('#scrim'); await sleep(400);
// 5. Discovered nodes sheet
await page.click('#discrow');
await page.waitForSelector('#sheet.on', { timeout: 5000 });
await sleep(400);
await shot('discovered');
await page.click('#scrim'); await sleep(400);
// 6. Terminal tab with some output
await page.click('#tabs button[data-t=term]');
await page.waitForSelector('#i', { timeout: 5000 });
for (const cmd of ['status', 'ver']) {
await page.fill('#i', cmd);
await page.press('#i', 'Enter');
await sleep(500);
}
await sleep(400);
await shot('terminal');
// 7. Settings modal (gear)
await page.click('#gear');
await page.waitForSelector('#modal.on', { timeout: 5000 });
await sleep(400);
await shot('settings');
await browser.close();
console.log('done.');
})().catch(e => { console.error('FAILED:', e); process.exit(1); });
+4 -2
View File
@@ -26,8 +26,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/out/firmware" # local mirror of the VPS firmware root
DEST="${WADAMESH_VPS:-}"; DEST_PATH="${WADAMESH_VPS_PATH:-/srv/wadamesh/firmware}"
# env:binname pairs — plain string form (works on macOS's bash 3.2; no associative arrays)
ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2"
# 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
+56 -8
View File
@@ -5,7 +5,7 @@
#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)
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
#include <SD_MMC.h>
#endif
@@ -277,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
@@ -420,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;
@@ -462,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;
@@ -478,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
@@ -504,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)) {
@@ -525,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) {
@@ -835,8 +883,8 @@ bool DataStore::useSdStorage() {
}
#endif
#if defined(HAS_TANMATSU)
// Tanmatsu: same full-store adoption as useSdStorage(), but on the SD_MMC slot.
#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
+3 -3
View File
@@ -43,9 +43,9 @@ public:
// if the dir can't be created. No-op on non-ESP32.
bool useSdStorage();
#endif
#if defined(HAS_TANMATSU)
// Full-store adoption of the SD_MMC card (Tanmatsu): identity + prefs move off
// the broken-metadata internal FFat (its exists()/f_stat lie, which made the
#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
+95 -13
View File
@@ -8,8 +8,8 @@
#include <time.h> // gmtime_r for the "clock" CLI command
#ifdef ESP32
#include <esp_system.h> // esp_restart for the "bootloader" CLI command
#if !defined(HAS_TANMATSU)
#include <soc/rtc_cntl_reg.h> // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; P4 lacks it)
#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4)
#include <soc/rtc_cntl_reg.h> // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; both P4 boards lack it)
#endif
#endif
#include <helpers/AdvertDataHelpers.h>
@@ -142,6 +142,9 @@ static int s_companion_ota_pinned_reply_target = -1;
// 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
// Minimum gap between contacts saves that were triggered by an ADD/REMOVE (count change).
// Coalesces a burst of newly-heard nodes into one atomic rewrite instead of one per node.
#define CONTACTS_ADD_SAVE_MIN_INTERVAL 30000
// 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
@@ -586,7 +589,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
@@ -2241,14 +2244,30 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
if (_ui_login_then && len > 4 && memcmp(&_ui_login_then, contact.id.pub_key, 4) == 0) {
const bool login_ok = (data[4] == RESP_SERVER_LOGIN_OK)
|| (len > 5 && memcmp(&data[4], "OK", 2) == 0);
const UiReqKind k = _ui_login_then_kind;
cancelUIDeferredLogin();
if (login_ok) {
ContactInfo& rc = const_cast<ContactInfo&>(contact);
if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc);
else sendStatusPingForUI(rc);
// The comment above assumes ANY response arriving while armed must be our login reply.
// That holds for the UI, but the COMPANION APP can have its own STATUS / TELEMETRY /
// BINARY request in flight to this same node, and there is no distinct "login failed"
// code on the wire (a failure is merely "not RESP_SERVER_LOGIN_OK"), so a non-OK frame
// is indistinguishable from the app's reply. Swallowing it here ate the app's response
// and returned, leaving the phone waiting forever — a contributor to the "no ping or
// telemetry response, from the device AND the app" reports (#124).
// A LOGIN_OK is unambiguously ours, so always take it. Otherwise, if the app is waiting
// on this same contact, fall through and let its matcher have the frame; our own reply
// deadline still disarms us, which is all the early disarm here ever bought.
const bool app_waiting =
(pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0) ||
(pending_telemetry && memcmp(&pending_telemetry, contact.id.pub_key, 4) == 0) ||
(pending_req && memcmp(&pending_req, contact.id.pub_key, 4) == 0);
if (login_ok || !app_waiting) {
const UiReqKind k = _ui_login_then_kind;
cancelUIDeferredLogin();
if (login_ok) {
ContactInfo& rc = const_cast<ContactInfo&>(contact);
if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc);
else sendStatusPingForUI(rc);
}
return; // login frame consumed (OK fired the REQ; fail disarmed)
}
return; // login frame consumed (OK fired the REQ; fail disarmed)
}
#endif
@@ -2401,6 +2420,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;
@@ -2420,6 +2455,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);
@@ -2541,7 +2608,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
@@ -2557,7 +2624,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",
@@ -3356,7 +3423,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();
@@ -4024,6 +4091,7 @@ void MyMesh::saveContacts() {
// freshly-written contact set + resets the refresh window.
_last_saved_contacts_n = getNumContacts();
_next_contacts_refresh_save = futureMillis(CONTACTS_REFRESH_SAVE_INTERVAL);
_next_contacts_add_save = futureMillis(CONTACTS_ADD_SAVE_MIN_INTERVAL);
}
void MyMesh::enterCLIRescue() {
@@ -4486,6 +4554,20 @@ void MyMesh::loop() {
&& !millisHasNowPassed(_next_contacts_refresh_save)) {
defer = true; // no add/remove + still inside the refresh window on GC-prone flash
}
// ...but a CHANGED contact count used to bypass the window entirely and rewrite the whole
// table immediately, every time. On a busy mesh that is the common case, not the rare one:
// each newly-heard node auto-added (and every transient/anon slot) bumps the count, so a
// burst of new nodes = a burst of full 30 KB rewrites 5 s apart, which is what drives the
// GC stalls a reporter measured at 11-19 s on two card-less V4s. Give add/remove its own
// short floor so a burst coalesces into ONE rewrite. Deliberately short (30 s, vs 5 min for
// pure refreshes): a new node still needs to reach flash promptly, and an unclean power loss
// inside the window only costs a rediscovery on that node's next advert, whereas the stall
// it prevents freezes the entire UI. The write stays ATOMIC (tmp + rename) — important on
// hardware that browns out, which is exactly what these reporters' boards do.
if (!defer && _store->contactsOnInternalFlash()
&& !millisHasNowPassed(_next_contacts_add_save)) {
defer = true;
}
#endif
if (defer) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // re-check soon; don't rewrite yet
+104
View File
@@ -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,6 +884,27 @@ 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
@@ -927,7 +1001,29 @@ private:
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
return _store->getBlobByKey(key, key_len, dest_buf);
}
// The core calls this on EVERY received advert (BaseChatMesh::onAdvertRecv, outside the
// auto-add block, so for known contacts too) and each call is a full create+truncate+write
// of a small file. On a card-less board that file lives on SPIFFS, so ~200 known nodes
// re-advertising means a flash write every few seconds forever — churn that keeps the
// partition permanently GC-prone and feeds the multi-second "mesh" loop stalls.
//
// The blob is the raw advert packet and its ONLY consumer is Share/export contact
// (BaseChatMesh::shareContact via getBlobByKey). Its content for a given node is
// effectively static — name, type, location. So persist it ONCE PER KEY PER BOOT and skip
// the redundant rewrites: after the first advert from each node the packet path does no
// flash I/O at all. Reads are unaffected because getBlobByKey above still reads the file we
// already wrote, so there is no cache to keep coherent. A node that RENAMES itself keeps its
// old shared blob until the next reboot, which is a fair price for removing the churn.
// Cost: kBlobSeenSlots * 4 bytes of DRAM. Move to PSRAM if static DRAM ever gets tight.
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override {
if (key_len >= 4) {
uint32_t pfx;
memcpy(&pfx, key, 4);
if (pfx == 0) pfx = 1; // 0 is the empty-slot marker
const uint16_t slot = (uint16_t)((pfx ^ (pfx >> 13) ^ (pfx >> 23)) & (kBlobSeenSlots - 1));
if (_blob_seen[slot] == pfx) return true; // already on flash this boot — skip the write
_blob_seen[slot] = pfx; // collision just costs one extra write later
}
return _store->putBlobByKey(key, key_len, src_buf, len);
}
@@ -990,6 +1086,14 @@ private:
// the first save. Kept in sync by every MyMesh::saveContacts() call.
int _last_saved_contacts_n = -1;
uint32_t _next_contacts_refresh_save = 0;
// Separate, much SHORTER floor for saves caused by an add/remove. Those used to bypass the
// refresh window completely, so on a growing mesh every newly-heard node forced its own full
// rewrite. See CONTACTS_ADD_SAVE_MIN_INTERVAL.
uint32_t _next_contacts_add_save = 0;
// Pubkey prefixes whose advert blob has already been persisted this boot — see
// putBlobByKey. Sized above a full contact list so the common case never thrashes.
static const uint16_t kBlobSeenSlots = 512; // power of two (mask), 2 KB total
uint32_t _blob_seen[kBlobSeenSlots] = {0};
TransportKey send_scope;
TransportKey _chan_scope_saved; // push/popChannelScope stash
+56 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include <helpers/AutoDiscoverRTCClock.h>
#include <sys/time.h>
// Monotonic clock floor for outgoing protocol timestamps (issue #89 follow-up).
//
@@ -35,21 +36,74 @@ 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);
pushSystemClock(time);
if (_floor > time + TRUSTED_BACK_CAP) _floor = time;
}
// The UI reads the ESP32 *system* clock for every displayed time (status bar, chat
// bubbles, logs — see the note at addMessage()), while protocol timestamps come from
// this class. On a board with NO RTC chip they are the same clock: AutoDiscoverRTCClock
// falls through to ESP32RTCClock, whose set IS settimeofday(), so the two can never
// disagree. With a chip present (the T-Display P4 and the ThinkNode M9 both carry a
// PCF8563 at 0x51 and call rtc_clock.begin(); the T-LoRa Pager has one too but
// deliberately skips begin(), since its PCF85063A is register-incompatible with the
// core's 8563 driver) the base class writes ONLY the chip, so the system clock keeps
// ESP32RTCClock::begin()'s power-on seed forever. That seed is
// exactly MIN_VALID_EPOCH, which is why the P4 showed 15 May 2024 in the UI while sent
// messages carried the correct time — and why "Sync clock from system" then poisoned
// the good clock: it fed that seed back in as a real set, clearing the MIN check by
// being precisely equal to it.
//
// So mirror every ACCEPTED value into the system clock. The validation above has
// already run, so 1902/2043-class garbage never reaches the display either.
static void pushSystemClock(uint32_t t) {
struct timeval tv;
tv.tv_sec = (time_t)t;
tv.tv_usec = 0;
settimeofday(&tv, nullptr);
}
// Boot seed. An RTC chip is battery-backed, so it already knows the time while the
// system clock is still on the power-on seed — pull it across once so the UI reads
// correctly with no network and no GPS fix. Goes through getCurrentTime(), so the
// garbage-future hatch and the persisted floor both apply. No-op on the chipless
// boards (it reads the system clock and writes the same value back).
void seedSystemClock() {
const uint32_t t = getCurrentTime();
if (t >= MIN_VALID_EPOCH && t <= MAX_PLAUSIBLE_EPOCH) pushSystemClock(t);
}
// 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; }
};
+6 -4
View File
@@ -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 -3
View File
@@ -3,12 +3,12 @@
#include <Arduino.h>
#if defined(HAS_TANMATSU)
// ---- MQTT DISABLED on the Tanmatsu (ESP32-P4) ----
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ----
// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient
// path this bridge uses is not wired for that and crashed at boot. Provide a no-op bridge with the
// SAME public API so the shared companion + settings code links without pulling PubSubClient into
// the P4 build. Re-enable once esp-hosted TCP is proven on the Tanmatsu.
// the P4 build. Re-enable once esp-hosted TCP is proven on the P4.
class MqttBridge {
public:
void begin(const char* /*nodeHex*/) {}
@@ -33,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;
@@ -42,8 +55,58 @@ 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
@@ -58,6 +121,9 @@ static void wsMirrorStreamTask(void* arg) {
}
void MultiTransportCompanionInterface::tickWebSocketHandshake() {
#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
@@ -76,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;
}
@@ -251,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,18 @@ 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;
+29 -16
View File
@@ -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);
}
}
+14
View File
@@ -3,6 +3,16 @@
#include <helpers/BaseSerialInterface.h>
#include <WiFi.h>
#if defined(HAS_TDISPLAY_P4)
// T-Display P4: TCP/IP lives on the C6 (ESP-AT over SDIO) — rebind the server + client types to
// the c6_at facades BEFORE the class definitions below so every TU sees one consistent layout.
// Inbound listening = AT+CIPSERVER (single port; it's this companion server's).
#include <C6WifiShim.h>
#include <C6Socket.h>
#define WiFiClient C6Client
#define WiFiServer C6Server
#endif
#ifndef TCP_COMPANION_MAX_CLIENTS
#define TCP_COMPANION_MAX_CLIENTS 3
#endif
@@ -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];
+73 -4
View File
@@ -38,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 = 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)
static const uint8_t TOUCH_CFG_VER = 42; // 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); v40 hist_sync_after (chat-history flush: consecutive off-thread write failures before the blocking loop-task fallback, 0 = never); v41 p4_antenna (T-Display P4 antenna select; now RESERVED/unused - the choice is session-only so every boot comes up on the on-board antenna); v42 hist_per_chat (max stored messages PER chat, default 250 - a busy public channel used to be able to fill the whole shared ring and drag the UI down)
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -111,6 +111,9 @@ struct __attribute__((packed)) TouchCfg {
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)
uint8_t hist_sync_after; // chat-history flush: consecutive off-thread write failures before falling back to the blocking loop-task write; 0 = never — v40 (trailing)
uint16_t hist_per_chat; // max stored messages per chat/thread; 0 = no per-chat cap (ring only) - v42 (trailing)
uint8_t p4_antenna; // RESERVED (was: T-Display P4 antenna select). The antenna choice is session-only by design and is never stored — see the note further down — v41 (trailing)
};
static TouchCfg s_cfg;
@@ -157,6 +160,8 @@ static void cfgSetDefaults(TouchCfg& c) {
c.lock_color = DEFAULT_LOCK_COLOR;
c.accent = DEFAULT_ACCENT;
c.gps_baud = 0; // 0 sentinel -> getter returns caller fallback
c.hist_per_chat = 250; // keep the newest 250 per chat: enough to scroll back, small enough to stay quick
c.p4_antenna = 0; // reserved, unused: the P4 antenna choice is never persisted
c.sig_probe_en = DEFAULT_SIG_PROBE_EN;
c.sig_poll_min = DEFAULT_SIG_POLL_MIN;
c.tz_zone = 0; // 0 = Europe (CET/CEST) — preserves prior behaviour
@@ -207,6 +212,7 @@ static void cfgSetDefaults(TouchCfg& c) {
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)
c.hist_sync_after = 2; // chat flush: 2 failed background writes -> synchronous loop-task fallback
}
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -392,12 +398,45 @@ bool touchPrefsSetScreenTimeoutSecs(uint16_t seconds) {
}
// --- Mesh signal auto-discover probe (toggle + poll interval) ---------------
// The interval is entered in whole minutes; clamp >1 min (one flood a minute is
// already aggressive on shared airtime) .. 1 day so a bad/blank entry can't make
// the probe hammer the mesh or effectively never run.
// The interval is entered in whole minutes; clamp 1 min .. 1 day so a bad or blank entry
// can't make the probe run hot or effectively never run.
//
// NOT A FLOOD. This comment used to describe the probe as a flood, which is where the
// "wadamesh spams the mesh every 5 minutes" worry came from (issue #80). It is a ZERO-HOP
// CTL_TYPE_NODE_DISCOVER_REQ (MyMesh::uiSendSignalProbe) — the same node-discovery packet
// the other MeshCore GUIs use. Repeaters answer it DIRECTLY and never re-broadcast it, so
// nothing propagates beyond our immediate neighbours; the fallback when no repeater path is
// known is sendAdvert(false), also zero-hop. The caller additionally SKIPS the probe
// whenever a direct neighbour was heard inside the poll window, so the busier the mesh, the
// less this transmits.
static const uint16_t SIG_POLL_MIN_MINS = 1; // 1 min = 60 s (the old fixed cadence)
static const uint16_t SIG_POLL_MAX_MINS = 1440;
// --- T-Display P4 LoRa antenna select — deliberately NOT persisted -----------
// XL9535 IO1 drives the board's SKY13453 antenna switch (full reasoning in Xl9535.h). The
// getter/setter that used to live here are gone on purpose: the choice is session-only, so
// there is nothing to store. Every boot forces the on-board antenna, in two places — the park
// in Xl9535::powerOnSequence() and the re-assert in UITask::begin() — because the external
// MMCX may have no antenna fitted, and keying a PA into an open connector damages it. That
// safety property only holds if a power cycle cannot restore "external", which means the
// choice must never reach flash. p4_antenna stays as a reserved trailing byte: dropping it
// would rewind TOUCH_CFG_VER on devices already carrying a v41 blob, for no gain.
// --- Per-chat history cap -----------------------------------------------------
// A single busy channel could previously fill the entire shared message ring, which both
// starved every other chat of history and made the inbox slow (see the thread-history cache
// in UITask). This bounds each chat independently. 0 = no per-chat cap, i.e. the old
// behaviour, which the settings UI warns about rather than hiding.
uint16_t touchPrefsGetHistPerChat() {
if (!s_begun) touchPrefsBegin();
return s_cfg.hist_per_chat;
}
bool touchPrefsSetHistPerChat(uint16_t n) {
if (!s_begun) touchPrefsBegin();
s_cfg.hist_per_chat = n;
return cfgFlush();
}
bool touchPrefsGetSigProbeEnabled() {
if (!s_begun) touchPrefsBegin();
return s_cfg.sig_probe_en != 0;
@@ -928,6 +967,15 @@ bool touchPrefsSetMapNight(bool on) {
s_cfg.map_night = on ? 1 : 0;
return cfgFlush();
}
uint8_t touchPrefsGetHistSyncAfter() {
if (!s_begun) touchPrefsBegin();
return s_cfg.hist_sync_after > 9 ? 9 : s_cfg.hist_sync_after;
}
bool touchPrefsSetHistSyncAfter(uint8_t n) {
if (!s_begun) touchPrefsBegin();
s_cfg.hist_sync_after = n > 9 ? 9 : n;
return cfgFlush();
}
uint8_t touchPrefsGetMapZoom() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_zoom;
@@ -1811,10 +1859,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);
+29
View File
@@ -103,6 +103,18 @@ bool touchPrefsSetTilesFromSd(bool from_sd);
bool touchPrefsGetMapNight();
bool touchPrefsSetMapNight(bool on);
// Chat-history flush: consecutive failed off-thread (background) writes before
// the flush falls back to the blocking loop-task write (reliable, but the UI
// hitches for the write's duration). 0 = never fall back. Default 2.
uint8_t touchPrefsGetHistSyncAfter();
bool touchPrefsSetHistSyncAfter(uint8_t n);
/** Max stored messages kept per chat; 0 = no per-chat cap. Default 250.
* Without a cap one busy channel can fill the whole shared ring, starving every other
* chat of history and slowing the UI down as the ring fills. */
uint16_t touchPrefsGetHistPerChat();
bool touchPrefsSetHistPerChat(uint16_t n);
/** Last map zoom level, persisted so the map reopens where the user left it.
* 0 = unset (let the auto-snap pick a level for the available tile pack). */
uint8_t touchPrefsGetMapZoom();
@@ -378,6 +390,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. */
@@ -532,6 +558,9 @@ bool touchPrefsSetGpsBaud(uint32_t baud);
bool touchPrefsGetSigProbeEnabled();
bool touchPrefsSetSigProbeEnabled(bool on);
uint16_t touchPrefsGetSigPollMins();
/* NB: there is deliberately no touchPrefsGet/SetP4Antenna(). The T-Display P4 antenna choice is
* session-only so that every boot comes up on the on-board antenna — see the note in the .cpp. */
bool touchPrefsSetSigPollMins(uint16_t mins);
#endif
+9 -1
View File
@@ -116,7 +116,15 @@ private:
volatile uint8_t _ppressed = 0;
volatile uint8_t _pknown = 0;
static const int KEY_RING = 32; // typed keys awaiting the UI thread (SPSC)
// Typed keys awaiting the UI thread (SPSC). 32 was far too small: the browser pushes from
// the network thread while the UI drains only a handful per loop iteration, and one
// iteration can be tens of ms (LVGL frame, SD write, radio). Anything arriving FASTER than
// that drain overflowed, and pushKey discards the overflow SILENTLY -- which is exactly
// what a phone autocomplete or a paste looks like, because it delivers a whole word at
// once. Reported over the web mirror as "missing a lot of characters while typing".
// 256 entries costs 512 bytes and is the MAXIMUM this ring can hold while _khead/_ktail
// stay uint8_t; anything larger REQUIRES widening those two or the modulo silently wraps.
static const int KEY_RING = 256;
uint16_t _keys[KEY_RING];
volatile uint8_t _khead = 0, _ktail = 0;
volatile bool _kb_focused = false, _kb_dirty = false; // editable-field focus signal
+68 -37
View File
@@ -435,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;
@@ -444,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) {
@@ -529,48 +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;
_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;
adoptClient(incoming);
}
}
@@ -911,6 +925,22 @@ 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;
@@ -921,6 +951,7 @@ void WebSocketCompanionServer::drainClientTx(int idx) {
// 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
@@ -5,6 +5,17 @@
#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
@@ -68,6 +79,10 @@ 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);
+5
View File
@@ -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";
+25 -7
View File
@@ -21,6 +21,24 @@
#define PIN_TOUCH_INT -1
#endif
// --- Which I2C controller the CHSC6x lives on -------------------------------
// Plain V4: the touch has its OWN controller (Wire1) on dedicated pins 47/48,
// physically separate from the board bus (Wire on 4/3) — two controllers on two
// pad-pairs is fine, so it stays on Wire1.
// V4-R8 (Expansion Kit V2): touch + RTC + env-sensors are all unified onto
// GPIO17/18. Running the touch on a SECOND controller (Wire1) bound to the SAME
// pads as the board's Wire let the core-0 touch-poll task and the core-1 RTC/
// sensor reads collide on the wire — SDA stuck low = an unrecoverable I2C wedge,
// i.e. the "random freezes that need a reset". Share the ONE board controller
// (Wire) instead: arduino-esp32's per-instance TwoWire mutex then serialises
// every transaction (repeated-starts included) across both cores — exactly the
// proven T-Deck pattern (GT911 touch + keyboard + sensors all on one Wire).
#if defined(HELTEC_LORA_V4_R8)
#define V4CAP_WIRE Wire
#else
#define V4CAP_WIRE Wire1
#endif
#ifndef HELTEC_V4_TOUCH_LONG_MS
#define HELTEC_V4_TOUCH_LONG_MS 1000
#endif
@@ -155,8 +173,8 @@ static bool probeTouchControllerAddress() {
// Known CHSC6x addresses seen across board variants/libraries.
static const uint8_t kAddrCandidates[] = {0x2E, 0x15, 0x14};
for (uint8_t i = 0; i < sizeof(kAddrCandidates); i++) {
Wire1.beginTransmission(kAddrCandidates[i]);
uint8_t rc = Wire1.endTransmission();
V4CAP_WIRE.beginTransmission(kAddrCandidates[i]);
uint8_t rc = V4CAP_WIRE.endTransmission();
if (rc == 0) return true;
}
return false;
@@ -170,9 +188,9 @@ bool heltecV4CapTouchBegin() {
s_last_init_try_ms = now;
// Ensure bus is configured and bounded so failed probes never wedge boot/runtime.
Wire1.begin(PIN_TOUCH_SDA, PIN_TOUCH_SCL, 100000);
V4CAP_WIRE.begin(PIN_TOUCH_SDA, PIN_TOUCH_SCL, 100000);
#if defined(ESP32)
Wire1.setTimeOut(20); // milliseconds
V4CAP_WIRE.setTimeOut(20); // milliseconds
#endif
if (PIN_TOUCH_INT >= 0) pinMode(PIN_TOUCH_INT, INPUT_PULLUP);
@@ -185,13 +203,13 @@ bool heltecV4CapTouchBegin() {
// Use our own INT pin handling above; avoid library-level attachInterrupt side effects.
// Keep reset pin support so controller can still be hard-reset.
static chsc6x instance_safe(&Wire1, PIN_TOUCH_SDA, PIN_TOUCH_SCL, -1, PIN_TOUCH_RST);
static chsc6x instance_safe(&V4CAP_WIRE, PIN_TOUCH_SDA, PIN_TOUCH_SCL, -1, PIN_TOUCH_RST);
s_tp = &instance_safe;
s_tp->chsc6x_init();
// chsc6x_init() re-runs Wire begin internally, so re-apply bounded bus settings.
Wire1.begin(PIN_TOUCH_SDA, PIN_TOUCH_SCL, 100000);
V4CAP_WIRE.begin(PIN_TOUCH_SDA, PIN_TOUCH_SCL, 100000);
#if defined(ESP32)
Wire1.setTimeOut(20);
V4CAP_WIRE.setTimeOut(20);
#endif
s_init_ok = true;
return true;
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "PagerEncoder.h"
#if defined(HAS_PAGER_ENCODER) && defined(ESP32)
#include <Arduino.h>
#ifndef ROTARY_A
#define ROTARY_A 40
#endif
#ifndef ROTARY_B
#define ROTARY_B 41
#endif
#ifndef ROTARY_C
#define ROTARY_C 7
#endif
// Mechanical detent ratio: most EC11-style encoders emit 4 quadrature
// transitions per detent (rest state -> rest state). NOT independently
// verified against this exact part — confirm on hardware (Milestone 7) and
// adjust if this encoder differs.
#ifndef PAGER_ENCODER_STEPS_PER_DETENT
#define PAGER_ENCODER_STEPS_PER_DETENT 4
#endif
// Standard Gray-code quadrature transition table: index = (prev_AB << 2 |
// curr_AB), value = +1 (CW), -1 (CCW), or 0 (bounce/invalid transition).
static const int8_t kQuadTable[16] = {
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0,
};
static volatile int32_t s_raw_delta = 0;
static volatile uint8_t s_ab_state = 0;
static bool s_inited = false;
static void IRAM_ATTR pagerEncoderISR() {
const uint8_t a = (uint8_t)digitalRead(ROTARY_A);
const uint8_t b = (uint8_t)digitalRead(ROTARY_B);
const uint8_t curr = (uint8_t)((a << 1) | b);
const uint8_t idx = (uint8_t)((s_ab_state << 2) | curr);
s_raw_delta += kQuadTable[idx];
s_ab_state = curr;
}
void pagerEncoderBegin() {
if (s_inited) return;
s_inited = true;
pinMode(ROTARY_A, INPUT_PULLUP);
pinMode(ROTARY_B, INPUT_PULLUP);
pinMode(ROTARY_C, INPUT_PULLUP);
s_ab_state = (uint8_t)((digitalRead(ROTARY_A) << 1) | digitalRead(ROTARY_B));
attachInterrupt(digitalPinToInterrupt(ROTARY_A), pagerEncoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(ROTARY_B), pagerEncoderISR, CHANGE);
}
int pagerEncoderReadDelta() {
noInterrupts();
int32_t raw = s_raw_delta;
s_raw_delta = raw % PAGER_ENCODER_STEPS_PER_DETENT; // keep the partial-detent remainder
interrupts();
// Confirmed inverted on hardware (2026-07-07): kQuadTable's CW/CCW sign convention
// doesn't match this physical part's wiring -- turning the knob the way a user expects
// to move forward/down moved focus and page-scroll backward/up instead. Negate here
// (not the table) so every consumer (nav NEXT/PREV, Alt+turn tab-switch, Alt+turn page
// scroll) inherits the corrected sense from one place.
return -(int)(raw / PAGER_ENCODER_STEPS_PER_DETENT);
}
bool pagerEncoderClickHeld() {
return digitalRead(ROTARY_C) == LOW; // active-low, matches TDeckTrackball's click idiom
}
#endif
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// T-LoRa Pager rotary encoder: a true quadrature A/B pair (GPIO 40/41) with
// the center press on GPIO 7. Decoded via ISR edge-counting in the same
// spirit as TDeckTrackball.cpp's direction pins, but this is a genuine
// quadrature pair rather than 4 independent direction pulses, so the ISR does
// a standard Gray-code state-transition lookup (both edges of both A and B)
// instead of a plain per-pin counter, and pagerEncoderReadDelta() returns
// signed detents rather than a 2D motion vector.
#if defined(HAS_PAGER_ENCODER) && defined(ESP32)
#include <stdint.h>
/** Configure the A/B quadrature pins + press pin and attach the ISRs.
* One-shot. */
void pagerEncoderBegin();
/** Signed detents accumulated since the last call (0 if none). Positive =
* clockwise. Safe to call from any single consistent context. */
int pagerEncoderReadDelta();
/** True while the encoder's center button is held (active-low GPIO 7). */
bool pagerEncoderClickHeld();
#endif
+210
View File
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "PagerKeyboard.h"
#if defined(HAS_PAGER_KEYBOARD) && defined(ESP32)
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_TCA8418.h>
#include <cctype>
#ifndef KB_INT
#define KB_INT 6
#endif
#ifndef KB_BACKLIGHT
#define KB_BACKLIGHT 46
#endif
#define KB_ROWS 4
#define KB_COLS 10
// Matrix legend, s_keymap[row][col] — same physical keyboard PCB as
// trail-mate's working LR1121 pager build, cross-checked there. '\0' = no
// character at that position (dead cell, or intercepted as a modifier below).
static constexpr char s_keymap[KB_ROWS][KB_COLS] = {
{'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'},
{'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'},
{'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'},
{' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
};
// Alt layer (symbols/numbers) — this hardware has no separate physical Symbol
// key, so Alt alone drives it (matches trail-mate's has_symbol_key=false path).
static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = {
{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'},
{'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'},
{'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'},
{' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
};
// Modifier/special-key positions: 0-based (row*KB_COLS + col), matching the
// TCA8418 raw event's (code & 0x7F) - 1. Alt is a hold (symbol layer while
// held); Shift is a hold too (momentary uppercase on the base layer, real
// Shift-key semantics) — held Alt THEN a Shift press instead chords into
// Alt+Shift, reported via s_alt_shift_chord_pending. What that chord DOES is
// a UI-level decision (UITask.cpp): Caps Lock toggle while editing a text
// field, no-op otherwise — this driver has no idea which field (if any) is
// focused, so it only reports the chord, it doesn't act on it (see
// pagerKeyboardConsumeAltShiftChord()/pagerKeyboardToggleCaps()). Held Alt
// THEN a Backspace press similarly chords into Alt+Backspace
// (s_alt_backspace_chord_pending) — unlike Alt+Shift this one has a single,
// context-independent effect (jump Home, everywhere, including mid-edit),
// so it's just as driver-agnostic to report but never conditional at the UI
// layer. Note: Alt (row2,col0) and Shift (row2,col8) share row2, and
// row0/row1 col8 are 'o'/'l' — holding Alt+Shift+O or Alt+Shift+L all three
// at once will phantom-ghost a 'q'/'a' at the row2/col0 intersection
// (classic diode-less-matrix 3-key rectangle, no software fix possible);
// harmless in practice since the intended gesture is
// hold-Alt-tap-Shift-release-both, not holding all three simultaneously.
// Backspace (row2,col9) sits one column over from Shift, so Alt+Backspace
// doesn't share this exact ghosting risk with any base-layer letter.
static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers)
static constexpr uint8_t kShiftPos = 2 * KB_COLS + 8; // row2,col8 ('\0' in both layers)
static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in both layers)
static constexpr uint8_t kSpacePos = 3 * KB_COLS + 0; // row3,col0 (' ' in both layers)
static Adafruit_TCA8418 s_kb;
static bool s_inited = false;
static bool s_alt = false;
static bool s_alt_used = false; // Alt consumed as a modifier since it was last pressed
static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile
static bool s_caps = false;
static bool s_shift_held = false; // momentary Shift, mirrors s_backspace_held/s_space_held
static bool s_alt_shift_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltShiftChord()
static bool s_alt_backspace_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltBackspaceChord()
static bool s_backspace_held = false;
static bool s_space_held = false;
// Single-producer (poll) / single-consumer (UI thread) ring — same pattern as
// TDeckKeyboard.cpp; byte indices are atomic enough for SPSC without a lock.
static volatile uint8_t s_ring[16];
static volatile uint8_t s_head = 0;
static volatile uint8_t s_tail = 0;
static bool s_bl_ready = false;
// This framework's Arduino-ESP32 core only has the channel-based LEDC API
// (ledcSetup/ledcAttachPin/ledcWrite by channel — confirmed against
// esp32-hal-ledc.h, not the newer pin-based ledcAttach()). Channel 0: nothing
// else on this board claims an LEDC channel (the AW9364 display backlight is
// pulse-driven, not PWM).
static constexpr uint8_t kKbBacklightPwmChannel = 0;
static void ringPush(uint8_t c) {
const uint8_t nh = (uint8_t)((s_head + 1) & 15);
if (nh != s_tail) { // drop if the ring is full
s_ring[s_head] = c;
s_head = nh;
}
}
void pagerKeyboardBegin() {
if (s_inited) return;
s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && s_kb.matrix(KB_ROWS, KB_COLS);
if (!s_inited) return;
s_kb.flush();
pinMode(KB_INT, INPUT_PULLUP); // TCA8418 INT is open-drain active-low; not ISR-driven here (see .h)
s_kb.enableInterrupts();
}
void pagerKeyboardPoll() {
if (!s_inited) return;
while (s_kb.available()) {
const uint8_t raw = s_kb.getEvent();
if (raw == 0) break;
// TCA8418 KEY_EVENT_A bit 7: 1 = press, 0 = release (TI datasheet SCPS215E
// register description, verified directly — the Adafruit library's own
// header comment states this backwards; don't trust it).
const bool pressed = (raw & 0x80) != 0;
const uint8_t code = (uint8_t)((raw & 0x7F) - 1);
if (code == kAltPos) {
// Solo tap (press+release, nothing else in between) vs. a modifier hold
// (symbol-layer typing, or the rotary encoder's Alt+turn via
// pagerKeyboardMarkAltUsed()) — only the former queues a pending tap.
if (pressed) { s_alt = true; s_alt_used = false; }
else { if (!s_alt_used) s_alt_tap_pending = true; s_alt = false; }
continue;
}
// Any other key event while Alt is held means Alt is being used as a
// modifier, not tapped solo — cancels the pending-tap interpretation.
if (s_alt && pressed) s_alt_used = true;
if (code == kShiftPos) {
if (pressed) {
if (s_alt) s_alt_shift_chord_pending = true; // Alt (Fn) held + Shift press = chord (UI decides the effect)
else s_shift_held = true;
} else {
s_shift_held = false;
}
continue;
}
if (code == kBackspacePos) {
if (pressed) {
// Alt+Backspace is a distinct chord (jump Home, everywhere -- see
// pagerKeyboardConsumeAltBackspaceChord()), not a delete: suppress
// both the '\b' ring-push AND s_backspace_held, so the plain-
// Backspace hold gestures (back / unlock) never also see this press.
if (s_alt) s_alt_backspace_chord_pending = true;
else { s_backspace_held = true; ringPush('\b'); }
} else {
s_backspace_held = false;
}
continue;
}
if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; }
if (!pressed) continue; // base/symbol keys only emit on press
const uint8_t row = code / KB_COLS;
const uint8_t col = code % KB_COLS;
if (row >= KB_ROWS) continue; // a GPIO event outside the matrix, not a key
char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col];
if (c == '\0') continue;
// Caps Lock and a held Shift both mean "uppercase," base layer only.
if ((s_caps || s_shift_held) && !s_alt) c = (char)toupper((unsigned char)c);
ringPush((uint8_t)c);
}
}
int pagerKeyboardReadKey() {
if (s_tail == s_head) return 0;
const uint8_t c = s_ring[s_tail];
s_tail = (uint8_t)((s_tail + 1) & 15);
return c;
}
void pagerKeyboardSetBacklight(uint8_t level) {
if (!s_bl_ready) {
ledcSetup(kKbBacklightPwmChannel, 1000 /* Hz */, 8 /* bits */);
ledcAttachPin(KB_BACKLIGHT, kKbBacklightPwmChannel);
s_bl_ready = true;
}
ledcWrite(kKbBacklightPwmChannel, level);
}
bool pagerKeyboardAltHeld() { return s_alt; }
void pagerKeyboardMarkAltUsed() { s_alt_used = true; }
bool pagerKeyboardConsumeAltTap() {
if (!s_alt_tap_pending) return false;
s_alt_tap_pending = false;
return true;
}
bool pagerKeyboardBackspaceHeld() { return s_backspace_held; }
bool pagerKeyboardSpaceHeld() { return s_space_held; }
bool pagerKeyboardConsumeAltShiftChord() {
if (!s_alt_shift_chord_pending) return false;
s_alt_shift_chord_pending = false;
return true;
}
void pagerKeyboardToggleCaps() { s_caps = !s_caps; }
bool pagerKeyboardConsumeAltBackspaceChord() {
if (!s_alt_backspace_chord_pending) return false;
s_alt_backspace_chord_pending = false;
return true;
}
#endif
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// T-LoRa Pager physical QWERTY keyboard: a TCA8418 I2C matrix controller (4
// rows x 10 cols, addr 0x34) on the shared I2C bus (SDA 3 / SCL 2). Unlike
// the T-Deck's keyboard (a second MCU that resolves ASCII itself before we
// ever see a byte), the TCA8418 only reports raw row/col matrix events — the
// keymap + shift/sym/alt state machine lives HERE, so pagerKeyboardReadKey()
// produces the same final ASCII/control-code stream handleHwKey() already
// expects from the T-Deck; no UI-side changes needed to consume it.
//
// Threading: pagerKeyboardPoll() does the I2C read + keymap translation and
// must be called from a single, consistent context each tick (whichever task
// ends up owning it — wired in a later milestone; this board has no
// pre-existing shared-bus task the way the T-Deck's touch poll does).
// pagerKeyboardReadKey() only pops from a lock-free ring and is safe to call
// from the UI thread regardless of which context polls.
#if defined(HAS_PAGER_KEYBOARD) && defined(ESP32)
#include <stdint.h>
/** Bring up the TCA8418 (I2C addr 0x34, 4x10 matrix). One-shot; safe to call
* even if the chip isn't present (poll()/readKey() just stay idle). */
void pagerKeyboardBegin();
/** Drain any pending TCA8418 key events, translate through the keymap/shift-
* sym-alt state machine, and push resulting characters into the ring.
* Call from a single consistent context each tick. */
void pagerKeyboardPoll();
/** Pop the next buffered key (ASCII/control code), or 0 if none. Safe from
* the UI thread. */
int pagerKeyboardReadKey();
/** Set the keyboard backlight (0 = off, 1-255 = brightness). Applied
* immediately via LEDC PWM on GPIO 46 — unlike the T-Deck's I2C-based
* backlight, this is a plain GPIO and needs no deferred flush-on-next-poll. */
void pagerKeyboardSetBacklight(uint8_t level);
/** True while Alt is physically held (raw modifier state, tracked by
* pagerKeyboardPoll() — not a ring event, since Alt alone drives the symbol
* layer and is never itself pushed as a key). Lets other drivers build
* Alt+<gesture> shortcuts (e.g. the rotary encoder's Alt+turn tab switch)
* without a second, separate modifier concept. */
bool pagerKeyboardAltHeld();
/** Mark the currently-held Alt as "used as a modifier" — call this when some
* other gesture (e.g. the rotary encoder's Alt+turn) consumes the hold, so
* releasing Alt afterward isn't also read as a solo tap by
* pagerKeyboardConsumeAltTap(). */
void pagerKeyboardMarkAltUsed();
/** One-shot: true exactly once if Alt was pressed and released without being
* used as a modifier for anything else in between (no key typed, no
* pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a
* symbol-layer or Alt+turn hold. Consumes the pending flag on read. */
bool pagerKeyboardConsumeAltTap();
/** True while Backspace is physically held WITHOUT Alt (raw state, mirrors
* pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b'
* as before; this is for callers that want to detect a long hold separately
* (e.g. UITask's press-and-hold "back" gesture). Alt+Backspace is a
* different gesture entirely (see pagerKeyboardConsumeAltBackspaceChord())
* and never sets this or ring-pushes '\b'. */
bool pagerKeyboardBackspaceHeld();
/** True while Space is physically held (raw state, mirrors
* pagerKeyboardBackspaceHeld()). A press still immediately ring-pushes ' '
* as before, so normal typing is unaffected; this is for callers that want
* to detect a long hold separately (e.g. UITask's press-and-hold
* "lock screen" gesture). */
bool pagerKeyboardSpaceHeld();
/** One-shot: true exactly once after Alt(Fn)+Shift is chorded (Shift pressed
* while Alt is held). The driver no longer decides what this chord DOES
* (that depends on UI state — is a text field being edited? — which this
* driver has no visibility into), it only reports that the chord happened;
* consumes the pending flag on read. See pagerKeyboardToggleCaps(). */
bool pagerKeyboardConsumeAltShiftChord();
/** Toggle persistent Caps Lock. Callers gate this on the Alt+Shift chord
* above only applying while a text field is actually being edited. */
void pagerKeyboardToggleCaps();
/** One-shot: true exactly once after Alt(Fn)+Backspace is chorded (Backspace
* pressed while Alt is held) — jumps Home, everywhere (editing a field or
* not), unlike the Alt+Shift chord above. Suppresses the normal Backspace
* press entirely: no '\b' ring-push, and pagerKeyboardBackspaceHeld() never
* reports held for this press, so it can't also fire the plain-Backspace
* hold-to-back/hold-to-unlock gestures. Consumes the pending flag on read. */
bool pagerKeyboardConsumeAltBackspaceChord();
#endif
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ST7796LCDDisplay.h"
#if defined(TLORA_PAGER) && defined(ESP32)
// This panel's 222px glass is narrower than the ST7796 controller's native
// (320px) GRAM. TFT_eSPI's own ST7796_Rotation.h already has the fix
// (colstart=49/rowstart=49 depending on rotation, applied automatically by
// every setAddrWindow() call) -- but only when CGRAM_OFFSET is defined.
// Unlike ST7789_Defines.h (which self-defines it), ST7796_Defines.h does not.
// Without this flag the build succeeds but every frame renders shifted/
// cropped by 49px with no error -- turn that into a build failure instead.
#if defined(ST7796_DRIVER) && !defined(CGRAM_OFFSET)
#error "ST7796LCDDisplay requires -D CGRAM_OFFSET=1 in this env's platformio.ini build_flags -- \
this panel's 222px glass is narrower than the ST7796 controller's 320px GRAM, and TFT_eSPI's \
ST7796_Rotation.h only applies the required 49px column/row offset when CGRAM_OFFSET is defined."
#endif
#ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 3 // landscape, matches the other boards' default
#endif
#ifndef DISPLAY_SCALE_X
#define DISPLAY_SCALE_X 1.0f // native res == logical res; kept for shape-parity with ST7789LCDDisplay
#endif
#ifndef DISPLAY_SCALE_Y
#define DISPLAY_SCALE_Y 1.0f
#endif
bool ST7796LCDDisplay::begin() {
if (!_isOn) {
display.init(); // reads TFT_WIDTH/HEIGHT/ST7796_DRIVER/TFT_* pins from build flags; brings up its own SPI bus
// REQUIRED on this panel: TFT_eSPI's generic ST7796_Init.h never sends an
// inversion command (0x20/0x21) at all, leaving colours at the glass's own
// power-on default -- which on this pager's specific ST7796 panel batch is
// inverted (confirmed on hardware: white background / black logo instead
// of the intended dark theme, everything else -- centering, timing,
// encoder/keyboard wake -- working correctly). trail-mate's own bespoke
// ST7796 init table explicitly sends INVON (0x21) for this exact part,
// which is why their build never showed the problem. TFT_eSPI's
// invertDisplay() sends the same command (doubled, per its own comment,
// "otherwise it does not always work").
display.invertDisplay(true);
// TEMPORARY M7 bring-up diagnostic (remove once the panel is confirmed
// solid): read the controller's own status registers back over SPI. A
// healthy post-init ST7796 reports RDDPM(0x0A)=0x9C (booster on, sleep
// out, display on) and RDDCOLMOD(0x0C)=0x55 (16bpp). All-0x00/0xFF here
// means the controller isn't responding at all (reset/power/SPI fault) —
// distinguishing "panel never initialized" from "panel fine, backlight
// dark", which look identical on the glass.
Serial.printf("[DISP] RDDPM=0x%02X MADCTL=0x%02X COLMOD=0x%02X RDDIM=0x%02X\n",
display.readcommand8(0x0A), display.readcommand8(0x0B),
display.readcommand8(0x0C), display.readcommand8(0x0D));
display.setRotation(DISPLAY_ROTATION);
setLogicalSize((int)(display.width() / DISPLAY_SCALE_X), (int)(display.height() / DISPLAY_SCALE_Y));
display.setAttribute(CP437_SWITCH, true);
display.fillScreen(TFT_BLACK);
display.setTextColor(TFT_WHITE);
display.setTextSize((uint8_t)(2 * DISPLAY_SCALE_X));
_backlight.begin(PIN_TFT_LEDA_CTL); // claims the pin itself (pinMode + initial LOW) -- don't also drive it here
setBrightness(_brightness_pct);
_isOn = true;
}
return true;
}
void ST7796LCDDisplay::turnOn() { ST7796LCDDisplay::begin(); }
void ST7796LCDDisplay::turnOff() {
if (_isOn) {
const uint8_t keep = _brightness_pct;
setBrightness(0); // backlight chip actually off (single EN-low)...
_brightness_pct = keep; // ...but turnOn()'s begin() must restore the pre-off level, not 0
display.writecommand(TFT_DISPOFF); // TFT_RST is on the XL9555 (board class owns it), so sleep via command here
_isOn = false;
}
}
void ST7796LCDDisplay::clear() { display.fillScreen(TFT_BLACK); }
void ST7796LCDDisplay::startFrame(Color bkg) { (void)bkg; display.fillScreen(TFT_BLACK); }
void ST7796LCDDisplay::setTextSize(int sz) { display.setTextSize((uint8_t)(sz * DISPLAY_SCALE_X)); }
void ST7796LCDDisplay::setColor(Color c) {
switch (c) {
case DisplayDriver::DARK: _color = TFT_BLACK; break;
case DisplayDriver::LIGHT: _color = TFT_WHITE; break;
case DisplayDriver::RED: _color = TFT_RED; break;
case DisplayDriver::GREEN: _color = TFT_GREEN; break;
case DisplayDriver::BLUE: _color = TFT_BLUE; break;
case DisplayDriver::YELLOW: _color = TFT_YELLOW; break;
case DisplayDriver::ORANGE: _color = TFT_ORANGE; break;
default: _color = TFT_WHITE; break;
}
display.setTextColor(_color);
}
void ST7796LCDDisplay::setCursor(int x, int y) {
display.setCursor((int16_t)(x * DISPLAY_SCALE_X), (int16_t)(y * DISPLAY_SCALE_Y));
}
void ST7796LCDDisplay::print(const char* str) { display.print(str); }
void ST7796LCDDisplay::fillRect(int x, int y, int w, int h) {
display.fillRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color);
}
void ST7796LCDDisplay::drawRect(int x, int y, int w, int h) {
display.drawRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color);
}
void ST7796LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
// TFT_eSPI has no native XBM primitive either -- same manual bit-unpack loop as ST7789LCDDisplay.
uint8_t byteWidth = (w + 7) / 8;
for (int j = 0; j < h; j++) {
for (int i = 0; i < w; i++) {
uint8_t byte = bits[j * byteWidth + i / 8];
if (byte & (0x80 >> (i & 7))) {
for (int dy = 0; dy < DISPLAY_SCALE_Y; dy++) {
for (int dx = 0; dx < DISPLAY_SCALE_X; dx++) {
display.drawPixel((int32_t)(x * DISPLAY_SCALE_X + i * DISPLAY_SCALE_X + dx),
(int32_t)(y * DISPLAY_SCALE_Y + j * DISPLAY_SCALE_Y + dy), _color);
}
}
}
}
}
}
uint16_t ST7796LCDDisplay::getTextWidth(const char* str) {
return (uint16_t)(display.textWidth(str) / DISPLAY_SCALE_X);
}
void ST7796LCDDisplay::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) {
if (!_isOn || !pixels || w <= 0 || h <= 0) return;
display.startWrite();
display.setAddrWindow(x, y, w, h); // colstart/rowstart (CGRAM_OFFSET) applied here automatically
display.pushColors(const_cast<uint16_t*>(pixels), (uint32_t)(w * h)); // default swap=true matches LVGL's RGB565 buffers
display.endWrite();
}
void ST7796LCDDisplay::endFrame() { /* no-op: pushColors' own endWrite() already closed the SPI transaction */ }
void ST7796LCDDisplay::setDisplayRotation(uint8_t r) {
display.setRotation(r);
setLogicalSize((int)(display.width() / DISPLAY_SCALE_X), (int)(display.height() / DISPLAY_SCALE_Y));
}
void ST7796LCDDisplay::setBrightness(uint8_t pct) {
if (pct > 100) pct = 100;
// AW9364LedDriver::setBrightness() is relative/circular pulse-stepping (no absolute-set wire
// command on this chip) -- round to the nearest of its 17 discrete steps (0..16), not floor,
// so e.g. 50% lands on step 8 rather than 7.
uint8_t steps = (uint8_t)(((uint16_t)pct * 16 + 50) / 100);
_backlight.setBrightness(steps);
_brightness_pct = pct;
}
#endif
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// T-LoRa Pager 480x222 (landscape) / 222x480 (native) ST7796 panel, driven
// via TFT_eSPI — the first real TFT_eSPI consumer in this codebase (the
// Heltec V4 env's TFT_eSPI lib_dep/-D flags are vestigial: nothing in the
// repo actually #includes TFT_eSPI.h; both Heltec V4 and T-Deck's
// ST7789LCDDisplay are built on Adafruit_GFX/Adafruit_ST7789 instead). Only
// the DisplayDriver-satisfying shape is mirrored from ST7789LCDDisplay, not
// any TFT_eSPI API calls — see the .cpp for the exact TFT_eSPI equivalents.
//
// Backlight is the AW9364 stepped pulse-dimmer IC (16 discrete steps), not a
// PWM-capable pin (TFT_BL=-1) — wrapped directly in here rather than as a
// separate Aw9364Backlight.{h,cpp}, since UITask.cpp has no display-class
// brightness hook to mirror today (brightness there is raw LEDC PWM on
// PIN_TFT_LEDA_CTL, which the AW9364 can't use). setBrightness()/
// getBrightness() here are pct-based (0-100) so a later UITask.cpp wiring
// pass can call display.setBrightness(pct) directly, matching the existing
// pct-based Settings convention.
#if defined(TLORA_PAGER) && defined(ESP32)
#include <helpers/ui/DisplayDriver.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <AW9364LedDriver.hpp>
class ST7796LCDDisplay : public DisplayDriver {
TFT_eSPI display;
AW9364LedDriver _backlight;
bool _isOn;
uint16_t _color;
uint8_t _brightness_pct; // exact requested pct, cached for getBrightness() (the 0-100 -> 0-16 -> 0-100
// round trip through the AW9364's discrete steps is lossy)
public:
// Native panel is 222x480 portrait; landscape (480x222) comes from MADCTL
// rotation, same approach as the other boards. These are placeholders --
// begin()/setDisplayRotation() overwrite via setLogicalSize() using
// TFT_eSPI's own (rotation-aware) width()/height().
static const int LOGICAL_WIDTH = 222;
static const int LOGICAL_HEIGHT = 480;
ST7796LCDDisplay() : DisplayDriver(LOGICAL_WIDTH, LOGICAL_HEIGHT), _isOn(false), _color(0xFFFF), _brightness_pct(100) { }
bool begin();
bool isOn() override { return _isOn; }
void turnOn() override;
void turnOff() override;
void clear() override;
void startFrame(Color bkg = DARK) override;
void setTextSize(int sz) override;
void setColor(Color c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
uint16_t getTextWidth(const char* str) override;
void endFrame() override;
// Extra methods beyond DisplayDriver, called directly on the concrete type
// (same pattern as ST7789LCDDisplay's writePixelsRGB565/setDisplayRotation).
void writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels);
void setDisplayRotation(uint8_t r);
// AW9364 brightness hook, pct in/pct out by design (see file header).
void setBrightness(uint8_t pct);
uint8_t getBrightness() const { return _brightness_pct; }
};
#endif
+85 -17
View File
@@ -48,11 +48,11 @@ static uint32_t _atoi(const char* sp) {
DataStore store(LittleFS, rtc_clock);
#elif defined(ESP32)
#include <SPIFFS.h>
#if defined(HAS_TDECK_GT911)
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
#include <SD.h>
#include <Preferences.h>
#ifndef PIN_SD_CS
#define PIN_SD_CS 39 // T-Deck microSD chip-select
#define PIN_SD_CS 39 // T-Deck microSD chip-select (V4-R8 sets 3 in the env)
#endif
#endif
extern "C" void set_boot_phase(int phase);
@@ -177,6 +177,10 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
#if defined(ESP32)
volatile int g_boot_phase = 0;
extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; }
// True once contacts/channels are actually routed to the SD card at boot (SD mounted). The
// "Store data on SD" toggle is only a stored intent — if the card didn't mount at boot, contacts
// silently stay on internal flash. The Storage settings page reads this to show the REAL location.
bool g_contacts_on_sd = false;
#endif
@@ -192,7 +196,7 @@ void halt() {
#include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56)
#if defined(HAS_TDECK_GT911)
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
// ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ----
// Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED
// (the flag never survived a reboot), so their identity/prefs/contacts kept living
@@ -211,6 +215,8 @@ bool meshcomodMigrateSpiffsToSd(bool force) {
SD.mkdir("/meshcomod/identity");
SD.mkdir("/meshcomod/bl");
SD.mkdir("/meshcomod/lock");
SD.mkdir("/meshcomod/msgs"); // chat segments: SPIFFS names them flat ("/msgs/seg_*.bin"),
// but the FAT card needs the real parent dir or every copy fails
bool identity_ok = false;
int copied = 0, failed = 0;
static uint8_t buf[4096];
@@ -230,15 +236,24 @@ bool meshcomodMigrateSpiffsToSd(bool force) {
File s = SPIFFS.open(src, FILE_READ);
File d = s ? SD.open(dst, FILE_WRITE) : File(); // FILE_WRITE truncates
bool ok = s && d;
size_t since_feed = 0;
while (ok && s.available()) {
const size_t n = s.read(buf, sizeof buf);
if (n == 0 || d.write(buf, n) != n) ok = false;
// Feed the task-WDT mid-file: a large history file on a slow/cold SD card can take
// longer than the 20 s task-WDT to copy in 4 KB chunks, and this runs on loopTask
// during setup() — without the feed the dog panics and reboot-loops the boot (GH
// #142/#148). The copy still can't tolerate a genuinely wedged FAT layer; the
// NVS breadcrumb at the call site handles that.
since_feed += n;
if (since_feed >= 32768) { esp_task_wdt_reset(); since_feed = 0; }
}
if (s) s.close();
if (d) d.close();
if (ok) { ++copied; if (strcmp(src, "/identity/_main.id") == 0) identity_ok = true; }
else { ++failed; Serial.printf("[BOOT] SD migrate FAILED: %s\n", src); if (SD.exists(dst)) SD.remove(dst); }
f.close();
esp_task_wdt_reset();
yield();
}
root.close();
@@ -249,6 +264,14 @@ bool meshcomodMigrateSpiffsToSd(bool force) {
copied, failed, identity_ok ? "ok" : "MISSING");
return identity_ok;
}
// Clear the boot safe-mode latch (see the SPIFFS->SD migration above): called after a
// successful manual "Copy internal data to SD" so a deliberate retry re-arms boot-time
// auto-adoption. The boot path re-latches on its own if a later migration wedges. GH #142/#148.
void meshcomodClearSdMigLatch() {
Preferences _mp;
if (_mp.begin("touch", false)) { _mp.remove("sd_mig_busy"); _mp.end(); }
}
#endif
void setup() {
@@ -443,9 +466,13 @@ void setup() {
// failure falls back to SPIFFS so the device always boots.
bool spiffs_ok = SPIFFS.begin(false); // try first WITHOUT auto-format
bool sd_storage = false;
#if defined(HAS_TDECK_GT911)
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
{
extern SPIClass* tdeckSharedSPI();
#if defined(HELTEC_LORA_V4_R8)
extern SPIClass* heltecV4R8SharedSPI(); // FSPI, shared with the TFT (CS=3)
#else
extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus
#endif
bool setup_done = false;
{ Preferences _p; if (_p.begin("touch", true)) {
setup_done = _p.getBool("setup_ok", false); // finished first-run setup
@@ -468,23 +495,39 @@ void setup() {
// device has no usable SPIFFS, the user opted in, or it's a brand-new device.
bool want_full_sd = !spiffs_ok || use_sd_pref || fresh_install;
#if defined(HELTEC_LORA_V4_R8)
SPIClass* _spi = heltecV4R8SharedSPI();
#else
SPIClass* _spi = tdeckSharedSPI();
#endif
bool sd_mounted = false;
if (_spi) {
// Try to mount the card on EVERY boot now (not just the full-adoption case):
// even a device that keeps identity on SPIFFS wants its churn-heavy
// contacts/channels on the card. Full 4-try ladder for the cold-card
// first-run adoption; a shorter 2-try for the contacts-overflow case so a
// card-less device only pays ~260 ms.
int tries = want_full_sd ? 4 : 2;
for (int a = 0; a < tries && !sd_mounted; ++a) { // short mount ladder (cold cards)
// Try to mount the card on EVERY boot: even a device that keeps identity on SPIFFS
// wants its churn-heavy contacts/channels on the card.
//
// Use the SAME forgiving ladder the map-tile mount (fmSdTryMount) uses — dropping to
// 1 MHz then 400 kHz — not a fast 4 MHz-only ladder. A cold / cheap / slow-to-wake card
// fails a 4 MHz-only mount here but later succeeds the slow tile mount, so contacts got
// silently pushed back to SPIFFS *even though the toggle said SD and tiles worked* — then
// SPIFFS churn eventually lost them (gubbinsgalore's "100 repeaters gone overnight").
// Matching the tile ladder means contacts land on the card wherever tiles do. A card-less
// device still bails fast (3 quick tries at 4 MHz, ~480 ms); only a present-but-cold card
// walks down to the slow clocks. delay() feeds the task WDT, so the ~2.8 s worst case
// (present cold card only) doesn't trip it.
static const struct { uint16_t settle_ms; uint32_t hz; } kBootMount[] = {
{ 40, 4000000 }, { 220, 4000000 }, { 220, 4000000 },
{ 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 },
};
int tries = want_full_sd ? 7 : 3;
for (int a = 0; a < tries && !sd_mounted; ++a) {
SD.end();
delay(a == 0 ? 40 : 220);
if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE)
delay(kBootMount[a].settle_ms);
if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE)
sd_mounted = true;
}
}
if (sd_mounted) {
g_contacts_on_sd = true; // every branch below routes contacts/channels to the card
if (want_full_sd) {
// beta_36 upgrade heal: adopting the card while the LIVE data still sits
// on SPIFFS (the pre-beta_36 "toggle ignored" state) must migrate FIRST,
@@ -494,8 +537,26 @@ void setup() {
// rather than adopting a card without the identity on it.
bool adopt = true;
if (SPIFFS.exists("/identity/_main.id") && !SD.exists("/meshcomod/identity/_main.id")) {
adopt = meshcomodMigrateSpiffsToSd(false);
if (!adopt) Serial.println("[BOOT] SD migration incomplete -> staying on SPIFFS this boot");
// Boot safe-mode (GH #142/#148): a wedged or corrupt SD card can hang / WDT-reboot the
// device mid-migration, stranding it on the boot screen EVERY boot (reset can't escape,
// only a downgrade could). Drop an NVS breadcrumb before migrating and clear it only if
// the copy RETURNS. If it's still set at the next boot, the last migration never finished
// -> skip it and boot from SPIFFS (the data is safe there); Settings > "Copy internal data
// to SD" re-arms a deliberate retry. A merely-slow (healthy) card completes thanks to the
// in-loop WDT feed, so it never latches here.
Preferences _mp;
const bool mp_ok = _mp.begin("touch", false);
const bool mig_busy = mp_ok && _mp.getBool("sd_mig_busy", false);
if (mig_busy) {
if (mp_ok) _mp.end();
adopt = false;
Serial.println("[BOOT] prior SD migration did not complete -> skipping (staying on SPIFFS); retry via Settings > Copy internal data to SD");
} else {
if (mp_ok) { _mp.putBool("sd_mig_busy", true); _mp.end(); }
adopt = meshcomodMigrateSpiffsToSd(false);
Preferences _mp2; if (_mp2.begin("touch", false)) { _mp2.remove("sd_mig_busy"); _mp2.end(); }
if (!adopt) Serial.println("[BOOT] SD migration incomplete -> staying on SPIFFS this boot");
}
}
if (adopt) {
sd_storage = store.useSdStorage();
@@ -529,7 +590,7 @@ void setup() {
// Route touch settings + Wi-Fi creds to the active filesystem (SD when that's
// the data store, else SPIFFS) instead of NVS. Old NVS values still load and
// migrate on their next save, so this is a transparent in-place upgrade.
#if defined(HAS_TDECK_GT911)
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS,
sd_storage ? "/meshcomod" : "/prefs");
#else
@@ -686,14 +747,21 @@ void setup() {
// hit the overflow) still get the big ring; default GPS-off keeps the stock 256 B. A user
// who enables GPS mid-session picks it up on the next reboot (gps_enabled is persisted).
{
#if defined(ATTAKY_MESH_SERIES)
// This fixed stack always carries the GPS, so take the larger RX ring
// unconditionally; the default 256 B ring gives the slowest first fix.
Serial1.setRxBufferSize(4096);
#else
auto* np = the_mesh.getNodePrefs();
if (np && np->gps_enabled) Serial1.setRxBufferSize(4096);
#endif
}
#endif
sensors.begin();
#ifdef DISPLAY_CLASS
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
Serial.println("[BOOT] ui ready");
#endif
board.onBootComplete();
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <lvgl.h>
// Shared chrome for full-screen "app pages" — Spectrum, RF Monitor, Discover, Airtime,
// Snake, VNC, Remote, Reader. Every one of them must look and behave identically:
//
// * the GLOBAL status bar goes tall and paints "< <title>"; a tap on it closes the page
// (on both CLICKED and PRESSED, so the cap-touch swipe detector dropping a lone
// CLICKED can't trap the user on a touch-only board)
// * the page root covers everything BELOW that bar, opaque and clickable, so the tab
// bar and whatever was on screen underneath cannot be touched through the page
// * the root is deleted ASYNCHRONOUSLY, never synchronously from inside an event
// callback on one of its own children
//
// These exist because the two self-contained modules (ChannelUtil, SnakeGame) hand-rolled
// the chrome against a hardcoded `kTopBar = 22`. STATUSBAR_H is a runtime value — SC(22)
// once the UI scale is above 100%, and SB_TOP_PAD + SB_ROW*2 on the T-Display P4's
// two-row bar — so on those boards the pages were positioned too high, which put their
// own title and close button UNDERNEATH the real status bar with no reachable exit. Going
// through here instead means a page can never disagree with the bar about its own height.
/** y of the first usable content row: immediately under the (possibly tall) status bar. */
lv_coord_t appPageContentTop();
/** Usable content height below the status bar. */
lv_coord_t appPageContentH();
/**
* Create a page root: full width, from just under the status bar to the bottom of the
* screen, on lv_layer_top, opaque `bg_color`, non-scrollable and CLICKABLE so no press
* reaches the UI underneath.
*/
lv_obj_t* appPageCreateRoot(uint32_t bg_color);
/** Take the status bar over: tall, "< title", tap anywhere on it calls close_fn. */
void appPageBegin(const char* title, void (*close_fn)());
/**
* Hand the status bar back — but only if `close_fn` is still the installed hook, so a
* page closing after another one already opened cannot steal the new page's chrome.
* Safe to call more than once (idempotent).
*/
void appPageEnd(void (*close_fn)());
/** Queue `root` for deletion after the current event finishes. Null-safe. */
void appPageDeleteRootAsync(lv_obj_t* root);
+296
View File
@@ -0,0 +1,296 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChannelUtil.h"
#include "AppPage.h" // shared app-page chrome: root, tall "< Airtime" bar, async teardown
#include "i18n.h"
#include <Arduino.h> // millis()
#include <stdio.h> // snprintf
#include "MyMesh.h" // extern MyMesh& the_mesh; (+ pulls target.h -> extern radio_driver)
// The globals the rest of the UI already talks to. `the_mesh` derives from
// mesh::Dispatcher (airtime + packet-count accumulators) and `radio_driver` is
// the RadioLib wrapper (live RSSI, noise floor, RX event/error/drop counts).
extern MyMesh& the_mesh;
// radio_driver is declared `extern WRAPPER_CLASS radio_driver;` in the board's
// target.h, reached transitively through MyMesh.h above.
// The page geometry + the tall "< Airtime" bar come from AppPage (shared with Spectrum
// and every other tool page). This module used to mirror the bar height with a local
// `kTopBar = 22`, which is wrong on any board where STATUSBAR_H isn't 22 — the P4's
// two-row bar, or any UI scale above 100% — and left this page's own title and X
// underneath the real bar with no way out.
static constexpr uint32_t kTickMs = 1000; // 1 Hz sample
static constexpr int kBusyPct = 40; // congestion threshold line / verdict pivot
// Palette (hex literals so the module is self-contained).
static constexpr uint32_t kColBg = 0x0E1216;
static constexpr uint32_t kColText = 0xE6EAEE;
static constexpr uint32_t kColDim = 0x8A929A;
static constexpr uint32_t kColGood = 0x6FCF6F; // green (idle / not congested)
static constexpr uint32_t kColWarn = 0xE0B84C; // amber (moderate)
static constexpr uint32_t kColBusy = 0xE0584C; // red (busy / congested)
static constexpr uint32_t kColUtil = 0x35C9C9; // chart util series (cyan)
static constexpr uint32_t kColThresh = 0x5A6166; // chart threshold line (grey)
ChannelUtil* ChannelUtil::s_active = nullptr;
bool ChannelUtil::isOpen() { return s_active != nullptr && s_active->root_ != nullptr; }
void ChannelUtil::launch() {
if (s_active) return;
ChannelUtil* c = new ChannelUtil();
s_active = c;
if (!c->open()) { s_active = nullptr; delete c; }
}
void ChannelUtil::destroyAsync(void* p) { delete static_cast<ChannelUtil*>(p); }
void ChannelUtil::dismiss() {
// THE single close path — the status bar's back hook, the backdrop tap and any future
// caller all land here, so teardown happens exactly once and in one order.
//
// Both halves are deferred on purpose. close() queues the root with lv_obj_del_async,
// and the instance itself is freed from an lv_async_call: dismiss() runs from inside an
// LVGL event callback, and the old code deleted both the root AND `this` synchronously
// from a child button's own handler — freeing the object LVGL was still dispatching to.
// Async calls run FIFO after the event completes, so the root dies first, then us.
if (!s_active) return;
ChannelUtil* c = s_active;
s_active = nullptr; // isOpen() reads false from here on
c->close();
lv_async_call(destroyAsync, c);
}
static uint32_t utilColor(int pct) {
if (pct >= kBusyPct) return kColBusy;
if (pct >= kBusyPct / 2) return kColWarn;
return kColGood;
}
void ChannelUtil::snapshotBaseline() {
base_ms_ = millis();
base_rx_air_ = the_mesh.getReceiveAirTime();
base_tx_air_ = the_mesh.getTotalAirTime();
base_rx_pkts_ = radio_driver.getPacketsRecv();
base_rx_err_ = radio_driver.getPacketsRecvErrors();
base_rx_evt_ = radio_driver.getRxEvents();
base_rx_drop_ = radio_driver.getRxQueueDrops();
have_prev_ = false; // windowed numbers restart too
}
void ChannelUtil::tick() {
if (!root_ || !chart_) return;
const uint32_t now = millis();
const unsigned long rx_air = the_mesh.getReceiveAirTime();
const unsigned long tx_air = the_mesh.getTotalAirTime();
const uint32_t rx_pk = radio_driver.getPacketsRecv();
const uint32_t rx_err = radio_driver.getPacketsRecvErrors();
const uint32_t rx_evt = radio_driver.getRxEvents();
const uint32_t rx_drp = radio_driver.getRxQueueDrops();
// --- Windowed (instantaneous, last tick) ---------------------------------
int inst_util = 0, inst_rx = 0, inst_tx = 0, rate = 0;
if (have_prev_ && now > prev_ms_) {
const uint32_t dt = now - prev_ms_; // ms of wall clock
const uint32_t d_rx = (uint32_t)(rx_air - prev_rx_air_);
const uint32_t d_tx = (uint32_t)(tx_air - prev_tx_air_);
inst_rx = (int)((uint64_t)d_rx * 100 / dt);
inst_tx = (int)((uint64_t)d_tx * 100 / dt);
inst_util = inst_rx + inst_tx;
if (inst_util > 100) inst_util = 100;
if (inst_rx > 100) inst_rx = 100;
if (inst_tx > 100) inst_tx = 100;
rate = (int)((uint64_t)(rx_pk - prev_rx_pkts_) * 60000 / dt);
}
prev_ms_ = now; prev_rx_air_ = rx_air; prev_tx_air_ = tx_air;
prev_rx_pkts_ = rx_pk; have_prev_ = true;
// --- Session (since open / last Reset) -----------------------------------
const uint32_t sess_ms = now - base_ms_;
int avg_util = 0;
if (sess_ms > 0) {
const uint64_t busy = (uint64_t)(rx_air - base_rx_air_) + (uint64_t)(tx_air - base_tx_air_);
avg_util = (int)(busy * 100 / sess_ms);
if (avg_util > 100) avg_util = 100;
}
const uint32_t sess_rx = rx_pk - base_rx_pkts_;
const uint32_t sess_err = rx_err - base_rx_err_;
const uint32_t sess_drop = rx_drp - base_rx_drop_;
// Radio heard it (RX-done ISR) but we never delivered it: events - recv - err.
// Can dip transiently negative by the 1-2 packets currently queued/unread.
long late = (long)(rx_evt - base_rx_evt_) - (long)sess_rx - (long)sess_err;
if (late < 0) late = 0;
const uint32_t sess_lost = (uint32_t)late + sess_drop;
// --- Chart ---------------------------------------------------------------
lv_chart_set_next_value(chart_, ser_util_, (lv_coord_t)inst_util);
// --- Headline ------------------------------------------------------------
lv_obj_set_style_text_color(headline_, lv_color_hex(utilColor(inst_util)), LV_PART_MAIN);
lv_label_set_text_fmt(headline_, TR("CHANNEL %d%% BUSY"), inst_util);
// --- Signal + preset -----------------------------------------------------
const int now_rssi = (int)radio_driver.getCurrentRSSI();
const int nf = radio_driver.getNoiseFloor();
NodePrefs* np = the_mesh.getNodePrefs();
const int sf = np ? (int)np->sf : 0;
const int bw_khz = np ? (int)np->bw : 0;
const int cr = np ? (int)np->cr : 0;
const uint32_t h = sess_ms / 3600000UL;
const uint32_t m = (sess_ms / 60000UL) % 60;
const uint32_t s = (sess_ms / 1000UL) % 60;
// Metrics block (recolour markup: #RRGGBB text#).
const uint32_t lossCol = sess_lost ? kColBusy : kColGood;
char b[420];
snprintf(b, sizeof b,
"Now #%06X %d%%# (RX %d / TX %d) avg #%06X %d%%#\n"
"RX #%06X %d/min# %lu pkt sig %d / nf %d\n"
"Lost #%06X %lu# (crc %lu late %ld qf %lu)\n"
"SF%d BW%d CR%d run %02lu:%02lu:%02lu",
(unsigned)utilColor(inst_util), inst_util, inst_rx, inst_tx,
(unsigned)utilColor(avg_util), avg_util,
(unsigned)kColUtil, rate, (unsigned long)sess_rx, now_rssi, nf,
(unsigned)lossCol, (unsigned long)sess_lost,
(unsigned long)sess_err, late, (unsigned long)sess_drop,
sf, bw_khz, cr,
(unsigned long)h, (unsigned long)m, (unsigned long)s);
lv_label_set_text(metrics_, b);
// --- Verdict -------------------------------------------------------------
// Two independent axes: is the air busy, and are we losing packets. Report the
// honest combination so a tester can copy a real conclusion, not a guess.
const bool busy = avg_util >= kBusyPct;
const bool losing = sess_lost > 0;
char v[200]; uint32_t vc;
if (!busy && !losing) {
vc = kColGood;
snprintf(v, sizeof v, TR("Air only %d%% busy, 0 lost - congestion unlikely."), avg_util);
} else if (busy && losing) {
vc = kColBusy;
snprintf(v, sizeof v, TR("Air %d%% busy and %lu lost - congestion is plausible."), avg_util, (unsigned long)sess_lost);
} else if (!busy && losing) {
vc = kColWarn;
snprintf(v, sizeof v, TR("Air only %d%% but %lu lost - not congestion (weak signal or stall?)."), avg_util, (unsigned long)sess_lost);
} else {
vc = kColWarn;
snprintf(v, sizeof v, TR("Air %d%% busy but 0 lost - handling the load fine."), avg_util);
}
lv_obj_set_style_text_color(verdict_, lv_color_hex(vc), LV_PART_MAIN);
lv_label_set_text(verdict_, v);
// Re-anchor the verdict under the now-4-line metrics block (the placeholder it
// was aligned to at open() was a single line). Layout must be current first.
lv_obj_update_layout(metrics_);
lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6);
}
bool ChannelUtil::open() {
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
const bool narrow = sw < 280;
// Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing
// under it can be touched, sized/positioned from the LIVE status-bar height, and the
// global bar carries "< Airtime" as the one and only way back.
root_ = appPageCreateRoot(kColBg);
lv_obj_set_style_pad_all(root_, 6, LV_PART_MAIN);
lv_obj_add_event_cb(root_, backdropCb, LV_EVENT_CLICKED, this);
// The bar title is stored by POINTER for as long as the page is up, so it must own its
// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled
// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string
// later can't turn the status-bar title into a dangling read.
static char s_bar_title[24];
snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Airtime"));
appPageBegin(s_bar_title, &ChannelUtil::dismiss);
// No in-page title or X any more — the tall bar provides both. That chrome was the bug:
// it was laid out against a 22 px bar assumption and disappeared under the real one.
// Reset button, top-right of the content area.
lv_obj_t* rst = lv_btn_create(root_);
lv_obj_set_size(rst, 30, 24);
lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_add_event_cb(rst, resetCb, LV_EVENT_CLICKED, this);
lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, LV_SYMBOL_REFRESH); lv_obj_center(rl);
// Headline utilization %.
headline_ = lv_label_create(root_);
lv_obj_set_style_text_color(headline_, lv_color_hex(kColText), LV_PART_MAIN);
lv_label_set_text(headline_, TR("CHANNEL --% BUSY"));
lv_obj_align(headline_, LV_ALIGN_TOP_MID, 0, narrow ? 30 : 26);
// Rolling utilization chart.
chart_ = lv_chart_create(root_);
const int chart_w = sw - 12;
const int chart_h = narrow ? 46 : 50;
const int chart_y = narrow ? 50 : 46;
lv_obj_set_size(chart_, chart_w, chart_h);
lv_obj_align(chart_, LV_ALIGN_TOP_MID, 0, chart_y);
lv_chart_set_type(chart_, LV_CHART_TYPE_LINE);
lv_chart_set_point_count(chart_, kPoints);
lv_chart_set_range(chart_, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
lv_chart_set_update_mode(chart_, LV_CHART_UPDATE_MODE_SHIFT);
lv_obj_set_style_size(chart_, 0, LV_PART_INDICATOR); // no point dots
lv_obj_set_style_bg_color(chart_, lv_color_hex(0x11161B), LV_PART_MAIN);
lv_obj_set_style_border_width(chart_, 0, LV_PART_MAIN);
ser_thresh_ = lv_chart_add_series(chart_, lv_color_hex(kColThresh), LV_CHART_AXIS_PRIMARY_Y);
ser_util_ = lv_chart_add_series(chart_, lv_color_hex(kColUtil), LV_CHART_AXIS_PRIMARY_Y);
lv_chart_set_all_value(chart_, ser_thresh_, kBusyPct); // flat congestion reference
lv_chart_set_all_value(chart_, ser_util_, 0);
// Metrics block (recolour markup).
metrics_ = lv_label_create(root_);
lv_label_set_recolor(metrics_, true);
lv_obj_set_style_text_color(metrics_, lv_color_hex(kColText), LV_PART_MAIN);
lv_obj_set_width(metrics_, sw - 12);
lv_label_set_long_mode(metrics_, LV_LABEL_LONG_WRAP);
lv_label_set_text(metrics_, TR("Sampling\xE2\x80\xA6"));
lv_obj_align(metrics_, LV_ALIGN_TOP_LEFT, 2, chart_y + chart_h + 6);
// Verdict line, anchored directly under the (fixed 4-line) metrics block so it
// can never overlap it, whatever the board height.
verdict_ = lv_label_create(root_);
lv_label_set_recolor(verdict_, false);
lv_obj_set_style_text_color(verdict_, lv_color_hex(kColDim), LV_PART_MAIN);
lv_obj_set_width(verdict_, sw - 12);
lv_label_set_long_mode(verdict_, LV_LABEL_LONG_WRAP);
lv_label_set_text(verdict_, TR("Measuring\xE2\x80\xA6 give it a few seconds of traffic."));
lv_obj_align_to(verdict_, metrics_, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 8);
snapshotBaseline();
timer_ = lv_timer_create(timerCb, kTickMs, this);
lv_timer_ready(timer_); // first sample right away (seeds prev_, shows preset)
return true;
}
void ChannelUtil::close() {
if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop sampling before the UI goes
appPageEnd(&ChannelUtil::dismiss); // hand the status bar back
appPageDeleteRootAsync(root_); // never a sync del from a callback
root_ = nullptr;
headline_ = nullptr; chart_ = nullptr; ser_util_ = nullptr; ser_thresh_ = nullptr;
metrics_ = nullptr; verdict_ = nullptr;
}
// ---- LVGL C-callback trampolines (user_data = the instance) ----
void ChannelUtil::timerCb(lv_timer_t* t) {
auto* self = static_cast<ChannelUtil*>(t->user_data);
if (self) self->tick();
}
void ChannelUtil::resetCb(lv_event_t* e) {
auto* self = static_cast<ChannelUtil*>(lv_event_get_user_data(e));
if (!self) return;
self->snapshotBaseline();
if (self->chart_ && self->ser_util_) lv_chart_set_all_value(self->chart_, self->ser_util_, 0);
}
// Backdrop tap = back, exactly as on the Spectrum page. Only a press on the root itself
// closes; taps on children (Reset) are theirs. wait_release swallows the rest of the
// gesture so the press can't land on whatever the page was covering.
void ChannelUtil::backdropCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
if (lv_event_get_target(e) != lv_event_get_current_target(e)) return;
lv_indev_t* a = lv_indev_get_act(); if (a) lv_indev_wait_release(a);
dismiss();
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <lvgl.h>
// Self-contained "Channel Utilization" tool launched from the Apps drawer.
//
// Purpose: give field testers a single screen that proves or DISPROVES the
// "the mesh is congested" theory for lost messages. It separates two distinct
// questions that get conflated:
//
// 1. Is the SPECTRUM actually busy? -> channel utilization % =
// (RX airtime + TX airtime) / wall-clock time. This is the real
// "is the air full" number, sampled live and averaged over the session.
//
// 2. Are packets actually being LOST at the radio? -> CRC errors
// (collisions), packets the radio heard but the loop read too late
// (getRxEvents - packetsRecv - recvErrors), and RX-queue drops.
//
// A mesh can be busy without losing anything, and can lose packets while the
// air is nearly idle (weak signal, SW stalls). Showing both side by side lets a
// tester report a concrete verdict instead of a hunch.
//
// Owns a full-screen overlay on lv_layer_top (mirrors SnakeGame): a title row
// with a Reset + close button, a big headline utilization %, a rolling chart of
// utilization over time, a metrics block, and a plain-language verdict line. A
// 1 Hz lv_timer samples the counters. One instance at a time. Decoupled from
// UITask internals — reaches the radio via the file-scope `the_mesh` /
// `radio_driver` globals the rest of the UI already uses.
class ChannelUtil {
public:
static void launch(); // open (no-op if already open)
static bool isOpen(); // UITask: is this page up?
// THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall
// "< Airtime" bar closes the page; also used by the backdrop tap. Signature is a plain
// void() on purpose — that is what appPageBegin takes. Safe from an event callback:
// both the root and the instance are torn down asynchronously.
static void dismiss();
private:
static constexpr int kPoints = 60; // chart history (~60 s at 1 Hz)
lv_obj_t* root_ = nullptr; // full-screen overlay (owns all below)
lv_obj_t* headline_ = nullptr; // big "12%" + BUSY/idle word
lv_obj_t* chart_ = nullptr; // utilization % over time
lv_chart_series_t* ser_util_ = nullptr; // total channel util (RX+TX)
lv_chart_series_t* ser_thresh_ = nullptr; // flat congestion-threshold line
lv_obj_t* metrics_ = nullptr; // recolour label: rate / loss / signal / preset
lv_obj_t* verdict_ = nullptr; // plain-language conclusion
lv_timer_t* timer_ = nullptr;
// Baselines snapshotted at open() (and re-snapshotted by Reset) so the
// session average and loss totals are measured from a known start, without
// touching the core's lifetime counters (other UI reads them too).
uint32_t base_ms_ = 0;
unsigned long base_rx_air_ = 0; // ms
unsigned long base_tx_air_ = 0; // ms
uint32_t base_rx_pkts_ = 0;
uint32_t base_rx_err_ = 0;
uint32_t base_rx_evt_ = 0;
uint32_t base_rx_drop_ = 0;
// Previous-tick snapshot for the windowed (instantaneous) numbers.
uint32_t prev_ms_ = 0;
unsigned long prev_rx_air_ = 0;
unsigned long prev_tx_air_ = 0;
uint32_t prev_rx_pkts_ = 0;
bool have_prev_ = false;
bool open(); // build UI; false on failure
void close();
void snapshotBaseline();
void tick(); // 1 Hz sample -> chart + labels
static ChannelUtil* s_active; // the one live instance (or nullptr)
static void timerCb(lv_timer_t* t);
static void backdropCb(lv_event_t* e); // tap the page background = back
static void resetCb(lv_event_t* e);
static void destroyAsync(void* p); // lv_async_call target: delete the instance
};
+9 -8
View File
@@ -227,28 +227,28 @@ static const lv_btnmatrix_ctrl_t kb_en_upper_ctrl[] = {
static const char* const kb_fr_lower[] = {
"1#", "a", "z", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "q", "s", "d", "f", "g", "h", "j", "k", "l", "m", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "", "\n",
"_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "\n",
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
};
static const lv_btnmatrix_ctrl_t kb_fr_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_HIDDEN,
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* const kb_fr_upper[] = {
"1#", "A", "Z", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "Q", "S", "D", "F", "G", "H", "J", "K", "L", "M", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "", "\n",
"_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "\n",
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
};
static const lv_btnmatrix_ctrl_t kb_fr_upper_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_HIDDEN,
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
@@ -626,8 +626,9 @@ static const char* hw_de_upper[26] = {
static const char* hw_de_digits[10] = { nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr };
static const char* hw_de_digits_shift[10] = { nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr,nullptr };
/* Dutch keeps the standard alpha matrix but exposes the common IJ digraph on a
* digit slot so compact hardware still gets a language-specific shortcut. */
/* Dutch keeps the standard alpha matrix. (The IJ digraph used to sit on a hardware
* digit slot, but slot 1 is the "1" key AND the back-mapped "!" key, so it broke both
* — GH #144/#164 — and the digraph now lives only on the on-screen keyboard's key.) */
static const char* hw_nl_lower[26] = {
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
@@ -638,8 +639,8 @@ static const char* hw_nl_upper[26] = {
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V", "W", "X", "Y", "Z"
};
static const char* hw_nl_digits[10] = { nullptr, "ij", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
static const char* hw_nl_digits_shift[10] = { nullptr, "IJ", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
static const char* hw_nl_digits[10] = { nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
static const char* hw_nl_digits_shift[10] = { nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
/* Spanish stays close to US QWERTY; use the number row to expose n-tilde on
* compact hardware where the semicolon key is absent. */
+55 -31
View File
@@ -1,14 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SnakeGame.h"
#include "AppPage.h" // shared app-page chrome: root, tall "< Snake" bar, async teardown
#include "i18n.h"
#include <Arduino.h> // random()
#include <string.h> // memmove
#include <stdio.h> // snprintf (bar title)
#include <LvglPsramAlloc.h> // PSRAM-preferred buffer for the canvas
// Mirrors the app's status-bar height; the overlay starts below it so the bar
// stays visible. Kept local so this module needs nothing from UITask.
static constexpr lv_coord_t kTopBar = 22;
// Page geometry + the tall "< Snake" bar come from AppPage (shared with Spectrum and the
// other tool pages). This used to mirror the bar height with a local `kTopBar = 22`, which
// is wrong wherever STATUSBAR_H isn't 22 (the P4's two-row bar, any UI scale above 100%)
// and buried the game's own X under the real bar with no reachable exit.
static constexpr uint32_t kStepMs = 300; // tick period (a touch slower than before)
// Palette (hex literals so the module is self-contained).
@@ -37,6 +40,32 @@ void SnakeGame::launch() {
if (!g->open()) { s_active = nullptr; delete g; }
}
void SnakeGame::destroyAsync(void* p) { delete static_cast<SnakeGame*>(p); }
void SnakeGame::dismiss() {
// THE single close path — the tall bar's back hook (installed via appPageBegin) and any
// other caller land here, so teardown happens once, in one order.
//
// Both halves are deferred deliberately. close() queues the root with lv_obj_del_async and
// the instance is freed from an lv_async_call: this can run from inside an LVGL event
// callback, and the old code deleted the root AND `this` synchronously from a child
// button's own handler — freeing what LVGL was still dispatching to. Async calls run FIFO,
// so the root (and with it the canvas) dies before ~SnakeGame() frees the canvas buffer.
if (!s_active) return;
SnakeGame* g = s_active;
s_active = nullptr;
g->close();
lv_async_call(destroyAsync, g);
}
// The canvas pixel buffer must outlive the canvas object. close() only QUEUES the root for
// deletion, so freeing buf_ there would leave a live canvas pointing at freed PSRAM for one
// more frame. It is freed here instead: the destructor runs from the lv_async_call queued
// after lv_obj_del_async, by which point the canvas is really gone.
SnakeGame::~SnakeGame() {
if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; }
}
void SnakeGame::placeFood() {
const int cells = cols_ * rows_;
for (int tries = 0; tries < 4 * cells + 16; ++tries) {
@@ -126,20 +155,24 @@ void SnakeGame::togglePause() {
bool SnakeGame::open() {
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
root_ = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(root_);
lv_obj_set_size(root_, sw, sh - kTopBar);
lv_obj_set_pos(root_, 0, kTopBar);
lv_obj_set_style_bg_color(root_, lv_color_hex(0x0E1216), LV_PART_MAIN);
lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE);
// Page root + chrome, identical to the Spectrum page: opaque and CLICKABLE so nothing
// underneath can be touched, geometry from the LIVE status-bar height, and the global bar
// carries "< Snake" as the way back. NOTE: no backdrop-tap-to-close here (unlike Airtime)
// — a tap on the playfield restarts after a game over, so the bar is the only exit.
root_ = appPageCreateRoot(0x0E1216);
lv_obj_add_event_cb(root_, gestureCb, LV_EVENT_GESTURE, this);
lv_obj_add_event_cb(root_, tapCb, LV_EVENT_CLICKED, this);
// The bar title is stored by POINTER for as long as the page is up, so it must own its
// storage. TR() hands back the table cell for a plain key (static, fine) but a recycled
// 4-slot ring for an icon-prefixed one — copy it out so adding a glyph to this string
// later can't turn the status-bar title into a dangling read.
static char s_bar_title[24];
snprintf(s_bar_title, sizeof s_bar_title, "%s", TR("Snake"));
appPageBegin(s_bar_title, &SnakeGame::dismiss);
// Fill the unoccupied screen below a 24-px score/close row. Whole cells only.
// Fill the unoccupied screen below a 24-px score row. Whole cells only.
const int avail_w = (int)sw;
const int avail_h = (int)(sh - kTopBar) - 24;
const int avail_h = (int)appPageContentH() - 24;
cols_ = avail_w / kCellPx; if (cols_ > kMaxCols) cols_ = kMaxCols; if (cols_ < 6) cols_ = 6;
rows_ = avail_h / kCellPx; if (rows_ > kMaxRows) rows_ = kMaxRows; if (rows_ < 6) rows_ = 6;
const int pw = cols_ * kCellPx, ph = rows_ * kCellPx;
@@ -153,16 +186,12 @@ bool SnakeGame::open() {
lv_obj_set_style_text_color(score_, lv_color_hex(kColText), LV_PART_MAIN);
lv_obj_align(score_, LV_ALIGN_TOP_LEFT, 6, 4);
lv_obj_t* x = lv_btn_create(root_);
lv_obj_set_size(x, 30, 24);
lv_obj_align(x, LV_ALIGN_TOP_RIGHT, -4, 0);
lv_obj_add_event_cb(x, closeCb, LV_EVENT_CLICKED, this);
lv_obj_t* xl = lv_label_create(x); lv_label_set_text(xl, LV_SYMBOL_CLOSE); lv_obj_center(xl);
// Pause/resume toggle, left of the X. Hidden until the game starts.
// No in-page X any more — the tall bar's "<" is the exit. That button was the bug: laid
// out against a 22 px bar assumption, it vanished under the real one on the P4.
// Pause/resume toggle, top-right. Hidden until the game starts.
pause_btn_ = lv_btn_create(root_);
lv_obj_set_size(pause_btn_, 30, 24);
lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -40, 0);
lv_obj_align(pause_btn_, LV_ALIGN_TOP_RIGHT, -4, 0);
lv_obj_add_event_cb(pause_btn_, pauseCb, LV_EVENT_CLICKED, this);
pause_lbl_ = lv_label_create(pause_btn_); lv_label_set_text(pause_lbl_, LV_SYMBOL_PAUSE); lv_obj_center(pause_lbl_);
lv_obj_add_flag(pause_btn_, LV_OBJ_FLAG_HIDDEN);
@@ -182,11 +211,13 @@ bool SnakeGame::open() {
}
void SnakeGame::close() {
if (timer_) { lv_timer_del(timer_); timer_ = nullptr; }
if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes canvas + button children
if (buf_) { lvglPsramFree(buf_); buf_ = nullptr; } // free AFTER the canvas is gone
if (timer_) { lv_timer_del(timer_); timer_ = nullptr; } // stop stepping before the UI goes
appPageEnd(&SnakeGame::dismiss); // hand the status bar back
appPageDeleteRootAsync(root_); // never a sync del from a callback
root_ = nullptr;
canvas_ = nullptr; score_ = nullptr; start_btn_ = nullptr;
pause_btn_ = nullptr; pause_lbl_ = nullptr;
// buf_ is freed in ~SnakeGame(), after the queued root delete has taken the canvas with it.
}
// ---- LVGL C-callback trampolines (user_data = the instance) ----
@@ -217,10 +248,3 @@ void SnakeGame::pauseCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (self) self->togglePause();
}
void SnakeGame::closeCb(lv_event_t* e) {
auto* self = static_cast<SnakeGame*>(lv_event_get_user_data(e));
if (!self) return;
self->close();
if (s_active == self) s_active = nullptr;
delete self;
}
+7 -1
View File
@@ -17,8 +17,14 @@ public:
static void launch(); // open (no-op if already open)
static bool isOpen(); // UITask: gate the trackball + tab bar
static void steer(int dx, int dy); // UITask: trackball motion -> direction
// THE close path. Installed as the AppPage back hook (see appPageBegin), so the tall
// "< Snake" bar closes the game. Plain void() on purpose — that is what appPageBegin
// takes. Safe from an event callback: root and instance are torn down asynchronously.
static void dismiss();
private:
~SnakeGame(); // frees the canvas buffer, after the queued root delete released the canvas
// The playfield grid is sized to the screen at open() (cols_ x rows_), capped
// by these maxima so the body arrays are fixed-size.
static constexpr int kCellPx = 14; // px per cell
@@ -61,7 +67,7 @@ private:
static void timerCb(lv_timer_t* t);
static void gestureCb(lv_event_t* e);
static void tapCb(lv_event_t* e);
static void closeCb(lv_event_t* e);
static void startCb(lv_event_t* e);
static void pauseCb(lv_event_t* e);
static void destroyAsync(void* p); // lv_async_call target: delete the instance
};
+6475 -609
View File
File diff suppressed because it is too large. Load diff
+52 -11
View File
@@ -60,6 +60,12 @@ public:
struct UIMessage {
uint32_t ts;
// Monotonic per-record sequence number, assigned at append and persisted by
// the segmented store (its record-to-segment mapping keys on seq ranges).
// NEVER reset mid-session; the boot loader seeds the generator from
// max(loaded seq)+1. Distinct from _msgcount, which the companion protocol
// may overwrite (msgRead) and therefore cannot be a unique key.
uint32_t seq;
bool channel;
bool outgoing;
uint32_t ack_hash; // expected-ack for outgoing DMs (0 if none / channel / incoming)
@@ -132,12 +138,35 @@ private:
char _alert[80];
unsigned long _alert_expiry;
int _msgcount;
/** Next UIMessage::seq to hand out. Seeded by the loader (max loaded seq + 1),
* strictly monotonic for the session. See UIMessage::seq. */
uint32_t _ui_seq_next = 1;
int _ui_msg_count;
int _ui_msg_head;
/** Runtime ring capacity: MAX_UI_MESSAGES_SD when history lives on an SD card,
* else MAX_UI_MESSAGES. Fixed for the whole boot (chosen before the PSRAM
* alloc in begin()); the loader linearizes files written under another cap. */
int _ui_msg_cap = MAX_UI_MESSAGES;
/** "Does this thread have any stored message?", answered in O(1).
*
* threadHasMessageHistory() used to answer it by walking the message ring looking for a
* match, and the inbox/unread paths call it once PER THREAD. A thread that HAS recent
* messages exits that scan early; a thread with none scans every record in the ring doing
* a strncmp each — and "no history" is exactly what those callers are testing for. With a
* busy public channel filling the ring (a field report: 3800 messages) each inbox refresh
* became tens of thousands of string compares, which is the reported "interface slows down
* drastically", and why it was still felt at only ~300 messages.
*
* Appends set the owning thread's flag directly, so the common path never scans. Anything
* that REMOVES messages (ring eviction, delete, clear, a fresh load) can only invalidate
* the flags, so it just marks them dirty and the next reader rebuilds all threads in ONE
* ring pass instead of one pass per thread. */
mutable uint16_t _thread_msgs[MAX_UI_THREADS] = { 0 };
mutable bool _thread_hist_dirty = true;
void rebuildThreadHistoryFlags() const;
/** Drop this thread's oldest stored messages until it is back within the per-chat cap
* (touchPrefsGetHistPerChat; 0 = uncapped). Called after an append. */
void enforceHistoryCap(int thread_idx);
unsigned long _next_thread_seed;
UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin()
UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin()
@@ -167,7 +196,7 @@ private:
bool _composer_mode;
int _composer_char_idx;
int _composer_action_idx;
char _compose_buf[128];
char _compose_buf[MAX_MSG_TEXT + 1]; // full LoRa text length; was 128 -> silently chopped sends at 127 bytes (GH #119)
unsigned long _next_mesh_thread_refresh;
TouchUiScreen _touch_screen;
bool _active_dm_contact_set;
@@ -234,6 +263,8 @@ private:
bool loadThreadsFromStorage();
bool loadMsgsFromStorage();
bool loadLegacyHistoryFromStorage();
bool loadMsgsFromSegments(); // segmented store (generation 3) loader
bool migrateRingToSegments(); // one-time old-format -> segments migration (verify-then-delete)
bool saveThreadsToStorage();
bool saveMsgsToStorage();
@@ -353,18 +384,26 @@ public:
if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_msgs) return false;
const bool ch = _ui_threads[idx].channel;
const char* nm = _ui_threads[idx].name;
int best = -1; uint32_t best_ts = 0;
for (int i = 0; i < _ui_msg_cap; ++i) {
const UIMessage& m = _ui_msgs[i];
// Newest by RING ORDER, not by timestamp. m.ts is taken from the ESP32 system clock,
// which is not monotonic across reboots: it restarts from ESP32RTCClock::begin()'s
// power-on seed every boot and only climbs with uptime until a real time source lands.
// Picking max(ts) therefore let a message received hours into an EARLIER boot outrank
// every message from this one, so the chat-list preview stuck on an old message and
// never updated (reported on the T-Display P4, whose system clock stayed on that seed
// because it has an RTC chip — see the mirror in ClockFloorRTC). Ring order is the
// actual arrival order and cannot be wrong, so walk back from the head and take the
// first match; that also exits immediately instead of scanning the whole ring.
for (int i = 0; i < _ui_msg_count; ++i) {
const int slot = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap;
const UIMessage& m = _ui_msgs[slot];
if (!m.text[0] || m.channel != ch) continue;
if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue;
if (best < 0 || m.ts >= best_ts) { best = i; best_ts = m.ts; } // >= : a later slot wins ts ties (newer in the ring)
if (sender && sender_cap) { strncpy(sender, m.sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; }
if (text && text_cap) { strncpy(text, m.text, text_cap - 1); text[text_cap - 1] = '\0'; }
if (outgoing) *outgoing = m.outgoing;
return true;
}
if (best < 0) return false;
if (sender && sender_cap) { strncpy(sender, _ui_msgs[best].sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; }
if (text && text_cap) { strncpy(text, _ui_msgs[best].text, text_cap - 1); text[text_cap - 1] = '\0'; }
if (outgoing) *outgoing = _ui_msgs[best].outgoing;
return true;
return false;
}
int threadScroll() const { return _thread_scroll; }
void setThreadScroll(int v) { _thread_scroll = v; }
@@ -453,7 +492,8 @@ public:
bool enableBle();
void disableBle() { if (_serial) _serial->disableBle(); }
int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; }
void setDeviceTimeFromSystemClock();
/** Push the ESP32 system clock into the mesh RTC. false = never synced, mesh clock untouched. */
bool setDeviceTimeFromSystemClock();
/** Mark recent user input — call when touch / hw button is detected. */
void noteUserInput();
/** Get / set the screen-off-after-idle timeout (0 = never). Persists in NVS. */
@@ -486,6 +526,7 @@ public:
// lost — the periodic flush is off-thread and rate-capped. Overrides the
// AbstractUITask hook so the companion CMD_REBOOT path flushes too.
void persistHistoryNow() override;
void flushHistorySoon(); // arm an immediate OFF-THREAD flush (worker) — no min-delay clamp, no UI stall
// from AbstractUITask
void msgRead(int msgcount) override;
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more