Compare commits

...
23 Commits
Author SHA1 Message Date
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
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
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
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
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
15 changed files with 7747 additions and 124 deletions

No files matched your search

+13 -4
View File
@@ -313,7 +313,9 @@ build_flags =
-D RADIOLIB_EXCLUDE_SSTV=1
-D ESP32_PLATFORM
-D ENV_INCLUDE_GPS=1
-D ENV_INCLUDE_AHTX0=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
@@ -323,7 +325,8 @@ build_flags =
-D ENV_INCLUDE_INA219=1
-D ENV_INCLUDE_INA226=1
-D ENV_INCLUDE_INA260=1
-D ENV_INCLUDE_MLX90614=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
@@ -346,12 +349,18 @@ build_flags =
-D MAX_LORA_TX_POWER=22
-D SX126X_REGISTER_PATCH=1
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
; 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
-D GPS_BAUD=9600
; 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
+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.
+6
View File
@@ -745,8 +745,14 @@ 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();
+289
View File
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChannelUtil.h"
#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.
// Mirrors the app's status-bar height; the overlay starts below it so the bar
// stays visible (and can dismiss us on the big-screen boards). Local so this
// module needs nothing from UITask.
static constexpr lv_coord_t kTopBar = 22;
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::dismiss() {
// Guaranteed exit from outside (a status-bar tap): the in-page X can sit under
// a taller status bar on big-screen boards. Mirrors closeCb's teardown; safe
// to delete synchronously here (the triggering target is the status bar).
if (!s_active) return;
ChannelUtil* c = s_active;
s_active = nullptr;
c->close();
delete 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 lv_coord_t sh = lv_disp_get_ver_res(nullptr);
const bool narrow = sw < 280;
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(kColBg), LV_PART_MAIN);
lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(root_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_pad_all(root_, 6, LV_PART_MAIN);
// Title.
lv_obj_t* title = lv_label_create(root_);
lv_label_set_text(title, TR("Channel Utilization"));
lv_obj_set_style_text_color(title, lv_color_hex(kColDim), LV_PART_MAIN);
lv_obj_align(title, LV_ALIGN_TOP_LEFT, 2, 2);
// Close (X) + Reset buttons, top-right.
lv_obj_t* x = lv_btn_create(root_);
lv_obj_set_size(x, 30, 24);
lv_obj_align(x, LV_ALIGN_TOP_RIGHT, 0, -2);
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);
lv_obj_t* rst = lv_btn_create(root_);
lv_obj_set_size(rst, 30, 24);
lv_obj_align(rst, LV_ALIGN_TOP_RIGHT, -36, -2);
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; }
if (root_) { lv_obj_del(root_); root_ = nullptr; } // deletes all children
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);
}
void ChannelUtil::closeCb(lv_event_t* e) {
auto* self = static_cast<ChannelUtil*>(lv_event_get_user_data(e));
if (!self) return;
self->close();
if (s_active == self) s_active = nullptr;
delete self;
}
+75
View File
@@ -0,0 +1,75 @@
// 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: gate the tab bar + raise the status bar
static void dismiss(); // close from outside (status-bar tap) — guaranteed exit
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 closeCb(lv_event_t* e);
static void resetCb(lv_event_t* e);
};
+12
View File
@@ -37,6 +37,18 @@ void SnakeGame::launch() {
if (!g->open()) { s_active = nullptr; delete g; }
}
void SnakeGame::dismiss() {
// Close from outside the game (a status-bar tap). The in-game X can sit UNDER a taller status
// bar on big-screen boards (the P4's bar is set taller at boot + raised over the game), leaving
// no reachable exit — this is the guaranteed way out. Mirrors closeCb's teardown. Safe to del
// synchronously here: the triggering event target is the status bar, not a Snake object.
if (!s_active) return;
SnakeGame* g = s_active;
s_active = nullptr;
g->close();
delete g;
}
void SnakeGame::placeFood() {
const int cells = cols_ * rows_;
for (int tries = 0; tries < 4 * cells + 16; ++tries) {
+1
View File
@@ -17,6 +17,7 @@ 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
static void dismiss(); // close from outside (status-bar tap) — guaranteed exit
private:
// The playfield grid is sized to the screen at open() (cols_ x rows_), capped
+462 -113
View File
@@ -34,6 +34,9 @@
#include "assets/lockscreen_placeholder_jpg.h" // seeded to SPIFFS /lock/placeholder.jpg on first boot (PNG decode is broken on this board)
#if CAP_LOCK_SCREEN
#include "assets/lockscreen_wallpaper_rgb565.h" // crisp pre-dithered default lock-screen wallpaper (no JPEG banding)
#if defined(TLORA_PAGER)
#include "assets/lockscreen_wallpaper_pager_rgb565.h" // native 480x222 crop/layout for this board's wide/short panel
#endif
#endif
#include <esp_timer.h>
#include <esp_chip_info.h>
@@ -102,6 +105,7 @@
#include <Utils.h>
#include <LvglPsramAlloc.h> // PSRAM-preferred alloc helpers for the map tile cache
#include "SnakeGame.h" // Apps → Snake (self-contained game module)
#include "ChannelUtil.h" // Apps → Airtime (self-contained channel-utilization tool)
#if defined(HAS_TOUCH_UI)
#include <lvgl.h>
@@ -123,6 +127,9 @@
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
#include <AttakyMeshSeriesKeyboard.h>
#endif
#if defined(ATTAKY_MESH_SERIES)
#include <AttakyMeshSeriesKeys.h>
#endif
#include "KeyboardLayouts.h"
#include "i18n.h"
#include "emoji_data.h" // baked Noto colour-emoji glyphs (emojiGlyphLookup)
@@ -528,6 +535,11 @@ static void initTouchFontFallbacks() {
// upscaling a low-res frame). g_font_12/14/16 are what the whole UI draws with, so this scales
// every screen at once. The colour-emoji + non-Latin fallbacks stay their baked sizes (they don't
// grow), which is fine for Latin text. g_font_tab is pinned to 16 px so the bottom bar never grows.
// UI scale from the saved pref (Normal/Large/Huge -> 100/140/170%). The P4 honours it like the
// Tanmatsu — its "UI size" dropdown is shown (CAP_LARGE_SCREEN), so pinning 100% here made that
// setting a dead no-op (reported: "changing text size doesn't work"). At Large/Huge some P4 chrome
// that still uses unscaled dims (status bar, home grid, list rows) can be tight; that is the lesser
// problem and a per-screen SC() follow-up, not a reason to disable scaling outright.
switch (touchPrefsGetUiScale()) { case 1: s_ui_fscale = 140; break; case 2: s_ui_fscale = 170; break; default: s_ui_fscale = 100; break; }
switch (s_ui_fscale) {
case 140: g_font_12 = lv_font_montserrat_16; g_font_14 = lv_font_montserrat_20; g_font_16 = lv_font_montserrat_24; break; // Large ~1.4x
@@ -1593,9 +1605,19 @@ static lv_obj_t* s_kb_bind_ta = nullptr;
// end-cursor, so backspace deleted the last character no matter where the caret
// was. When this returns false, kbMirrorBind binds the field directly and the
// mirror sync / redirects below are skipped.
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
// Set while the module's '#' has summoned the OSK for this editing session; hideKb clears it.
static bool s_osk_forced = false;
#endif
static inline bool kbMirrorActive() {
#if CAP_KEYBOARD
return false; // physical keyboard: bind keys straight to the field, never show the on-screen kb
#elif defined(HAS_ATTAKY_MESH_KEYBOARD)
// Keyboard is a detachable module, so decide at runtime, not via CAP_KEYBOARD:
// suppress the on-screen keys while the module answers on I2C, unless '#' has
// summoned them back for this field. No module: behave like stock upstream.
return !(attakyKeyboardPresent() && !s_osk_forced);
#else
return true;
#endif
@@ -5407,6 +5429,12 @@ static void hideKb() {
txtMenuHide(); // tear down any open edit menu
kbMirrorSyncToReal();
s_kb_bind_ta = nullptr;
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
// The summon lasts one editing session. Reset the panel to letters so a later
// reveal does not inherit the symbol mode the summon opened.
if (s_osk_forced && g_lv.keyboard) lv_keyboard_set_mode(g_lv.keyboard, LV_KEYBOARD_MODE_TEXT_LOWER);
s_osk_forced = false;
#endif
if (s_kb_mirror_root) lv_obj_add_flag(s_kb_mirror_root, LV_OBJ_FLAG_HIDDEN);
if (g_lv.keyboard) {
lv_keyboard_set_textarea(g_lv.keyboard, nullptr);
@@ -5474,6 +5502,11 @@ static void showKb(LvChatPanel* p) {
#if !CAP_KEYBOARD
// No on-screen keyboard on the T-Deck — the physical keyboard types straight
// into the composer (already visible), so skip showing the keys + the lift.
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
// Same while the module is attached, until '#' summons the keys. kbMirrorActive()
// guards the settings path; the chat composer comes through here and needs its own.
if (attakyKeyboardPresent() && !s_osk_forced) return;
#endif
lv_obj_clear_flag(g_lv.keyboard, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(g_lv.keyboard);
// Shrink message area to keep composer visible above keyboard.
@@ -6105,7 +6138,25 @@ static void composerSuggestRefresh() {
static void keyboardCb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) {
accentExit(); accentBoxHide();
// Dismissing the keys must not kill the module: hideKb() unbinds the textarea
// the module's scan is gated on. Re-bind after hiding so the keys go down but
// the module keeps typing, the state a suppressed field starts in.
LvChatPanel* const kb_panel = s_kb_panel;
lv_obj_t* const kb_field = s_kb_bind_ta;
hideKb();
if (attakyKeyboardPresent()) {
if (kb_panel && kb_panel->composer_ta && lv_obj_is_valid(kb_panel->composer_ta))
showKb(kb_panel); // chat: rebind + refocus the composer, keys stay down
else if (kb_field && lv_obj_is_valid(kb_field))
kbMirrorBind(kb_field); // settings-style field: straight back to the direct bind
}
}
#else
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { accentExit(); accentBoxHide(); hideKb(); }
#endif
// VALUE_CHANGED fires for any keypress (incl. backspace). Fade the rotate
// arrows down to ~20% so they don't compete visually with the text the
// user is typing. Reset to full opacity on the next showKb / kbMirrorBind.
@@ -6729,6 +6780,29 @@ static void threadSelectCb(lv_event_t* e) {
#endif
}
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
// Send the panel composer's text and clear it. Split from the send button's
// callback so the module's Enter can reach the same path.
static void composerSendFromPanel(LvChatPanel* p) {
if (!g_lv.task || !p || !p->composer_ta) return;
const char* text = lv_textarea_get_text(p->composer_ta);
if (!text || !text[0]) return;
hideKb();
g_lv.task->setComposerMode(true);
g_lv.task->composerReset();
for (const char* cp = text; *cp; ++cp) g_lv.task->composerAppendChar(*cp);
if (g_lv.task->composerSend()) {
lv_textarea_set_text(p->composer_ta, "");
refreshChatDetailAsync(*p);
g_lv.dirty_threads = true;
}
}
static void sendFromPanelCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
composerSendFromPanel(static_cast<LvChatPanel*>(lv_event_get_user_data(e)));
}
#else
static void sendFromPanelCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return;
auto* p = static_cast<LvChatPanel*>(lv_event_get_user_data(e));
@@ -6745,6 +6819,7 @@ static void sendFromPanelCb(lv_event_t* e) {
g_lv.dirty_threads = true;
}
}
#endif
// ---- Quick-reply macro picker (composer bar → list icon) ----
// One sheet at a time; we cache the active panel pointer so the tap handler
@@ -11823,9 +11898,11 @@ static void buildDeviceSettings(int sec) {
}
#endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9
/* Auto-lock on screen-off: when the idle timeout dims the screen, also hard-
lock so the touchscreen is inert (Tim: pocket-taps while dark). Both boards;
unlock with a trackball hold (T-Deck) or the button (V4). */
// Auto-lock on screen-off: when the idle timeout dims the screen, also hard-lock so the
// touchscreen is inert (Tim: pocket-taps while dark). NOT on the P4 — it has no unlock path
// (no button/trackball and no touch-unlock overlay yet), so a hard lock there is a power-cycle
// trap. The P4 still dims on idle (screen-off wakes on touch); a proper P4 lock is a follow-up.
#if !defined(HAS_TDISPLAY_P4)
{
int h = settingsRowLabel(body, y, 6, TR("Lock when screen off"), COLOR_SUB, nullptr, 56);
lv_obj_t* sw = lv_switch_create(body);
@@ -11836,6 +11913,7 @@ static void buildDeviceSettings(int sec) {
lv_obj_add_event_cb(sw, lockOnScreenOffToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += LV_MAX(40, h + 12);
}
#endif
}
@@ -21877,10 +21955,8 @@ static void makeHome(lv_obj_t* tab) {
lv_obj_set_ext_click_area(s_home_chart_legend, 8);
lv_obj_add_event_cb(s_home_chart_legend, homeChartClickedCb, LV_EVENT_CLICKED, nullptr);
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9)
// Landscape (T-Deck / Tanmatsu / pager / RAK Tap V2 / M9): the right column holds
// Advert + Terminal + Files/Apps + Control, so the chart must stop short of that
// strip — else it draws over the buttons.
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) || defined(ATTAKY_MESH_SERIES)
// Landscape boards keep the chart clear of the right-hand button strip.
const int chart_w = home_land ? (cw - RSTRIP) : cw;
#else
const int chart_w = cw;
@@ -23713,8 +23789,32 @@ static int wifiScanWatchdogSafe(uint32_t cap_ms, uint16_t per_chan_ms = 300) {
}
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA
// Per-board OTA download bin name (the app-only <name>.bin under releases/<ch>/beta_<N>/).
// MUST match the release artifact names exactly, or the self-update 404s. Every touch board is
// dual-slot OTA-capable (CAP_OTA=1 + app0/app1 partitions + FIRMWARE_OTA_ENV) EXCEPT the Tanmatsu
// (AppFS/launcher, CAP_OTA=0, never reaches this file). Keep this chain in sync when adding a board.
#if defined(HAS_TDECK_GT911)
static const char* const OTA_BIN_NAME = "wadamesh-tdeck";
#elif defined(HAS_TDISPLAY_P4)
#if defined(HAS_TDP4_LCD)
static const char* const OTA_BIN_NAME = "wadamesh-tdisplay-p4-lcd"; // T-Display P4 TFT-LCD SKU
#else
static const char* const OTA_BIN_NAME = "wadamesh-tdisplay-p4"; // T-Display P4 AMOLED SKU
#endif
#elif defined(HELTEC_LORA_V4_R8)
static const char* const OTA_BIN_NAME = "wadamesh-heltec-v4-r8-tft"; // must precede the V4-TFT fallback
#elif defined(HAS_THINKNODE_M9)
static const char* const OTA_BIN_NAME = "wadamesh-thinknode-m9";
#elif defined(HAS_RAK_TAP_V2)
static const char* const OTA_BIN_NAME = "wadamesh-rak-tap-v2";
#elif defined(TLORA_PAGER)
#if defined(USE_LR1121)
static const char* const OTA_BIN_NAME = "wadamesh-tlora-pager-lr1121";
#else
static const char* const OTA_BIN_NAME = "wadamesh-tlora-pager-sx1262";
#endif
#elif defined(ATTAKY_MESH_SERIES)
static const char* const OTA_BIN_NAME = "wadamesh-attaky";
#else
static const char* const OTA_BIN_NAME = "wadamesh-heltec-v4-tft"; // Heltec V4 TFT (Tanmatsu excluded above)
#endif
@@ -29458,33 +29558,26 @@ static void chatVirtCheckStoreEdges(LvChatPanel* p) {
}
#endif
static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode,
bool thread_is_room, lv_coord_t bubble_max_w) {
ChatBubbleDisplay d{};
chatParseMessageDisplay(m, channel_mode, thread_is_room, d);
const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH;
lv_coord_t inner_y = 0;
if ((channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0])
inner_y += lv_font_get_line_height(&g_font_12);
lv_point_t txt_size;
lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w;
lv_point_t wrapped_size;
lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0,
txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE);
static void chatBuildBubbleMeta(const UITask::UIMessage& m, bool channel_mode,
char* out, size_t out_len, uint32_t* out_fg) {
if (out && out_len > 0) out[0] = '\0';
if (out_fg) *out_fg = COLOR_SUB;
if (!out || out_len == 0) return;
char ts_buf[20];
formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf));
const char* deliv_glyph = "";
uint32_t deliv_fg = COLOR_SUB;
if (m.outgoing && !channel_mode && m.deliv_state != UITask::DELIV_NONE) {
switch (m.deliv_state) {
case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; break;
case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; break;
case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE; break;
case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break;
case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break;
case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break;
default: break;
}
}
char rep_buf[12] = "";
if (m.outgoing && m.sent_fp) {
const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp);
@@ -29495,15 +29588,67 @@ static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool chann
snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops);
}
lv_coord_t body_h = inner_y + wrapped_size.y;
if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) {
char footer[40];
snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf);
lv_point_t foot_size;
lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
body_h += 1 + foot_size.y;
snprintf(out, out_len, "%s%s%s", ts_buf, deliv_glyph, rep_buf);
if (out_fg) *out_fg = deliv_glyph[0] ? deliv_fg : COLOR_SUB;
}
static lv_coord_t chatTextWidth(const char* s) {
if (!s || !s[0]) return 0;
lv_point_t size;
lv_txt_get_size(&size, s, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
return size.x;
}
static bool chatUtf8Continuation(char c) {
return (static_cast<uint8_t>(c) & 0xC0) == 0x80;
}
static void chatFitLeadingEllipsis(const char* src, lv_coord_t max_w, char* out, size_t out_len) {
if (!out || out_len == 0) return;
out[0] = '\0';
if (!src || !src[0] || max_w <= 0) return;
if (chatTextWidth(src) <= max_w) {
snprintf(out, out_len, "%s", src);
return;
}
return kChatBubblePadV * 2 + body_h;
const char* ell = "...";
if (chatTextWidth(ell) > max_w) return;
const size_t len = strlen(src);
for (size_t i = 0; i < len; ++i) {
if (chatUtf8Continuation(src[i])) continue;
char cand[48];
snprintf(cand, sizeof(cand), "%s%s", ell, src + i);
if (chatTextWidth(cand) <= max_w) {
snprintf(out, out_len, "%s", cand);
return;
}
}
snprintf(out, out_len, "%s", ell);
}
static lv_coord_t chatMeasureBubbleHeight(const UITask::UIMessage& m, bool channel_mode,
bool thread_is_room, lv_coord_t bubble_max_w) {
ChatBubbleDisplay d{};
chatParseMessageDisplay(m, channel_mode, thread_is_room, d);
const lv_coord_t inner_max_w = bubble_max_w - 2 * kChatBubblePadH;
lv_coord_t inner_y = 0;
char meta_buf[48];
chatBuildBubbleMeta(m, channel_mode, meta_buf, sizeof(meta_buf), nullptr);
// Bubble layout: timestamp/meta always share the top row (channels, DMs, rooms).
const bool show_sender = (channel_mode || thread_is_room) && !m.outgoing && d.san_sender[0];
if (show_sender || meta_buf[0])
inner_y += lv_font_get_line_height(&g_font_12);
lv_point_t txt_size;
lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
const lv_coord_t txt_w_used = (txt_size.x <= inner_max_w) ? txt_size.x : inner_max_w;
lv_point_t wrapped_size;
lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0,
txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE);
return kChatBubblePadV * 2 + inner_y + wrapped_size.y;
}
static void chatBuildCompactLine(const UITask::UIMessage& m, LvChatPanel* p, int logical_i,
@@ -30212,17 +30357,67 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i
lv_obj_set_style_pad_ver(bubble, kChatBubblePadV, LV_PART_MAIN);
lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH;
lv_point_t txt_size;
lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW;
char meta_buf[48];
uint32_t meta_fg = COLOR_SUB;
chatBuildBubbleMeta(m, p->channel_mode, meta_buf, sizeof(meta_buf), &meta_fg);
// All bubble-style threads (channel / DM / room): timestamp + delivery meta on the top row.
const bool show_sender_line = (p->channel_mode || s_chat_virt.thread_is_room) &&
!m.outgoing && d.san_sender[0];
const lv_coord_t sender_w = show_sender_line ? chatTextWidth(d.san_sender) : 0;
const lv_coord_t meta_w = meta_buf[0] ? chatTextWidth(meta_buf) : 0;
lv_coord_t inner_w = txt_w_used > 0 ? txt_w_used : 1;
{
lv_coord_t header_w = (show_sender_line ? sender_w : 0) + meta_w;
if (show_sender_line && meta_w) header_w += 6;
if (header_w > kInnerMaxW) header_w = kInnerMaxW;
if (header_w > inner_w) inner_w = header_w;
}
int inner_y = 0;
lv_coord_t inner_w = 0; // widest child (sender/text/footer) -> analytic bubble width for x-alignment
if ((p->channel_mode || s_chat_virt.thread_is_room) && !m.outgoing && d.san_sender[0]) {
lv_obj_t* slbl = lv_label_create(bubble);
lv_label_set_text(slbl, d.san_sender);
lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN);
lv_obj_set_pos(slbl, 0, inner_y);
inner_y += lv_font_get_line_height(&g_font_12);
lv_point_t ss; lv_txt_get_size(&ss, d.san_sender, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
if (ss.x > inner_w) inner_w = ss.x;
// Analytic bubble width for x-alignment (widest of sender/text/meta) — do not use
// lv_obj_get_width() right after create; unsettled layout can mis-place outgoing bubbles.
if (show_sender_line || meta_buf[0]) {
const lv_coord_t line_h = lv_font_get_line_height(&g_font_12);
char meta_fit[48] = "";
lv_coord_t meta_fit_w = 0;
lv_coord_t sender_label_w = sender_w;
if (meta_buf[0]) {
lv_coord_t available_meta_w = inner_w - (show_sender_line ? (sender_w + 6) : 0);
const lv_coord_t ell_w = chatTextWidth("...");
if (show_sender_line && available_meta_w < ell_w && inner_w > ell_w + 6) {
sender_label_w = inner_w - ell_w - 6;
available_meta_w = ell_w;
}
chatFitLeadingEllipsis(meta_buf, available_meta_w, meta_fit, sizeof(meta_fit));
meta_fit_w = chatTextWidth(meta_fit);
}
if (show_sender_line && sender_label_w > 0) {
lv_obj_t* slbl = lv_label_create(bubble);
lv_label_set_text(slbl, d.san_sender);
lv_obj_set_style_text_font(slbl, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(slbl, sender_col, LV_PART_MAIN);
if (sender_label_w < sender_w) {
lv_label_set_long_mode(slbl, LV_LABEL_LONG_DOT);
lv_obj_set_width(slbl, sender_label_w);
}
lv_obj_set_pos(slbl, 0, inner_y);
}
if (meta_fit[0]) {
lv_obj_t* mlbl = lv_label_create(bubble);
lv_label_set_text(mlbl, meta_fit);
lv_obj_set_style_text_font(mlbl, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(mlbl, lv_color_hex(meta_fg), LV_PART_MAIN);
lv_obj_set_pos(mlbl, inner_w - meta_fit_w, inner_y);
}
inner_y += line_h;
}
lv_obj_t* tlbl = lv_label_create(bubble);
@@ -30236,10 +30431,6 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i
char rc[UITask::MAX_MSG_TEXT + 40];
if (chatRecolorUrls(d.san_text, rc, sizeof rc)) { lv_label_set_recolor(tlbl, true); lv_label_set_text(tlbl, rc); }
}
const lv_coord_t kInnerMaxW = kBubbleMaxW - 2 * kChatBubblePadH;
lv_point_t txt_size;
lv_txt_get_size(&txt_size, d.san_text, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
const lv_coord_t txt_w_used = (txt_size.x <= kInnerMaxW) ? txt_size.x : kInnerMaxW;
if (txt_size.x > kInnerMaxW) lv_label_set_long_mode(tlbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(tlbl, txt_w_used);
if (txt_w_used > inner_w) inner_w = txt_w_used;
@@ -30254,53 +30445,14 @@ static lv_coord_t chatVirtCreateBubble(LvChatPanel* p, int logical_i, int ring_i
lv_obj_add_event_cb(bubble, bubbleUrlTapCb, LV_EVENT_SHORT_CLICKED,
reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx)));
// Failed sends keep the pre-virtualization one-tap resend (the compact path
// already has it); the footer below spells the affordance out.
// already has it); delivery status on the top meta row spells the affordance out.
if (m.outgoing && m.deliv_state == UITask::DELIV_FAILED)
lv_obj_add_event_cb(bubble, bubbleRetryTapCb, LV_EVENT_CLICKED,
reinterpret_cast<void*>(static_cast<intptr_t>(ring_idx)));
char ts_buf[20];
formatBubbleTs(m.ts, ts_buf, sizeof(ts_buf));
const char* deliv_glyph = "";
uint32_t deliv_fg = COLOR_SUB;
if (m.outgoing && !p->channel_mode && m.deliv_state != UITask::DELIV_NONE) {
switch (m.deliv_state) {
case UITask::DELIV_SENT: deliv_glyph = " " LV_SYMBOL_OK; deliv_fg = COLOR_SUB; break;
case UITask::DELIV_DELIVERED: deliv_glyph = " " LV_SYMBOL_OK LV_SYMBOL_OK; deliv_fg = COLOR_ACCENT; break;
case UITask::DELIV_FAILED: deliv_glyph = " " LV_SYMBOL_CLOSE " tap to resend"; deliv_fg = 0xE08080; break;
default: break;
}
}
char rep_buf[12] = "";
if (m.outgoing && m.sent_fp) {
const uint8_t reps = the_mesh.uiRepeatsForFp(m.sent_fp);
if (reps > 0) snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_REFRESH "%u", (unsigned)reps);
} else if (!m.outgoing && (m.meta_flags & UITask::MSG_META_HAS_RX)
&& (m.meta_flags & UITask::MSG_META_IS_FLOOD)) {
const uint8_t hops = static_cast<uint8_t>(m.path_len & 0x3F);
snprintf(rep_buf, sizeof(rep_buf), " " LV_SYMBOL_SHUFFLE "%u", (unsigned)hops);
}
if (ts_buf[0] || deliv_glyph[0] || rep_buf[0]) {
char footer[40];
snprintf(footer, sizeof(footer), "%s%s%s", ts_buf, deliv_glyph, rep_buf);
lv_obj_t* foot = lv_label_create(bubble);
lv_label_set_text(foot, footer);
lv_obj_set_style_text_font(foot, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(foot, lv_color_hex(deliv_glyph[0] ? deliv_fg : COLOR_SUB), LV_PART_MAIN);
lv_point_t wrapped_size;
lv_txt_get_size(&wrapped_size, d.san_text, &g_font_12, 0, 0,
txt_w_used > 0 ? txt_w_used : LV_COORD_MAX, LV_TEXT_FLAG_NONE);
lv_point_t foot_size;
lv_txt_get_size(&foot_size, footer, &g_font_12, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
if (foot_size.x > inner_w) inner_w = foot_size.x;
const int foot_x = (txt_w_used > foot_size.x) ? (txt_w_used - foot_size.x) : 0;
const int foot_y = inner_y + wrapped_size.y + 1;
lv_obj_set_pos(foot, foot_x, foot_y);
}
lv_obj_update_layout(bubble);
lv_coord_t bh = lv_obj_get_height(bubble);
// Width for x-alignment is computed analytically from the widest child (text/sender/footer),
// Width for x-alignment is computed analytically from the widest child (text/sender/meta),
// NOT lv_obj_get_width(): right after a send the layout isn't settled and get_width reads wide,
// which put the (actually narrow) outgoing bubble on the LEFT until the next rebuild.
lv_coord_t bw = inner_w + 2 * kChatBubblePadH;
@@ -32653,6 +32805,10 @@ static int s_lock_clock_min = -1; // last minute drawn (redraw g
static lv_obj_t* s_lock_unread = nullptr; // envelope + unread count under the clock (issue #93)
static int s_lock_unread_n = -1; // last count drawn (redraw guard)
static unsigned long s_lock_unread_ms = 0; // 1 Hz poll limiter
#if defined(TLORA_PAGER)
static lv_obj_t* s_lock_status = nullptr; // "Screen locked" -- tracked for lockscreenHide() cleanup
static lv_obj_t* s_lock_hint = nullptr; // unlock hint -- tracked for lockscreenHide() cleanup
#endif
// How long the trackball must be held to unlock, in ms.
static const unsigned long kLockUnlockHoldMs = 1000;
@@ -32715,9 +32871,20 @@ static void lockscreenUpdateClock() {
// the panel. Deterministic from the minute, so it also moves on every reveal.
const int dx = (mm % 5) * 3 - 6; // -6 … +6 px
const int dy = ((mm / 5) % 3) * 4 - 4; // -4 … +4 px
#if defined(TLORA_PAGER)
// Same anti-burn-in drift, but around this board's top-LEFT clock position
// (lockscreenShow()'s TOP_LEFT/6,30) instead of T-Deck's TOP_MID -- without
// this override every periodic clock update (this function runs on every
// minute rollover) silently snapped the clock back to horizontally
// centered, undoing the top-left placement the moment it first ticked.
// The unread badge stays in its own right-column spot (300,70) -- it
// doesn't ride with the clock on this layout, so no drift needed there.
lv_obj_align(s_lock_clock, LV_ALIGN_TOP_LEFT, 6 + dx, 30 + dy);
#else
lv_obj_align(s_lock_clock, LV_ALIGN_TOP_MID, dx, 30 + dy);
// The unread badge rides along with the same drift so it never parks either.
if (s_lock_unread) lv_obj_align(s_lock_unread, LV_ALIGN_TOP_MID, dx, 68 + dy);
#endif
s_lock_clock_min = mm;
}
@@ -32797,19 +32964,55 @@ static void lockscreenShow() {
lv_obj_set_style_bg_opa(s_lock_root, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(s_lock_root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(s_lock_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps (no UI leak)
#if defined(TLORA_PAGER)
// Keep the lock overlay OUT of the keyboard/encoder nav focus group. The root
// is CLICKABLE (to absorb taps) and it lives on lv_layer_top, so on this
// no-touch board navMaybeRebuild() would otherwise collect it as the only
// focusable element in the overlay and focus it -- at which point navFocusCb's
// keyboard-focus highlight reverse-videos it: light bg (hidden behind the
// wallpaper) + navInvertText() flipping every child label's text to COLOR_BG
// (dark). That's the reported "lock text renders white then flips to black a
// split second after every reveal, wallpaper stays fine" bug -- the focus
// lands a tick after the reveal paint. NAV_SKIP_FLAG makes navCollect/
// navTreeSig skip the whole subtree, so it's never focused and never inverted.
lv_obj_add_flag(s_lock_root, NAV_SKIP_FLAG);
#endif
// Wallpaper, scaled to cover the screen (crop overflow, never letterbox).
int ww = 0, wh = 0;
const uint8_t* wall_data = nullptr;
if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; }
if (s_lock_wall) {
// Invalidate LVGL's image cache entry for s_lock_wall_dsc BEFORE freeing the
// buffer it points at. s_lock_wall_dsc is a static var (stable address), and
// LVGL's img cache is keyed by that src pointer -- without this, the next
// lockscreenShow() can hit a stale cache entry pointing at freed PSRAM that
// has since been reused elsewhere (e.g. the map's 128 KB tile buffers),
// rendering as RGB565 noise on wake. Same bug class already fixed for map
// tiles in freeMapTileSlot() -- see the comment there (issue #127).
lv_img_cache_invalidate_src(&s_lock_wall_dsc);
lvglPsramFree(s_lock_wall);
s_lock_wall = nullptr;
}
char wpath[TOUCH_LOCK_WALLPAPER_MAXLEN];
touchPrefsGetLockWallpaper(wpath, sizeof wpath);
if (!strcmp(wpath, "/lock/placeholder.jpg")) {
// Default: the pre-dithered RGB565 embed, drawn straight from flash — crisp,
// with no JPEG round-trip to re-introduce gradient banding.
#if defined(TLORA_PAGER)
// Native 480x222 crop of the same icon+wordmark, repositioned to the left
// (see lockscreen_wallpaper_pager_rgb565.h) -- the shared 320x240 art's
// cover-fill on this much wider/shorter panel zoomed it ~1.5x and center-
// cropped ~70px off top+bottom, shoving it into the fixed-position text
// labels below (reported/photographed). Being screen-native, this needs
// no crop math at all: the cover-zoom below naturally computes ~1:1.
wall_data = (const uint8_t*)lockscreen_wallpaper_pager_rgb565;
ww = LOCKSCREEN_WALLPAPER_PAGER_W;
wh = LOCKSCREEN_WALLPAPER_PAGER_H;
#else
wall_data = (const uint8_t*)lockscreen_wallpaper_rgb565;
ww = LOCKSCREEN_WALLPAPER_W;
wh = LOCKSCREEN_WALLPAPER_H;
#endif
} else {
s_lock_wall = lockscreenDecodeWallpaper(&ww, &wh); // custom wallpaper (JPEG)
wall_data = s_lock_wall;
@@ -32826,6 +33029,10 @@ static void lockscreenShow() {
lv_img_set_antialias(img, true);
lv_img_set_pivot(img, ww / 2, wh / 2);
lv_obj_clear_flag(img, LV_OBJ_FLAG_CLICKABLE);
// Background wallpaper: same cover-fill-and-center treatment on every
// board, pager included -- only the text labels below get a pager-
// specific layout (they're what was actually unreadable/colliding; the
// wallpaper crop itself is unchanged from how it's always looked).
uint32_t zx = (uint32_t)sw * 256u / (uint32_t)ww;
uint32_t zy = (uint32_t)sh * 256u / (uint32_t)wh;
uint32_t zoom = (zx > zy) ? zx : zy; // cover
@@ -32834,19 +33041,48 @@ static void lockscreenShow() {
lv_obj_align(img, LV_ALIGN_CENTER, 0, 0);
}
#if defined(TLORA_PAGER)
// Force a guaranteed-visible white here rather than the shared, user-
// customizable touchPrefsGetLockTextColor() -- on this board that pref was
// rendering noticeably dim/dark (reported/photographed against this panel's
// background) even at the shared soft-white default (0xE6F2FF), so this
// board gets pure white instead of chasing a per-device pref/storage
// question for a cosmetic lock screen.
const lv_color_t col = lv_color_hex(0xFFFFFFu);
#else
const lv_color_t col = lv_color_hex(touchPrefsGetLockTextColor());
#endif
s_lock_clock = lv_label_create(s_lock_root);
lv_label_set_text(s_lock_clock, "--:--");
lv_obj_set_style_text_font(s_lock_clock, &lv_font_montserrat_28, LV_PART_MAIN);
lv_obj_set_style_text_color(s_lock_clock, col, LV_PART_MAIN);
#if defined(TLORA_PAGER)
// Fixed-width box spanning the same 192px the composited icon occupies
// (lockscreen_wallpaper_pager_rgb565.h, pasted at x=6 width=192) with
// centered text, instead of just left-anchoring the label -- "10:37" and
// "8:05" are different pixel widths, so anchoring by the label's own LEFT
// edge left the clock's actual visual center drifting with the text
// instead of lining up with the icon below it. Centering within a box of
// the icon's own width keeps the two centers matched regardless of what
// the clock displays.
lv_obj_set_width(s_lock_clock, 192);
lv_obj_set_style_text_align(s_lock_clock, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_align(s_lock_clock, LV_ALIGN_TOP_LEFT, 6, 30); // top-left corner, below the 22 px status bar
#else
lv_obj_align(s_lock_clock, LV_ALIGN_TOP_MID, 0, 30); // below the 22 px status bar
#endif
s_lock_clock_min = -1;
s_lock_unread = lv_label_create(s_lock_root);
lv_obj_set_style_text_font(s_lock_unread, &g_font_16, LV_PART_MAIN);
lv_obj_set_style_text_color(s_lock_unread, col, LV_PART_MAIN);
#if defined(TLORA_PAGER)
// Positioned once the hint label exists below -- see the bottom-right stack
// built after `hint` is created.
#else
lv_obj_align(s_lock_unread, LV_ALIGN_TOP_MID, 0, 68);
#endif
lv_obj_add_flag(s_lock_unread, LV_OBJ_FLAG_HIDDEN);
s_lock_unread_n = -1;
lockscreenUpdateClock();
@@ -32856,16 +33092,20 @@ static void lockscreenShow() {
lv_label_set_text(st, TR("Screen locked"));
lv_obj_set_style_text_font(st, &g_font_16, LV_PART_MAIN);
lv_obj_set_style_text_color(st, col, LV_PART_MAIN);
#if defined(TLORA_PAGER)
s_lock_status = st; // positioned once the hint label exists below -- see the bottom-right stack
#else
lv_obj_align(st, LV_ALIGN_TOP_MID, 0, 190);
#endif
lv_obj_t* hint = lv_label_create(s_lock_root);
#if defined(HAS_TANMATSU)
lv_label_set_text(hint, TR("press Volume Down to unlock"));
#elif defined(HAS_PAGER_KEYBOARD)
// Defensive only -- lockscreenShow() is never actually called on this board
// today (its callers are all HAS_TDECK_GT911-gated); the pager's own lock
// uses the plain off+wake path with no overlay, per updatePagerSpaceHold()/
// updatePagerBackspaceUnlockHold(). Kept correct in case that changes.
// lockscreenShow() DOES run on this board -- lockscreenReveal() (peek on a
// Backspace tap, or the new-message notify flash while locked) is gated on
// CAP_LOCK_SCREEN, which is 1 for the pager, not HAS_TDECK_GT911. (An
// earlier version of this comment claimed otherwise; it was wrong.)
lv_label_set_text(hint, TR("hold Backspace to unlock"));
#elif defined(HAS_THINKNODE_M9)
lv_label_set_text(hint, TR("hold the d-pad to unlock"));
@@ -32875,13 +33115,28 @@ static void lockscreenShow() {
lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(hint, col, LV_PART_MAIN);
lv_obj_set_style_text_opa(hint, LV_OPA_70, LV_PART_MAIN);
#if defined(TLORA_PAGER)
lv_obj_align(hint, LV_ALIGN_BOTTOM_RIGHT, -6, -8); // bottom-right corner, clear of the icon/clock column above
s_lock_hint = hint;
// Message count + "Screen locked" stack right-aligned directly above the hint,
// each anchored to the one below it (not a fixed y) so the group holds
// together and stays right-aligned regardless of label width or whether the
// unread badge is visible.
lv_obj_align_to(st, hint, LV_ALIGN_OUT_TOP_RIGHT, 0, -4);
lv_obj_align_to(s_lock_unread, st, LV_ALIGN_OUT_TOP_RIGHT, 0, -4);
#else
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8);
#endif
}
static void lockscreenHide() {
lockscreenUnlockPopupHide();
if (s_lock_root) { lv_obj_del(s_lock_root); s_lock_root = nullptr; s_lock_clock = nullptr; s_lock_unread = nullptr; }
if (s_lock_wall) { lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; }
if (s_lock_root) { lv_obj_del(s_lock_root); s_lock_root = nullptr; s_lock_clock = nullptr; s_lock_unread = nullptr;
#if defined(TLORA_PAGER)
s_lock_status = nullptr; s_lock_hint = nullptr;
#endif
}
if (s_lock_wall) { lv_img_cache_invalidate_src(&s_lock_wall_dsc); lvglPsramFree(s_lock_wall); s_lock_wall = nullptr; }
s_lock_clock_min = -1;
s_lock_unread_n = -1;
// Restore the status bar's normal opaque background + accent border.
@@ -35159,9 +35414,9 @@ static void openControlCenter() {
lv_obj_t* card = lv_obj_create(s_cc_root);
lv_obj_remove_style_all(card);
#if defined(HAS_TANMATSU)
lv_obj_set_size(card, card_w, 384); // bigger: header + 3 roomier sliders + toggle grid + sysinfo
const int card_h = 384; // bigger: header + 3 roomier sliders + toggle grid + sysinfo
#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9)
lv_obj_set_size(card, card_w, 200); // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen)
const int card_h = 200; // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen)
#elif defined(TLORA_PAGER)
// 222-px-tall screen: the shared V4/T-Deck-landscape 212px card below is
// tuned for a 240px-tall screen (212 + the card's own 4px y-offset = 216,
@@ -35169,12 +35424,20 @@ static void openControlCenter() {
// same card overflows the 200px root by 16px, pushing the toggle row/sysinfo
// text off the bottom of the physical display. Size from the actual
// available height instead of the shared constant, with a small margin.
lv_obj_set_size(card, card_w, sh - STATUSBAR_H - 4 - 6);
const int card_h = sh - STATUSBAR_H - 4 - 6;
#elif defined(HAS_TDISPLAY_P4)
// Tall panel (1232 px): the shared V4 236px card is far too short here. The P4's
// GPS line sits lower (it clears two 30px sliders, so gps_y ~124 vs the V4's ~70),
// and the bottom-anchored 2-row chip grid then climbs up over the GPS text ("the
// acquiring text is under the buttons"). Make the dropdown taller so GPS + chips +
// sysinfo each get their own band — the chip-row math below derives off card_h.
const int card_h = 320;
#else
// Portrait has headroom on the 320-tall screen; make the card taller so the
// brightness slider + toggles + sysinfo all get their own rows.
lv_obj_set_size(card, card_w, portrait ? 236 : 212);
const int card_h = portrait ? 236 : 212;
#endif
lv_obj_set_size(card, card_w, card_h);
lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 4);
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
// Frosted-glass panel: translucent fill so the (dimmed) screen behind shows through.
@@ -35454,7 +35717,7 @@ static void openControlCenter() {
const int per_row = (chip_count + 1) / 2; // 6 -> 3 per row, 5 -> 3 then 2
tw = (card_w - 20 - 5 * (per_row - 1)) / per_row;
if (tw > 92) tw = 92;
int rowh = 196 - (gps_y + 38); // row bottom (216 content − 20) up to just below GPS/env
int rowh = (card_h - 40) - (gps_y + 38); // card bottom (minus margin) up to just below GPS/env (card_h-40 == the old 196 on the 236px V4 card)
if (rowh > 116) rowh = 116;
th = (rowh - 8) / 2; // two rows + an 8px inter-row gap
if (th > 52) th = 52;
@@ -35509,7 +35772,7 @@ static void openControlCenter() {
enum AppDrawerAction {
APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS,
APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL,
APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER,
APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_AIRTIME, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER,
APPACT_DISCOVER,
};
@@ -35755,6 +36018,7 @@ static void appTileCb(lv_event_t* e) {
case APPACT_SIGNAL: openSignalInfoPopup(); return; // signal/traffic + auto-discover settings
case APPACT_MONITOR: openMonitorPage(); return; // RF activity graph + repeater-style radio stats
case APPACT_SPECTRUM: openSpectrumPage(); return; // swept RF spectrum analyzer (borrows the radio)
case APPACT_AIRTIME: ChannelUtil::launch(); return; // channel-utilization / congestion proof tool
case APPACT_DISCOVER: openDiscoverPage(); return; // active node-discovery sweep + nearby list
#if !defined(HAS_TANMATSU)
case APPACT_VNC: openVncPage(); return; // screen mirror + remote control from a browser
@@ -36115,6 +36379,7 @@ static void openAppDrawer() {
{ nullptr, "Signal", APPACT_SIGNAL, 0, COLOR_ACCENT }, // theme (drawn signal bars)
{ TOUCH_SYM_ANTENNA, "Monitor", APPACT_MONITOR, 0, 0x35C9C9 }, // RF monitor cyan
{ TOUCH_SYM_ANTENNA, "Spectrum", APPACT_SPECTRUM, 0, 0xE8A33D }, // RF spectrum analyzer amber
{ "%", "Airtime", APPACT_AIRTIME, 0, 0xF2793C }, // channel-utilization / congestion proof (orange)
{ "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber
{ LV_SYMBOL_SETTINGS, "Settings", APPACT_SETTINGS, 0, 0x9AA3AD }, // neutral gear grey
#if defined(HAS_TOUCH_UI)
@@ -36207,9 +36472,11 @@ static void statusBarHoldCb(lv_event_t* e) {
static void statusBarTapCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
// While Snake is up the bar is raised to the FRONT of lv_layer_top (see the
// edge-trigger in updateGlobalStatusBar), so it stays tappable over the game.
// Swallow the tap so it can't open the control centre over the game.
if (SnakeGame::isOpen()) return;
// edge-trigger in updateGlobalStatusBar), so it stays tappable over the game. The
// game's own X can sit UNDER a taller status bar on big-screen boards (the P4),
// leaving no reachable exit — so a bar tap closes Snake here (the guaranteed way out).
if (SnakeGame::isOpen()) { SnakeGame::dismiss(); return; }
if (ChannelUtil::isOpen()) { ChannelUtil::dismiss(); return; } // guaranteed exit if the X sits under a tall bar
if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold
// A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back
// chevron + title, so a tap goes back (closes the page) just like settings.
@@ -36241,7 +36508,12 @@ static void statusBarTapCb(lv_event_t* e) {
// nothing". This closes the reader on touch-down whenever it's the open page.
static void statusBarReaderBackCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_PRESSED) return;
if (s_reader_page_open && s_apppage_close) s_apppage_close();
// Every full-screen tool page (Reader, Monitor, Spectrum, Discover, VNC, Remote, Advert, …) is
// exited only by the bar's CLICKED -> s_apppage_close. On cap-touch the swipe detector's
// click-abort (lvglTouchRead) can drop that lone CLICKED, trapping the user on a touch-only board
// (the P4). Close on PRESSED too — the proven Reader fallback, now generalised to every app page.
// close() clears s_apppage_close, so the later CLICKED in statusBarTapCb is then a no-op.
if (s_apppage_close) s_apppage_close();
}
// Capture the composited screen to a 16-bit BMP on the SD card. BMP (no
@@ -36860,7 +37132,7 @@ static void updateGlobalStatusBar() {
// lv_layer_top and moved foreground, so without this the TALL bar's lower half (back
// chevron + title) is covered by the page. Settings sheets sit on lv_scr_act so they
// never need this.
const bool want_fg = SnakeGame::isOpen() || (s_apppage_title != nullptr);
const bool want_fg = SnakeGame::isOpen() || ChannelUtil::isOpen() || (s_apppage_title != nullptr);
if (want_fg) {
// Keep the bar the TOPMOST lv_layer_top child so its back tap is always hittable —
// re-assert every tick (a page opened over a chat can otherwise let the chat's
@@ -37501,7 +37773,7 @@ static void relayoutHomeCharts() {
const int cw = tabContentW();
const int BTNW = SC(100);
const int RSTRIP = BTNW + 10;
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_RAK_TAP_V2)
#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_RAK_TAP_V2) || defined(ATTAKY_MESH_SERIES)
const int chart_w = home_land ? (cw - RSTRIP) : cw;
#else
const int chart_w = cw;
@@ -42306,7 +42578,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
s_rmt_boot_guard = RMT_GUARD_MAGIC; // arm; cleared after a good run
}
}
#if defined(ATTAKY_MESH_SERIES)
// Remote landscape must follow the board's own rotation: this panel is ROT_90-
// native, so a hardcoded 270 would leave remote mode upside down.
if (s_remote_mode) s_ui_rotation = s_remote_landscape ? LV_DISP_ROT_90 : LV_DISP_ROT_NONE;
#else
if (s_remote_mode) s_ui_rotation = s_remote_landscape ? LV_DISP_ROT_270 : LV_DISP_ROT_NONE;
#endif
#endif
// Apply the saved backlight brightness (takes the LEDA pin over from the
// display's digitalWrite via LEDC PWM). Both touch boards have the LEDA pin.
@@ -43302,6 +43580,13 @@ static inline void touchScreenBacklight(bool on) {
// the base = MORE conduction = brighter. applyBrightness() below already accounts for this.
if (on) applyBrightness(s_brightness_pct);
else ledcWrite(kM9BlPwmChannel, 255); // inverted: 255 = 0% conduction = off
#elif defined(HAS_TDISPLAY_P4)
// T-Display P4: the RM69A10 AMOLED has no backlight pin — "brightness" is the panel's own DCS
// 0x51 register. Off = 0 (blanks the AMOLED), on = restore the saved brightness. Without this
// branch touchScreenBacklight was the (void)on no-op, so screen-timeout, wake, lock, sleep and
// the burn-in guard all silently did nothing on the P4 (reported: "screen timeout not working").
if (on) applyBrightness(s_brightness_pct);
else display.setBrightness(0);
#else
(void)on;
#endif
@@ -43336,6 +43621,11 @@ void UITask::noteUserInput() {
* release that. Idle-timeout locks still unlock on touch. */
if (_screen_off && _manual_lock) return;
_last_input_ms = millis();
#if defined(ATTAKY_MESH_SERIES)
// This board wakes on POWER_BTN only (polled in the UI loop). A touch on a dark
// panel is already absorbed by the indev read, and must not light it either.
if (_screen_off) return;
#endif
if (_screen_off) wakeScreen();
}
@@ -44067,6 +44357,10 @@ void UITask::loop() {
// every click. Signed keeps a slightly-ahead stamp negative (= "just had input").
if (_screen_timeout_ms > 0 && !_screen_off &&
(int32_t)(now - _last_input_ms) >= (int32_t)_screen_timeout_ms) {
#if !defined(HAS_TDISPLAY_P4)
// The P4 has no unlock path (no button/overlay), so it must NEVER hard-lock, even if an old
// pref left the flag set — it always takes the plain screen-off branch below, which wakes on
// touch. (Guards the trap; the "Lock when screen off" toggle is also hidden on the P4.)
if (s_lock_on_screen_off && !_manual_lock) {
// "Lock when screen off": idle dim also hard-locks, so the touchscreen is
// inert until a deliberate unlock (trackball hold on the T-Deck, BOOT
@@ -44074,7 +44368,9 @@ void UITask::loop() {
// !_manual_lock guard stops the Tanmatsu's lit lock screen from re-firing
// lockScreen() (and re-lighting) on every idle tick.
lockScreen();
} else {
} else
#endif
{
touchScreenBacklight(false);
setCpuForScreen(false); // idle dim (no lock) -> drop to 80 MHz too
_screen_off = true;
@@ -44337,9 +44633,12 @@ void UITask::loop() {
// whole batch so nothing queued here leaks through as real input on the
// very next tick right after waking.
bool any = false;
bool saw_backspace = false;
for (int kbi = 0; kbi < 12; ++kbi) {
if (pagerKeyboardReadKey() <= 0) break;
int k = pagerKeyboardReadKey();
if (k <= 0) break;
any = true;
if (k == 0x08) saw_backspace = true;
}
// Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while
// idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home)
@@ -44348,8 +44647,21 @@ void UITask::loop() {
pagerKeyboardConsumeAltShiftChord();
pagerKeyboardConsumeAltBackspaceChord();
// Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding
// Backspace does (updatePagerBackspaceUnlockHold, already polled above).
if (any && !g_lv.task->isManualLock()) g_lv.task->wakeScreen();
// Backspace does (updatePagerBackspaceUnlockHold, polled unconditionally
// above off the raw held-state, so it's unaffected by this drain either
// way). A Backspace TAP, though, should PEEK the lock screen -- light the
// wallpaper, stay locked -- the keyboard-only equivalent of the T-Deck's
// trackball click while locked (lockscreenReveal() is the exact same call
// that path, and the new-message notify flash, already use). Without this
// the ring byte a Backspace press pushes immediately on press was just
// getting silently drained above with everything else, so tapping it
// while the screen was actually dark did nothing (reported bug) even
// though holding it through to unlockScreen worked fine.
if (g_lv.task->isManualLock()) {
if (saw_backspace) g_lv.task->lockscreenReveal();
} else if (any) {
g_lv.task->wakeScreen();
}
} else {
for (int kbi = 0; kbi < 12; ++kbi) {
int key = pagerKeyboardReadKey();
@@ -44361,6 +44673,11 @@ void UITask::loop() {
updatePagerAltBackspaceChord();
updatePagerBackspaceHold(now);
updatePagerSpaceHold(now);
// Missing on this board until now (T-Deck/M9 both already call it in their
// own equivalent branch): without this the lock-screen clock/unread badge
// never refreshed again after the first reveal -- correct at the moment
// you peek, then frozen there for as long as the screen stays lit.
serviceLockscreen();
}
#elif defined(HAS_M9_KEYBOARD)
// M9's keyboard controller is on its OWN bus (Wire1) — no touch task shares
@@ -44379,6 +44696,15 @@ void UITask::loop() {
serviceLockscreen();
serviceLockingCountdown(now);
#endif
#if defined(ATTAKY_MESH_SERIES)
// POWER_BTN (AW9523 @0x59 P07) toggles the panel. Polled before the screen-off
// early-outs so it works with the backlight down; it is this board's only wake.
attakyKeysPoll();
if (attakyPowerKeyPressed()) {
if (_screen_off) wakeScreen();
else sleepScreen();
}
#endif
#if defined(HAS_ATTAKY_MESH_KEYBOARD)
{
lv_obj_t* akb_ta = g_lv.keyboard ? lv_keyboard_get_textarea(g_lv.keyboard) : nullptr;
@@ -44387,8 +44713,31 @@ void UITask::loop() {
int key = attakyKeyboardReadKey();
if (key <= 0) break;
if (!_screen_off) noteUserInput();
if (key == 0x0D || key == 0x0A) lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr);
// Enter in the chat composer sends (upstream does this in handleHwKey(), which
// this board does not compile; READY only dismisses the keys). Honour the
// enter-sends pref and rebind after, so the module can type the next message.
if (key == 0x0D || key == 0x0A) {
if (s_kb_panel && touchPrefsGetEnterSends()) {
LvChatPanel* const p = s_kb_panel; // the send path clears s_kb_panel
composerSendFromPanel(p);
if (p->composer_ta && lv_obj_is_valid(p->composer_ta)) showKb(p);
break; // keyboard rebound above; akb_ta is stale from here
}
else if (s_kb_panel) lv_textarea_add_char(akb_ta, '\n'); // enter-sends off: compose multi-line
else lv_event_send(g_lv.keyboard, LV_EVENT_READY, nullptr); // settings field: confirm
}
else if (key == 0x08 || key == 0x7F) lv_textarea_del_char(akb_ta);
// '#' summons the on-screen keys for this editing session (hideKb clears it),
// opening straight on the symbol panel: the module already types letters and
// digits, so the keys are only for symbols its 5x5 matrix cannot reach.
else if (key == '#' && attakyKeyboardPresent() && !s_osk_forced) {
s_osk_forced = true;
if (s_kb_panel) showKb(s_kb_panel);
else kbMirrorBind(akb_ta);
// Set the mode after the reveal (it redraws the map). 'abc' still returns to letters.
if (g_lv.keyboard) lv_keyboard_set_mode(g_lv.keyboard, LV_KEYBOARD_MODE_SPECIAL);
break; // both rebind the keyboard's textarea; akb_ta is stale from here
}
else if (key >= 0x20) lv_textarea_add_char(akb_ta, (uint32_t)key);
}
}
@@ -44544,7 +44893,7 @@ void UITask::loop() {
}
// Also hard-lock the tab bar while Snake is open so a swipe/tap near the
// bottom can't slip through to the app switcher (e.g. into the map).
const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen();
const bool want_lock = (now < s_tabbar_lock_until) || SnakeGame::isOpen() || ChannelUtil::isOpen();
if (want_lock != s_tabbar_locked && g_lv.tabview) {
lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview);
if (tab_btns) {
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -69,8 +69,12 @@ set(WADA_DEFS
# PIN_GPS_TX = the GPIO wired to the MODULE'S TX pin; PIN_GPS_RX = wired to the module's RX.
# Matches LilyGo's t_display_p4_config.h "GPS_TX 22 / GPS_RX 23" naming. The first cut had them
# swapped (silent: UART opens fine, just never hears NMEA). Wake: XL9535 GPS_WAKE must be HIGH
# (powerOnSequence) or the L76K sleeps. Baud: L76K power-on default 9600 = the core default. ---
ENV_INCLUDE_GPS=1 PIN_GPS_TX=22 PIN_GPS_RX=23
# (powerOnSequence) or the L76K sleeps. Baud: L76K power-on default 9600 = the core default.
# ENV_SKIP_GPS_DETECT: like the T-Deck, force gps_detected=true — the P4's heavy boot can miss the
# 1 s "any NMEA byte" detect window, and a false result then HIDES the GPS toggle and no-ops the
# saved-pref resume, so GPS stays dead with no recovery until a lucky reboot. GPS_BAUD_RATE=9600
# pins the correct L76K baud (a stale 38400 NVS override will still need clearing on-device). ---
ENV_INCLUDE_GPS=1 PIN_GPS_TX=22 PIN_GPS_RX=23 ENV_SKIP_GPS_DETECT=1 GPS_BAUD_RATE=9600
# --- microSD on SDMMC slot 0 (CLK43/CMD44/D0-3=39-42); C6 esp-hosted is slot 1 ---
BOARD_HAS_SDMMC=1 BOARD_SDMMC_SLOT=0
@@ -114,3 +118,12 @@ endif()
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"${WADA_FW_TAG}\"" APPEND)
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"${WADA_FW_DATE}\"" APPEND)
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_VERSION=\"wadamesh ${WADA_FW_TAG}\"" APPEND)
# On-device OTA / update-channel UI (the "Get test builds (beta)" toggle + the Install-update button
# in UITask.cpp) is gated on a NON-EMPTY FIRMWARE_OTA_ENV. The 8 S3 boards set this per-env in
# platformio.ini; the P4 is an IDF build and must set it here, or the whole update UI stays hidden
# (the symptom testers hit on beta_49: no beta toggle, stuck reading the stable channel). The value is
# only a presence marker — the OTA download URL is built from OTA_BIN_NAME (set per-SKU in UITask.cpp),
# not from this. The P4 partition table has dual ota_0/ota_1 slots and the C6 WiFi shim routes the
# HTTPClient + Update.h writer, so the OTA path itself is wired; this just unhides the UI.
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_OTA_ENV=\"tdisplay_p4\"" APPEND)
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AttakyMeshSeriesKeys.h"
#if defined(ATTAKY_MESH_SERIES) && defined(ESP32)
#include <Arduino.h>
#include <Wire.h>
#include "AttakySharedI2C.h"
static const uint8_t KEYS_ADDR = 0x59;
static const uint8_t AW_REG_IN_P0 = 0x00;
static const uint8_t AW_REG_CFG_P0 = 0x04;
static const uint8_t AW_REG_MODE_P0 = 0x12; // 0x12 = P0 LED-mode select (0x13 is P1)
static const uint8_t POWER_BIT = (1u << 7); // P07 = POWER_BTN
// Debounce: the buttons sit behind a shared-bus I2C expander, so require a
// stable read across several polls before a press counts.
static const uint8_t DEBOUNCE_POLLS = 2;
static const uint32_t POLL_INTERVAL_MS = 30;
static bool s_inited = false;
static bool s_present = false;
static uint32_t s_next_ms = 0;
static bool s_power_down = false; // debounced state
static uint8_t s_pending_n = 0; // consecutive polls agreeing with s_pending_down
static bool s_pending_down = false;
static volatile bool s_power_event = false;
static bool awWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(KEYS_ADDR);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
// Reads IN_P0. Returns false on I2C failure without touching *val, so a failed
// read is never mistaken for 0xFF ("nothing pressed").
static bool awReadInP0(uint8_t* val) {
Wire.beginTransmission(KEYS_ADDR);
Wire.write(AW_REG_IN_P0);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)KEYS_ADDR, 1) != 1) return false;
*val = (uint8_t)Wire.read();
return true;
}
static void ensureInit() {
if (s_inited) return;
s_inited = true;
if (!attakyI2cLock(30)) { s_inited = false; return; } // retry on the next poll
// P0 as GPIO inputs (POR defaults, but the touch driver shares this expander,
// so set them explicitly rather than depend on init order).
bool ok = awWrite(AW_REG_MODE_P0, 0xFF); // GPIO mode, not LED mode
ok &= awWrite(AW_REG_CFG_P0, 0xFF); // all inputs
s_present = ok;
attakyI2cUnlock();
}
void attakyKeysPoll() {
ensureInit();
if (!s_present) return;
const uint32_t now = millis();
if ((int32_t)(now - s_next_ms) < 0) return;
s_next_ms = now + POLL_INTERVAL_MS;
uint8_t p0 = 0xFF;
bool ok = false;
if (attakyI2cLock(10)) {
ok = awReadInP0(&p0);
attakyI2cUnlock();
}
if (!ok) return; // bus busy or read failed — hold the previous state
const bool down = (p0 & POWER_BIT) == 0; // active low
if (down != s_pending_down) { s_pending_down = down; s_pending_n = 1; return; }
if (s_pending_n < DEBOUNCE_POLLS) { s_pending_n++; return; }
if (down != s_power_down) {
s_power_down = down;
if (down) s_power_event = true; // fire on the press edge
}
}
bool attakyPowerKeyPressed() {
if (!s_power_event) return false;
s_power_event = false;
return true;
}
bool attakyKeysPresent() { return s_present; }
#else
void attakyKeysPoll() {}
bool attakyPowerKeyPressed() { return false; }
bool attakyKeysPresent() { return false; }
#endif
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stdbool.h>
// Front buttons on the board's AW9523 @0x59 (P00-P07, active-low). Only
// POWER_BTN (P07) is used: short-press toggles the panel. A long press is the
// board's hardware power-cut, outside firmware control.
/** Poll the expander. Call once per UI tick; rate-limits its own I2C traffic. */
void attakyKeysPoll();
/** True once per POWER_BTN press edge (consumes the event). */
bool attakyPowerKeyPressed();
/** Whether the @0x59 expander answered at init (false = keys unavailable). */
bool attakyKeysPresent();
+39 -5
View File
@@ -19,9 +19,19 @@
#include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51)
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_hi8561.h"
#include "driver/ledc.h" // GPIO 51 PWM backlight — the TFT-LCD has a real LED backlight
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
// The HI8561 TFT-LCD backlight is a hardware LED driven by PWM on GPIO 51 (LilyGo's
// hi8561::kScreenBacklight). Unlike the AMOLED (RM69A10, self-emissive, brightness via DCS 0x51),
// the LCD panel renders but stays dark until this PWM is driven — the "no backlight, works under a
// flashlight" report on the P4 LCD. Set up LEDC and drive it from setBrightness().
static constexpr int kBacklightGpio = 51;
static constexpr ledc_timer_t kBlTimer = LEDC_TIMER_0;
static constexpr ledc_channel_t kBlChan = LEDC_CHANNEL_0;
static bool s_hi8561_bl_ready = false;
// The DPI panel copies each draw_bitmap band into the internal frame buffer ASYNCHRONOUSLY
// (DMA2D) and fires on_color_trans_done when the copy is complete. writePixelsRGB565 reuses a
// single upscale scratch buffer (and LVGL reuses its draw buffer) on the very next flush, so
@@ -119,9 +129,28 @@ bool HI8561Display::begin() {
if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[HI8561] panel_init fail"); return false; }
esp_lcd_panel_disp_on_off(_panel, true);
// Brightness: the HI8561's default init does SLPOUT/DISPON but leaves brightness unset; drive it at
// runtime via DCS 0x51 (the TDDI's integrated backlight — no GPIO). Keep the DBI IO handle so
// setBrightness() can drive it live (CC slider); start at max so the LCD actually lights.
// Backlight: bring up the LEDC PWM on GPIO 51 (the LCD's real LED backlight). Without this the
// panel renders but is dark. 20 kHz avoids audible/visible flicker; 8-bit duty maps 0..255 directly.
{
ledc_timer_config_t bl_tmr = {};
bl_tmr.speed_mode = LEDC_LOW_SPEED_MODE;
bl_tmr.timer_num = kBlTimer;
bl_tmr.duty_resolution = LEDC_TIMER_8_BIT;
bl_tmr.freq_hz = 20000;
bl_tmr.clk_cfg = LEDC_AUTO_CLK;
ledc_channel_config_t bl_ch = {};
bl_ch.gpio_num = kBacklightGpio;
bl_ch.speed_mode = LEDC_LOW_SPEED_MODE;
bl_ch.channel = kBlChan;
bl_ch.timer_sel = kBlTimer;
bl_ch.duty = 0;
bl_ch.hpoint = 0;
s_hi8561_bl_ready = (ledc_timer_config(&bl_tmr) == ESP_OK) &&
(ledc_channel_config(&bl_ch) == ESP_OK);
if (!s_hi8561_bl_ready) Serial.println("[HI8561] backlight PWM init FAILED");
}
// Keep the DBI IO handle so setBrightness() can also send DCS 0x51 (harmless if the panel
// ignores it); the real light is the PWM above. Start at max so the LCD actually lights.
_dbi_io = dbi_io;
setBrightness(255);
_on = true;
@@ -182,8 +211,13 @@ void HI8561Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t
}
void HI8561Display::setBrightness(uint8_t b) {
if (!_dbi_io) return;
esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
// The real backlight is the LEDC PWM on GPIO 51 (drives the LCD's LED); the DCS 0x51 is a
// best-effort no-op the AMOLED path used. Drive both so the CC slider dims the LCD.
if (s_hi8561_bl_ready) {
ledc_set_duty(LEDC_LOW_SPEED_MODE, kBlChan, b);
ledc_update_duty(LEDC_LOW_SPEED_MODE, kBlChan);
}
if (_dbi_io) esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
}
void HI8561Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }