V4-R8 perf pass + M9 UI polish (prefs v49/v50)

Heltec V4-R8 — "are we getting full performance?" audit. Compute side was already
at spec; six gaps closed (record: variants/heltec_v4/R8_AUDIT.md "Perf pass"):
- FEM LNA defaulted OFF on a KCT8103L board: prefs v49 defaults it ON on the R8
  (one-time flip of existing installs, no new field). Toggle stays in Radio & Mesh.
- Display SPI 40 -> 80 MHz (LGFX_SPI_WRITE_HZ; -D to 40000000 steps back).
- Async DMA band flush (LGFXDisplay::flushBandRGB565): swap into a 12 KB internal
  DMA buffer, one no-convert DMA per band, lv_disp_flush_ready at once so LVGL
  renders band N+1 while band N drains; lv_disp_flush_is_last closes the frame
  transaction so the shared micro-SD gets the bus between frames. Sync fallback.
- Wi-Fi modem power save is a pref (wifi_ps; toggle in Wi-Fi settings, live once
  associated). Default ON everywhere (unchanged), OFF on the R8.
- micro-SD 4 -> 20 MHz after the proven mount, read-verified (probe-file head
  captured at 4 MHz and byte-compared after the raise; falls back). Wired at all
  three mount sites; no-op without SD_SPI_FAST_HZ. Note: SDFS::begin returns true
  if already mounted, so every clock change goes through SD.end() first.
- R8 env boots at ESP32_CPU_FREQ=240 (setup() no longer runs at the V4's 80 MHz).
- Settings -> About "Perf:" row on the R8 (no serial console on that board):
  "CPU 240 · TFT 80 MHz DMA · SD 20 MHz · LNA on" is the all-green reading.

ThinkNode M9 UI:
- Spectrum page root gets NAV_SKIP_FLAG: nothing on it is actionable, so the
  d-pad no longer highlights the chart / scale box; Back closes it as before.
- Map "Show tile z/x/y" line defaults OFF (prefs v50, one-time flip; toggle kept).
- The tab bar is bypassed entirely on the M9: the accent "glow" indicator lived on
  the screen, not in the bar, so hiding the zero-height bar left it glowing over
  the content's bottom edge. No bar styling / gestures / key hints / indicator
  are built on the M9 now.

Cross-compiled: R8, V4, M9, T-Deck. Flashed + booted on the user's R8 and M9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Van Hoose
2026-08-20 13:51:11 -04:00
co-authored by Claude Fable 5
parent 16e883187c
commit 844d8c3357
11 changed files with 423 additions and 34 deletions
+94
View File
@@ -0,0 +1,94 @@
#pragma once
// Post-mount micro-SD operating-clock raise (perf pass 2026-08-20; Heltec V4-R8 first).
//
// Every SPI-SD board mounts the card with a conservative ladder that tops out at 4 MHz,
// and SD.begin()'s clock is the operating clock for the whole session — so the card that
// is the PRIMARY store on the R8 (contacts, chat history, sync log, file manager) ran at
// ~400 KB/s on traces that carry the display at 80 MHz. Standard SD bring-up is "initialise
// slow, then raise": once the 4 MHz mount has proved the card, re-begin at SD_SPI_FAST_HZ
// and READ-VERIFY it — a probe file's first 512 bytes are read at the proven clock first
// and compared byte-for-byte after the raise (SPI-mode SD has no data CRC by default, so a
// bare "mount succeeded" would not catch a marginal clock). Any mismatch or failure drops
// straight back to the clock that just worked. Boards that do not define SD_SPI_FAST_HZ
// (or set it <= 4 MHz) get a no-op — nothing changes for the T-Deck/M9/Pager ladders.
//
// Returns the operating clock after the call (cur_hz if the raise was refused or failed),
// or 0 if the card could not be re-mounted at all (the caller treats that as unmounted).
#include <Arduino.h>
#if defined(ESP32)
#include <SD.h>
#include <SPI.h>
#include <string.h>
#ifndef SD_SPI_FAST_HZ
#define SD_SPI_FAST_HZ 0
#endif
static inline uint32_t sdTryFastClock(uint8_t cs_pin, SPIClass& spi, uint32_t cur_hz, const char* tag) {
#if SD_SPI_FAST_HZ > 4000000
if (cur_hz == 0 || cur_hz >= (uint32_t)SD_SPI_FAST_HZ) return cur_hz;
// 1) Pick a probe file and capture its head at the proven clock.
char probe_path[96] = {0};
uint8_t ref[512];
size_t ref_len = 0;
{
File root = SD.open("/");
if (root && root.isDirectory()) {
for (int i = 0; i < 16; ++i) {
File e = root.openNextFile();
if (!e) break;
if (!e.isDirectory() && e.size() > 0 && e.path() && strlen(e.path()) < sizeof probe_path) {
strlcpy(probe_path, e.path(), sizeof probe_path);
ref_len = e.size() < sizeof ref ? e.size() : sizeof ref;
if (e.read(ref, ref_len) != ref_len) { probe_path[0] = 0; ref_len = 0; }
e.close();
break;
}
e.close();
}
root.close();
}
}
// 2) Re-begin at the fast clock.
SD.end();
delay(20);
bool ok = SD.begin(cs_pin, spi, SD_SPI_FAST_HZ, "/sd", 6) && SD.cardType() != CARD_NONE;
// 3) Verify: directory walk always; byte-compare of the probe head when we have one.
if (ok) {
File root = SD.open("/");
ok = root && root.isDirectory();
if (ok) { File e = root.openNextFile(); if (e) e.close(); root.close(); }
}
if (ok && probe_path[0]) {
uint8_t now[512];
File p = SD.open(probe_path, FILE_READ);
ok = p && p.read(now, ref_len) == ref_len && memcmp(now, ref, ref_len) == 0;
if (p) p.close();
}
if (ok) {
Serial.printf("[%s] SD clock %lu -> %lu Hz (%s)\n", tag, (unsigned long)cur_hz,
(unsigned long)SD_SPI_FAST_HZ, probe_path[0] ? "read-verified" : "dir-verified, no probe file");
return (uint32_t)SD_SPI_FAST_HZ;
}
// 4) Fall back to the clock that just worked.
Serial.printf("[%s] SD %lu Hz verify FAILED; back to %lu Hz\n", tag,
(unsigned long)SD_SPI_FAST_HZ, (unsigned long)cur_hz);
for (int attempt = 0; attempt < 2; ++attempt) {
SD.end();
delay(attempt == 0 ? 60 : 150);
if (SD.begin(cs_pin, spi, cur_hz, "/sd", 6) && SD.cardType() != CARD_NONE) return cur_hz;
}
Serial.printf("[%s] SD remount at %lu Hz failed\n", tag, (unsigned long)cur_hz);
return 0;
#else
(void)cs_pin; (void)spi; (void)tag;
return cur_hz;
#endif
}
#endif // ESP32
+11
View File
@@ -252,6 +252,17 @@ 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
; Boot at the S3's full clock. The base env's ESP32_CPU_FREQ=80 is applied in
; ESP32Board::begin(), so ALL of setup() — radio init, the SD mount ladder, the
; store load, mesh begin, Wi-Fi bring-up — ran at a third speed until UITask::begin
; bumped it to 240. The screen-off DFS drop to 80 MHz (setCpuForScreen) is unaffected.
-D ESP32_CPU_FREQ=240
; micro-SD operating clock (perf pass 2026-08-20): after the conservative 4 MHz mount
; succeeds, re-begin at this clock with a directory + file READ verify and fall back
; to 4 MHz if it fails (include/SdFastClock.h). The same traces already carry the
; display at 80 MHz, and the card is the primary store on this board (contacts, chat
; history, sync log) — 4 MHz was the M9 ladder's value, never re-tuned here.
-D SD_SPI_FAST_HZ=20000000
-D P_LORA_TX_LED=46
-D PIN_VEXT_EN=40
-D PIN_VEXT_EN_ACTIVE=LOW
+1 -1
View File
@@ -9,7 +9,7 @@
namespace TouchPrefsSchema {
static constexpr uint16_t MAGIC = 0x5743; // 'WC' (WadaCfg)
static constexpr uint8_t CURRENT_VERSION = 48;
static constexpr uint8_t CURRENT_VERSION = 50; // v49: fem_lna default flip on the V4-R8; v50: map tile z/x/y line off by default (no new fields)
static constexpr uint8_t BROKEN_MID_INSERT_VERSION = 44;
// Persisted byte layout. New fields must be appended at the end: older blobs
+15 -2
View File
@@ -37,7 +37,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 = TouchPrefsSchema::MAGIC;
static const uint8_t TOUCH_CFG_VER = TouchPrefsSchema::CURRENT_VERSION; // 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); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once); v44 broken retry_echo mid-struct insertion; v45 moves retry_echo to the actual tail and resets the ambiguous v44 suffix
static const uint8_t TOUCH_CFG_VER = TouchPrefsSchema::CURRENT_VERSION; // 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); v43 Pager UI-size presets (reset the previously ignored large-screen default to Small once); v44 broken retry_echo mid-struct insertion; v45 moves retry_echo to the actual tail and resets the ambiguous v44 suffix; v46 app_hide; v47 MQTT hidden by default; v48 lang_file; v49 fem_lna default ON on the V4-R8 (KCT8103L FEM; one-time flip of existing installs, no new field); v50 map_show_tilexyz default OFF (tile z/x/y line hidden; one-time flip, no new field)
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -106,7 +106,7 @@ static void cfgSetDefaults(TouchCfg& c) {
c.map_night = 0; // default: normal (light) tiles
c.map_zoom = 0; // 0 = unset -> auto-snap on first map open
c.map_show_coords = 1; // default: show coords / tile line / contacts
c.map_show_tilexyz = 1;
c.map_show_tilexyz = 0; // v50: the "z12 12/2105/1376" tile-path line is developer clutter on the map; opt-in via Map options
c.map_show_contacts = 1;
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
#if defined(TLORA_PAGER)
@@ -121,7 +121,13 @@ static void cfgSetDefaults(TouchCfg& c) {
#endif
c.tb_nav = 0; // T-Deck trackball: soft-cursor by default. D-pad UI nav is EXPERIMENTAL (opt-in)
c.scope_direct = 0; // OFF: direct/login floods stay unscoped (cross-region safe). Opt-in per issue #64.
#if defined(HELTEC_LORA_V4_R8)
c.fem_lna = 1; // ON (v49): the V4-R8 is a V4.3.1-generation board with the KCT8103L FEM, whose
// switchable ~17 dB LNA is the whole point of that FEM revision — shipping it
// bypassed left RX sensitivity on the table. Toggle stays in Radio & Mesh.
#else
c.fem_lna = 0; // OFF: V4.3 FEM LNA bypassed (matches the hardware default). Opt-in high-gain RX.
#endif
c.msg_flash = 0; // OFF: opt-in new-message keyboard/screen flash
c.flood_adv_hrs = 0; // OFF: no periodic flood self-advert (advertise manually)
c.local_adv_min = 0; // OFF: no periodic zero-hop self-advert
@@ -217,6 +223,13 @@ static void cfgLoadOrMigrate() {
if (stored_version < 24) s_cfg.tb_nav = 0;
// v25: new trailing field — V4.3 FEM LNA OFF on existing installs (matches hardware default).
if (stored_version < 25) s_cfg.fem_lna = 0;
#if defined(HELTEC_LORA_V4_R8)
// v49: FEM LNA ON by default on the V4-R8 (KCT8103L). One-time flip of existing
// installs so they match the new default; an explicit later off/on persists.
if (stored_version < 49) s_cfg.fem_lna = 1;
#endif
// v50: map tile z/x/y overlay line OFF by default (one-time flip; the Map-options toggle persists afterwards).
if (stored_version < 50) s_cfg.map_show_tilexyz = 0;
if (stored_version < 26) s_cfg.msg_flash = 0;
if (stored_version < 27) { s_cfg.flood_adv_hrs = 0; s_cfg.local_adv_min = 0; }
if (stored_version < 28) s_cfg.beta_updates = 0;
+20
View File
@@ -17,6 +17,12 @@ static const char *WIFI_CONFIG_PWD_KEY = "wifi_pwd";
static const char *WIFI_CONFIG_RADIO_EN_KEY = "wifi_radio_en";
static const char *WIFI_CONFIG_WIFI_CHOSEN_KEY = "wifi_chosen";
static const char *WIFI_CONFIG_BLE_EN_KEY = "ble_en"; // BLE radio on/off (default on)
static const char *WIFI_CONFIG_PS_KEY = "wifi_ps"; // modem power save once associated
#if defined(HELTEC_LORA_V4_R8)
#define WIFI_CONFIG_PS_DEFAULT 0 // USB-powered Expansion Kit: latency + link stability over mA
#else
#define WIFI_CONFIG_PS_DEFAULT 1 // battery boards keep the DTIM sleep they always had
#endif
static SdNvsPrefs s_prefs;
static bool s_begun = false;
@@ -143,6 +149,20 @@ void wifiConfigSetBleEnabled(bool enabled) {
s_begun = s_prefs.begin(WIFI_CONFIG_NAMESPACE, true);
}
bool wifiConfigGetPowerSave() {
if (!s_begun) wifiConfigBegin();
return s_prefs.getUChar(WIFI_CONFIG_PS_KEY, WIFI_CONFIG_PS_DEFAULT) != 0;
}
void wifiConfigSetPowerSave(bool enabled) {
if (!s_begun) wifiConfigBegin();
s_prefs.end();
if (!s_prefs.begin(WIFI_CONFIG_NAMESPACE, false)) return;
s_prefs.putUChar(WIFI_CONFIG_PS_KEY, enabled ? 1 : 0);
s_prefs.end();
s_begun = s_prefs.begin(WIFI_CONFIG_NAMESPACE, true);
}
#if defined(TLORA_PAGER)
void wifiConfigSetPagerWifiBlePhase(PagerWifiBlePhase phase) {
s_pager_wifi_ble_phase = phase;
+8
View File
@@ -27,6 +27,14 @@ void wifiConfigSetRadioEnabled(bool enabled);
bool wifiConfigGetBleEnabled();
void wifiConfigSetBleEnabled(bool enabled);
/* Wi-Fi modem power save (DTIM sleep) once associated. ON saves power and gives BLE
* coexistence airtime; OFF keeps the RX chain up for a lower-latency companion-TCP /
* web-mirror link and holds a marginal association better. Default ON everywhere
* except the V4-R8 (USB-powered Expansion Kit form factor; perf pass 2026-08-20).
* Applied by the main loop after association and live by the Wi-Fi settings toggle. */
bool wifiConfigGetPowerSave();
void wifiConfigSetPowerSave(bool enabled);
#if defined(TLORA_PAGER)
/* Pager coexistence ownership. Wi-Fi intent alone cannot answer whether a
* cold NimBLE start is safe: touch builds keep the STA scannable even with no
+14 -1
View File
@@ -51,6 +51,7 @@ static uint32_t _atoi(const char* sp) {
#include <SPIFFS.h>
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
#include <SD.h>
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
#include <Preferences.h>
#if defined(TLORA_PAGER)
#include <mbedtls/sha256.h>
@@ -996,6 +997,7 @@ void setup() {
delay(60);
if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 6) && SD.cardType() != CARD_NONE) {
Serial.printf("[BOOT] SD renegotiated %lu -> 4000000 Hz\n", (unsigned long)mounted_hz);
mounted_hz = 4000000;
} else {
SD.end();
delay(120);
@@ -1003,6 +1005,9 @@ void setup() {
if (sd_mounted) Serial.printf("[BOOT] SD stays at %lu Hz (4 MHz renegotiation failed)\n", (unsigned long)mounted_hz);
}
}
// Operating-clock raise with read-verify (SD_SPI_FAST_HZ boards only; no-op elsewhere).
if (sd_mounted) { mounted_hz = sdTryFastClock(PIN_SD_CS, *_spi, mounted_hz, "BOOT"); sd_mounted = mounted_hz != 0; }
if (sd_mounted) { extern uint32_t g_sd_operating_hz; g_sd_operating_hz = mounted_hz; } // About-page readout (UITask.cpp)
}
#endif
if (sd_mounted) {
@@ -1686,8 +1691,16 @@ void loop() {
// BLE coexistence airtime). Deferred to here on purpose: enabling it on the
// unassociated STA naps the radio through a scan dwell and breaks the setup
// wizard's WiFi.scanNetworks() ("no networks found"). One-shot.
// Persisted preference (Wi-Fi settings -> "Power save"): default ON, except the V4-R8
// where it defaults OFF (perf pass 2026-08-20 — lower-latency app link, steadier
// association on its weak 2.4 GHz path). The toggle also applies live once associated.
static bool modem_sleep_set = false;
if (!modem_sleep_set) { WiFi.setSleep(true); modem_sleep_set = true; }
if (!modem_sleep_set) {
const bool ps = wifiConfigGetPowerSave();
WiFi.setSleep(ps);
Serial.printf("[wifi] modem power save %s\n", ps ? "on" : "off");
modem_sleep_set = true;
}
if (!sntp_kicked) {
/* Brussels timezone with DST rules baked in (POSIX "CET-1CEST,...").
* On touch builds the base is shifted by the user's manual hour offset
+105 -21
View File
@@ -62,6 +62,7 @@
#endif
#if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
#include <SD.h> // microSD — T-Deck/M9 on the LoRa SPI, V4-R8 on the TFT SPI
#include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards)
#include "sd_diskio.h" // internal Arduino-SD drive helpers (sdcard_init / sd_*_raw)
extern SPIClass* tdeckSharedSPI();
// FatFs mkfs (the prebuilt ESP-IDF compiles f_setlabel OUT — FF_USE_LABEL=0 —
@@ -2149,6 +2150,10 @@ static bool g_cap_touch_hw_started = false;
// LVGL just calls flush_cb more often.
static constexpr int LV_DRAW_BUF_LINES = 24;
static lv_color_t* g_draw_buffer = nullptr;
// Operating clock of the LAST successful SD.begin() on SPI-SD boards (0 = none). Recorded at
// every mount/remount site (main.cpp boot adoption + the two UITask mounts) because SD.begin's
// clock is the session clock; Settings -> About reports it on the R8 (no serial console there).
uint32_t g_sd_operating_hz = 0;
static uint32_t g_draw_buf_px = 240 * LV_DRAW_BUF_LINES; // actual buffer size in px; shrinks if the full alloc fails at boot
#if CAP_LARGE_SCREEN
// UI resolution scaling (Tanmatsu, no touchscreen). LVGL renders at s_lv_pw x s_lv_ph (PHYSICAL
@@ -3230,7 +3235,16 @@ static void lvglFlush(lv_disp_drv_t* disp_drv, const lv_area_t* area, lv_color_t
return;
}
#endif
#if defined(HELTEC_LORA_V4_R8)
// Async DMA band flush: returns as soon as the swapped copy is handed to the SPI DMA, so
// LVGL renders the next band while this one drains; the last band of the refresh closes
// the frame transaction. The screenshot/web-mirror copies below read color_p, which the
// driver has already copied out — unaffected. (LGFXDisplay::flushBandRGB565)
display.flushBandRGB565(area->x1, area->y1, w, h, reinterpret_cast<uint16_t*>(color_p),
lv_disp_flush_is_last(disp_drv));
#else
display.writePixelsRGB565(area->x1, area->y1, w, h, reinterpret_cast<uint16_t*>(color_p));
#endif
if (g_shot_buf) { // mirror this area into the screenshot buffer
for (int32_t row = 0; row < h; ++row) {
const int32_t dy = area->y1 + row;
@@ -3821,10 +3835,10 @@ static void navRefocusFirstVisible(lv_obj_t* p) {
}
// Small key hints over each menubar icon — shown only while keyboard nav is on.
static void navMenubarKeysSync() {
#if defined(HAS_TANMATSU) || defined(TLORA_PAGER)
#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
// Tanmatsu menubar uses the coloured F-key shapes, not letter hotkeys. The
// pager prints each fixed mnemonic beside its icon directly in the tab label,
// so neither target needs this optional overlay. A plain `return` isn't enough
// so neither target needs this optional overlay. The M9 has no tab bar at all. A plain `return` isn't enough
// since the body below still needs s_kbd_nav to exist at compile time; exclude it.
#else
if (!g_lv.tabview) return;
@@ -10639,7 +10653,8 @@ static void buildRadioSettings() {
#if defined(HELTEC_LORA_V4_TFT)
// Heltec V4.3 only: the external FEM's high-gain receive amplifier (~17 dB). Bypassed by
// default; a big win in quiet/remote sites, but can desensitize in noisy areas. This is
// default on the plain V4 (ON by default on the V4-R8 since prefs v49); a big win in
// quiet/remote sites, but can desensitize in noisy areas. This is
// SEPARATE from the SX1262's tiny internal "boosted gain". Hidden on V4.2 (no switchable LNA).
if (board.femLnaControllable()) {
int rh = settingsRowLabel(body, y, 4, TR("High-gain receiver (FEM LNA)"), COLOR_TEXT, &g_font_12, 56);
@@ -11434,6 +11449,7 @@ static void sdRestoreRun() {
g_lv.task->persistHistoryNow();
discoveredFlushNow();
the_mesh.flushContactsIfDirty();
the_mesh.persistSyncHistoryNow();
if (!touchPrefsFlush()) {
g_sd_migration_blocked = true;
g_lv.task->showAlert(TR("Copy blocked: internal data is busy"), 2600);
@@ -11449,7 +11465,6 @@ static void sdRestoreRun() {
#if defined(TLORA_PAGER)
if (!meshcomodSdProfileMatchesInternal()) {
the_mesh.persistSyncHistoryNow();
g_lv.task->showAlert(TR("Copy blocked: SD card holds a different or unreadable profile"), 3600);
return;
}
@@ -13387,6 +13402,22 @@ static void buildDeviceSettings(int sec) {
mk_info(TR("Model:"), "Heltec LoRa32 V4 TFT (touch)");
#else
mk_info(TR("Model:"), "Heltec LoRa32 V4");
#endif
#if defined(HELTEC_LORA_V4_R8)
// Perf-pass readout (2026-08-20). This board has no reachable serial console, so the
// four runtime facts that pass needs verified live here: CPU clock, display bus clock +
// flush mode (DMA = async band flush active), micro-SD operating clock, FEM LNA state.
{
char sd[16];
if (g_sd_operating_hz >= 1000000) snprintf(sd, sizeof sd, "%lu MHz", (unsigned long)(g_sd_operating_hz / 1000000));
else if (g_sd_operating_hz > 0) snprintf(sd, sizeof sd, "%lu kHz", (unsigned long)(g_sd_operating_hz / 1000));
else snprintf(sd, sizeof sd, "none");
snprintf(buf, sizeof(buf), "CPU %u · TFT %u MHz %s · SD %s · LNA %s",
(unsigned)getCpuFrequencyMhz(), (unsigned)(LGFX_SPI_WRITE_HZ / 1000000),
display.asyncFlushActive() ? "DMA" : "sync", sd,
board.femLnaControllable() ? (touchPrefsGetFemLna() ? "on" : "off") : "n/a");
mk_info(TR("Perf:"), buf);
}
#endif
// Public key prefix (first 8 bytes = 16 hex)
{
@@ -14912,6 +14943,18 @@ static void buildMqttSettings() {
#endif
}
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
// Wi-Fi modem power save: persist + apply live. Only touches the driver once associated —
// WiFi.setSleep() on an unassociated STA naps the radio through scan dwells (see main.cpp).
// esp_wifi_set_ps is a plain setter (no disconnect/begin), so it is safe from the LVGL ctx.
static void wifiPowerSaveToggleCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return;
const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
wifiConfigSetPowerSave(on);
if (WiFi.status() == WL_CONNECTED) WiFi.setSleep(on);
}
#endif
static void buildWifiSettings() {
lv_obj_t* body = createSettingsModal("", SettingsModalKind::Wifi); // no group header — the top bar already says "Wi-Fi"
int y = 0;
@@ -14932,6 +14975,18 @@ static void buildWifiSettings() {
lv_label_set_text(g_set_modal.wifi_sta_status_l, TR("Loading..."));
y += SC(30);
// Modem power save (DTIM sleep) — see wifiPowerSaveToggleCb. Default ON; OFF on the V4-R8.
{
int rh = settingsRowLabel(body, y, 4, TR("Power save (modem sleep)"), COLOR_TEXT, &g_font_12, 56);
lv_obj_t* sw = lv_switch_create(body);
lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y);
if (wifiConfigGetPowerSave()) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, wifiPowerSaveToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += LV_MAX(34, rh + 10);
y += settingsRowLabel(body, y, 0, TR("Off: snappier app/TCP link, steadier on a weak signal. On: less power, kinder to Bluetooth."),
COLOR_SUB, &g_font_12, 0) + 2;
}
// Make sure the network we're currently using shows up as saved, and on first
// run import any legacy 3-slot profiles into the new known-networks store.
{
@@ -19868,8 +19923,13 @@ static bool fmSdTryMount() {
SD.end();
delay(120);
mounted = SD.begin(PIN_SD_CS, *spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE;
} else {
mounted_hz = 4000000;
}
}
// Operating-clock raise with read-verify (SD_SPI_FAST_HZ boards only; no-op elsewhere).
if (mounted) { mounted_hz = sdTryFastClock(PIN_SD_CS, *spi, mounted_hz, "SD"); mounted = mounted_hz != 0; }
if (mounted) g_sd_operating_hz = mounted_hz;
}
#endif
if (mounted) {
@@ -23489,6 +23549,12 @@ static void openSpectrumPage() {
lv_obj_set_style_bg_opa(s_spec_root, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(s_spec_root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(s_spec_root, spectrumDismissCb, LV_EVENT_CLICKED, nullptr);
// Keyboard/d-pad nav (M9, T-Deck, Tanmatsu): nothing on this page is actionable — the
// chart and the waterfall's scale box are plain containers, so the nav collector was
// highlighting them for no reason. Skip the whole subtree (same as the lock screen):
// the focus group stays empty and the only way out is Back (hardware key / bar chevron /
// Esc — all routed through the page ladder, none of which needs focus).
lv_obj_add_flag(s_spec_root, NAV_SKIP_FLAG);
// Settings-subpage chrome: the GLOBAL status bar goes tall and shows "‹ Spectrum"
// (tap the bar = Back). Content insets below the bar's lower row — no second header.
@@ -27765,11 +27831,11 @@ static bool s_map_show_links = true;
static lv_obj_t* s_map_link_objs[k_map_links_max] = {};
static lv_point_t s_map_link_pts[k_map_links_max][2];
// Per-element visibility of the map's on-screen text/markers (persisted; all
// default shown). Coords = bottom-left read-out, TileXYZ = the zoom + tile path
// line, Contacts = the contact markers.
// Per-element visibility of the map's on-screen text/markers (persisted; coords +
// contacts default shown, the tile line default hidden since prefs v50). Coords =
// bottom-left read-out, TileXYZ = the zoom + tile path line, Contacts = the contact markers.
static bool s_map_show_coords = true;
static bool s_map_show_tilexyz = true;
static bool s_map_show_tilexyz = false;
static bool s_map_show_contacts = true;
static bool s_map_tile_debug = false; // developer: tile-pipeline diagnostic overlay on the zoom line (off by default)
static bool s_map_direct_only = false; // when true, only 0-hop (directly-heard) contacts appear
@@ -38413,6 +38479,7 @@ static void powerOffCb(lv_event_t* e) {
g_lv.task->persistHistoryNow(); // flush chat before we go down
discoveredFlushNow(); // and the Discovered ring
the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring (RAM is lost in deep sleep)
touchPrefsFlush(); // and all queued A/B preference snapshots
#if defined(HELTEC_LORA_V4_R8)
g_lv.task->showAlert(TR("Powering off\xE2\x80\xA6 press BOOT to wake"), 1500);
@@ -38479,7 +38546,6 @@ static void rebootToDownloadMode() {
#endif
static void powerDownloadCb(lv_event_t* e) {
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring (RAM is lost in deep sleep)
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
closePowerMenu();
#if defined(ESP32)
@@ -38488,6 +38554,7 @@ static void powerDownloadCb(lv_event_t* e) {
if (g_lv.task) {
g_lv.task->persistHistoryNow(); // flush chat before we go down
discoveredFlushNow(); // and the Discovered ring
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring
touchPrefsFlush(); // and all queued A/B preference snapshots
g_lv.task->showAlert(TR("Download mode\xE2\x80\xA6 reflash over USB"), 1500);
}
@@ -38554,7 +38621,6 @@ static void openPowerMenu() {
}
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* l = lv_label_create(b);
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring
lv_label_set_text(l, TR(txt));
lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
@@ -44417,10 +44483,14 @@ static void buildUiTree() {
lv_obj_t* tab_btns = lv_tabview_get_tab_btns(g_lv.tabview);
#if defined(HAS_THINKNODE_M9)
// TABBAR_H is 0 on this board (see its definition) — hide the zero-height
// btnmatrix outright so it can never paint, hit-test, or take focus.
// No tab bar on this board (TABBAR_H == 0, see its definition): hide the zero-height
// btnmatrix outright so it can never paint, hit-test, or take focus — and build NONE of
// the bar chrome below (surface styling, Home re-tap / swipe-up gestures, key hints, the
// tab font, the accent "glow" indicator). The M9 switches screens with its dedicated
// HOME/MESSAGE/MAP keys and the app drawer; a bottom highlight over content was the only
// visible leftover of the bar once the icons went.
lv_obj_add_flag(tab_btns, LV_OBJ_FLAG_HIDDEN);
#endif
#else
// Tab bar bar itself sits on pure BG, not the panel — keeps the bottom
// strip indistinguishable from the rest of the screen except for the
// active-tab highlight.
@@ -44453,6 +44523,7 @@ static void buildUiTree() {
#if CAP_TRACKBALL
lv_obj_add_event_cb(tab_btns, navMenubarSizeCb, LV_EVENT_SIZE_CHANGED, nullptr); // keep the keyboard-nav key hints positioned
#endif
#endif // !HAS_THINKNODE_M9 — tab-bar chrome
// Tab labels: icons-only on touch targets; the 480px-wide Pager prefixes each
// icon with its physical-keyboard mnemonic so the shortcuts are discoverable.
@@ -44491,7 +44562,9 @@ static void buildUiTree() {
lv_obj_t* tab_settings = lv_tabview_add_tab(g_lv.tabview, LV_SYMBOL_SETTINGS);
#endif
// Slightly larger font for icons so they're easy to tap.
#if CAP_UI_SIZE
#if defined(HAS_THINKNODE_M9)
// no tab bar on the M9 (see above) — nothing to size
#elif CAP_UI_SIZE
lv_obj_set_style_text_font(tab_btns, &g_font_tab, LV_PART_MAIN);
#else
lv_obj_set_style_text_font(tab_btns, &g_font_16, LV_PART_MAIN);
@@ -44566,11 +44639,14 @@ static void buildUiTree() {
#endif
lv_obj_add_flag(s_chat_unread_badge, LV_OBJ_FLAG_HIDDEN);
#if !defined(HAS_THINKNODE_M9)
// Thin rounded accent "glow" bar that marks the active tab. A child of the
// screen (like s_update_badge) created BEFORE the chat overlays so those cover
// it, and above the tabview so it shows over the bottom bar. A soft accent
// shadow gives it the glow; updateTabIndicator() slides it under the active
// tab and hides it on the map.
// tab and hides it on the map. NOT built on the M9 (no tab bar): it sat on the
// screen, not in the bar, so hiding the bar left it glowing over the content's
// bottom edge — the "lingering tab highlight". updateTabIndicator() null-guards.
s_tab_indicator = lv_obj_create(lv_scr_act());
lv_obj_remove_style_all(s_tab_indicator);
lv_obj_clear_flag(s_tab_indicator, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE);
@@ -44583,6 +44659,7 @@ static void buildUiTree() {
lv_obj_set_style_shadow_opa(s_tab_indicator, LV_OPA_40, LV_PART_MAIN);
lv_obj_set_style_shadow_spread(s_tab_indicator, 0, LV_PART_MAIN);
updateTabIndicator(); // place it under the initial active tab
#endif // !HAS_THINKNODE_M9
// Create full-screen detail overlays (hidden until a thread is tapped)
makeChatDetail(g_lv.dm);
@@ -48502,7 +48579,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// sluggishness. 160 MHz is the known-good 2x bump; 240 MHz showed RGB565
// noise on the map's SJPG decode, so 160 is the ceiling. The T-Deck sets no
// ESP32_CPU_FREQ so it already boots at the 240 MHz default — bumping only
// the V4 here avoids dragging the T-Deck *down* to 160.
// the V4 here avoids dragging the T-Deck *down* to 160. The V4-R8 env now
// sets ESP32_CPU_FREQ=240 itself (whole of setup() at full clock), so this
// is a no-op there.
#if !defined(HAS_TDECK_GT911)
setCpuFrequencyMhz(240); // S3 max; watch the map tiles for SJPG decode noise (drop to 160 if it shows)
#endif
@@ -49308,8 +49387,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// mesh so it survives reboot. OFF by default — no effect unless the user enabled it.
the_mesh.setScopeDirectFloods(touchPrefsGetScopeDirect());
#if defined(HELTEC_LORA_V4_TFT)
// Heltec V4.3 high-gain FEM LNA: apply the saved state at boot (default OFF / bypassed,
// matching the hardware). No-op on a V4.2 board (femLnaControllable() == false).
// Heltec V4.3 high-gain FEM LNA: apply the saved state at boot (default OFF / bypassed on
// the plain V4; ON on the V4-R8 since prefs v49). No-op on a V4.2 board (femLnaControllable() == false).
if (board.femLnaControllable()) board.setFemLnaEnable(touchPrefsGetFemLna());
#endif
#if defined(HAS_TDISPLAY_P4)
@@ -50488,6 +50567,7 @@ void UITask::rebootDevice() {
}
discoveredFlushNow(); // persist the Discovered ring before we go down
the_mesh.flushContactsIfDirty(); // and any coalesced contacts refresh (card-less devices)
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring
touchPrefsFlush(); // finish queued A/B snapshots before reset
if (_board) _board->reboot();
}
@@ -50567,7 +50647,6 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
if (touchPrefsGetIgnoreTinyMsgs()) {
const char* b = body ? body : "";
while (*b == ' ' || *b == '\t' || *b == '\r' || *b == '\n') b++; // whitespace is not content
the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring
size_t blen = strlen(b);
while (blen > 0 && (b[blen-1] == ' ' || b[blen-1] == '\t' ||
b[blen-1] == '\r' || b[blen-1] == '\n')) blen--;
@@ -50931,8 +51010,13 @@ static void sdHealthTick() {
const bool remounted = begin_ok && SD.cardType() != CARD_NONE;
#else
SD.end();
const bool remounted = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) &&
SD.cardType() != CARD_NONE;
bool remounted = SD.begin(PIN_SD_CS, *spi, 4000000, "/sd", 6) &&
SD.cardType() != CARD_NONE;
if (remounted) {
const uint32_t hz = sdTryFastClock(PIN_SD_CS, *spi, 4000000, "SD"); // no-op unless SD_SPI_FAST_HZ
remounted = hz != 0;
if (remounted) g_sd_operating_hz = hz;
}
#endif
if (remounted) {
s_sd_mounted = true;
+74 -8
View File
@@ -3,14 +3,24 @@
#include "LGFXDisplay.h"
#include <Arduino.h>
#include <esp_heap_caps.h>
#ifndef LGFX_INVERT_COLOR
#define LGFX_INVERT_COLOR true // default: black bg (ST7789 INVON)
#endif
// Largest LVGL band this driver ever sees: the R8's draw buffer is 240 x LV_DRAW_BUF_LINES
// (24) px = 5760 px, in either orientation (LVGL re-splits to the buffer size). Rounded up.
#ifndef LGFX_SWAP_BUF_PX
#define LGFX_SWAP_BUF_PX 6144
#endif
LGFXDisplay::LGFXDisplay(RefCountedDigitalPin* peripher_power)
: DisplayDriver(240, 320),
_periph_power(peripher_power)
_periph_power(peripher_power),
_swap_buf(nullptr),
_swap_px(0),
_swap_alloc_failed(false),
_frame_open(false)
{
_isOn = false;
_color = 0xFFFF;
@@ -22,11 +32,13 @@ LGFXDisplay::LGFXDisplay(RefCountedDigitalPin* peripher_power)
auto cfg = _bus.config();
cfg.spi_host = SPI2_HOST;
cfg.spi_mode = 0;
cfg.freq_write = 40000000; // 40 MHz — the old 20 MHz made a full-screen flush
// ~61 ms of pure bus time (~4x the plain V4's 80 MHz
// driver); ST7789 typically takes 62.5-80 MHz, so 40
// is still the conservative choice. If hardware shows
// tearing/garbled bands, fall back to 26.6 MHz.
// 80 MHz (perf pass 2026-08-20) — parity with the plain V4's TFT_eSPI driver on the
// same ST7789 panel family. History: 20 MHz made a full-screen flush ~61 ms of pure
// bus time, 40 MHz ~31 ms, 80 MHz ~15 ms. The S3's SPI clock is 80 MHz / n, so the
// only rungs are 80 / 40 / 26.7 / 20; build with -D LGFX_SPI_WRITE_HZ=40000000 to
// step back down if a unit shows tearing or garbled bands (the micro-SD shares
// SCLK/MOSI, so the trace load is higher than the plain V4's).
cfg.freq_write = LGFX_SPI_WRITE_HZ;
cfg.freq_read = 16000000;
cfg.spi_3wire = false;
cfg.use_lock = true;
@@ -107,13 +119,14 @@ void LGFXDisplay::turnOn() {
}
void LGFXDisplay::turnOff() {
if (!_isOn) return;
finishFrame();
pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
digitalWrite(PIN_TFT_LEDA_CTL, LOW);
_isOn = false;
if (_periph_power) _periph_power->release();
}
void LGFXDisplay::clear() { _lcd.fillScreen(0x0000); }
void LGFXDisplay::startFrame(ColorVal) { _lcd.fillScreen(0x0000); }
void LGFXDisplay::clear() { finishFrame(); _lcd.fillScreen(0x0000); }
void LGFXDisplay::startFrame(ColorVal) { finishFrame(); _lcd.fillScreen(0x0000); }
void LGFXDisplay::setTextSize(int sz) { _lcd.setTextSize(sz); }
void LGFXDisplay::setColor(ColorVal c) {
@@ -136,9 +149,61 @@ void LGFXDisplay::writePixelsRGB565(int x, int y, int w, int h, const uint16_t*
_lcd.endWrite();
}
// Async LVGL flush (perf pass 2026-08-20).
//
// LVGL renders little-endian RGB565 into ONE draw buffer; the ST7789 wants big-endian.
// The sync path above hands that buffer to LovyanGFX with setSwapBytes(true), which is
// LGFX's *convert* path: a per-pixel byte swap into 32..256 px chunks, each chunk its own
// DMA kick, and the call only returns once the whole band is on the wire — so LVGL's
// render of band N+1 never overlapped the transfer of band N (13 bands per full frame).
//
// Here the swap is one 16-bit rotate per pixel into our own internal DMA buffer, the band
// goes out as ONE no-convert DMA (swap=false -> src depth == panel depth -> no_convert ->
// Bus_SPI::writeBytes DMA), and we return at once: LVGL renders the next band while the
// SPI drains this one. Ordering is LGFX's: the next writeBytes waits on SPI_USR before it
// starts, and we waitDMA() before overwriting the buffer. One startWrite()/endWrite()
// transaction spans the frame and is closed on the last band, so the micro-SD (bus_shared)
// gets the bus back between frames exactly as before — just with ~13 fewer transaction
// setups per frame. Total bus time is unchanged; what changes is that the CPU is free
// during it.
void LGFXDisplay::flushBandRGB565(int x, int y, int w, int h, const uint16_t* pixels, bool last) {
if (!_isOn || !pixels || w <= 0 || h <= 0) { if (last) finishFrame(); return; }
const size_t px = (size_t)w * (size_t)h;
if (!_swap_buf && !_swap_alloc_failed) {
_swap_px = LGFX_SWAP_BUF_PX;
_swap_buf = (uint16_t*)heap_caps_malloc(_swap_px * sizeof(uint16_t),
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!_swap_buf) {
_swap_alloc_failed = true; // internal DRAM exhausted: stay on the sync path for good
Serial.println("[TFT] async flush: no internal DMA RAM for the swap buffer; sync flush");
}
}
if (!_swap_buf || px > _swap_px) { // fallback: synchronous convert path
writePixelsRGB565(x, y, w, h, pixels);
if (last) finishFrame();
return;
}
if (!_frame_open) { _lcd.startWrite(); _frame_open = true; }
_lcd.waitDMA(); // the previous band may still be reading _swap_buf
const uint16_t* s = pixels;
uint16_t* d = _swap_buf;
for (size_t i = 0; i < px; ++i) d[i] = __builtin_bswap16(s[i]);
_lcd.setAddrWindow(x, y, w, h);
_lcd.writePixelsDMA(_swap_buf, (int32_t)px, /*swap=*/false); // already panel byte order
if (last) finishFrame();
}
void LGFXDisplay::finishFrame() {
if (!_frame_open) return;
_lcd.waitDMA();
_lcd.endWrite();
_frame_open = false;
}
// UI contract (see UITask applyRotation): called with 1 for ROT_90, 3 for ROT_270; portrait
// stays as-inited (rotation 0). Honor the argument — the old hardcoded 1 ignored it.
void LGFXDisplay::setDisplayRotation(uint8_t r) {
finishFrame();
_lcd.setRotation(r & 3);
setLogicalSize(_lcd.width(), _lcd.height());
}
@@ -152,6 +217,7 @@ void LGFXDisplay::panelSleep(bool sleep) {
// setSleep writes the bare command with no delay: t_SLPIN wants 5 ms of bus
// quiet after SLPIN, and >=5 ms must pass after SLPOUT before the next
// command (the wake path fires backlight + LVGL flushes immediately after).
finishFrame();
if (sleep) { _lcd.sleep(); delay(5); } // SLPIN
else { _lcd.wakeup(); delay(6); } // SLPOUT
}
+31 -1
View File
@@ -16,6 +16,14 @@
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
// Display SPI write clock. 80 MHz (perf pass 2026-08-20) = parity with the plain V4's
// TFT_eSPI driver; the S3's rungs are 80 / 40 / 26.7 / 20 MHz. Override with
// -D LGFX_SPI_WRITE_HZ=40000000 if a unit shows tearing / garbled bands. Exposed here
// (not just in the .cpp) so Settings -> About can report it.
#ifndef LGFX_SPI_WRITE_HZ
#define LGFX_SPI_WRITE_HZ 80000000
#endif
class LGFXDisplay : public DisplayDriver {
private:
lgfx::Panel_ST7789 _panel;
@@ -27,6 +35,14 @@ private:
uint16_t _color;
RefCountedDigitalPin* _periph_power;
// Async LVGL flush state (see flushBandRGB565): one internal DMA-capable band buffer
// holding the byte-swapped copy of the LVGL band currently on the wire, and whether a
// frame-spanning startWrite() transaction is open on the (micro-SD-shared) bus.
uint16_t* _swap_buf;
size_t _swap_px;
bool _swap_alloc_failed;
bool _frame_open;
public:
LGFXDisplay(RefCountedDigitalPin* peripher_power = nullptr);
bool begin();
@@ -47,8 +63,22 @@ public:
uint16_t getTextWidth(const char* str) override;
void endFrame() override;
// ---- LVGL flush entry point ----
// ---- LVGL flush entry points ----
// Synchronous: byte-swap + write, returns once the band is on the wire (LovyanGFX's
// convert path). Kept for non-LVGL callers and as the fallback of the async path.
void writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels);
// Asynchronous (lvglFlush on the R8): swaps the band into an internal DMA buffer, kicks a
// single DMA and returns immediately so LVGL renders band N+1 while band N drains. `last`
// = lv_disp_flush_is_last(): waits for the DMA and closes the frame transaction so the
// shared micro-SD gets the bus back between frames. Falls back to the sync path if the
// DMA buffer could not be allocated.
void flushBandRGB565(int x, int y, int w, int h, const uint16_t* pixels, bool last);
// Wait for any in-flight band DMA and end the frame transaction (no-op if none is open).
// Every non-LVGL bus user below (sleep, rotation, clear) calls this first.
void finishFrame();
// True once the async path has its internal DMA buffer (i.e. LVGL flushes are async);
// false before the first flush or if internal DRAM was exhausted (sync fallback).
bool asyncFlushActive() const { return _swap_buf != nullptr; }
// ---- Hardware panel rotation ----
void setDisplayRotation(uint8_t r);
+50
View File
@@ -141,3 +141,53 @@ on the physical device.
- Refuted during verification: the About "Model:" mislabel (code is inside
`#if 0`); slot-pool/cull, drain-order, tab-bar-geometry concerns (M9-pass
parity checks) — all verified non-issues on the R8.
---
# Perf pass (2026-08-20)
Question asked: "are we achieving full performance out of the R8 build?" Audit of
CPU / memory / flash / display / SD / Wi-Fi / RF paths. Compute side was already at
spec (240 MHz runtime clock, octal PSRAM @ 80 MHz, QIO flash @ 80 MHz, LVGL heap in
PSRAM, draw buffer in internal DMA RAM, 16 ms refresh). Six gaps found and fixed,
all flashed to the user's unit the same day:
1. **FEM LNA defaulted OFF.** The R8 is a V4.3.1-generation board (KCT8103L FEM with
the software-switchable ~17 dB RX LNA); `fem_lna` defaulted to 0 = bypassed.
Prefs schema v49: default ON on `HELTEC_LORA_V4_R8`, one-time flip of existing
installs (no new field). Toggle stays in Radio & Mesh. **[HW]** confirm the boot
line `[R8] FEM type=1` / About "LNA on"; expect better RX in quiet sites, possibly
worse in RF-noisy ones (then turn it off).
2. **Display bus 40 → 80 MHz** (`LGFX_SPI_WRITE_HZ`, LGFXDisplay.h): parity with
the plain V4's TFT_eSPI driver. Full-frame bus time ~31 → ~15 ms. **[HW]** watch
for tearing / garbled bands; `-D LGFX_SPI_WRITE_HZ=40000000` steps back.
3. **Async DMA band flush** (`LGFXDisplay::flushBandRGB565`, used by `lvglFlush` on
the R8 only). Before: LVGL's LE pixels went through LovyanGFX's convert path
(per-pixel swap into 32..256 px chunks, each its own DMA kick) and the flush
returned only when the band was on the wire — render and transfer never
overlapped. Now: one 16-bit rotate per pixel into an internal DMA buffer (12 KB),
ONE no-convert DMA per band, `lv_disp_flush_ready` immediately; the last band
(`lv_disp_flush_is_last`) waits and closes the frame transaction so the shared
micro-SD gets the bus between frames exactly as before. Sync fallback if the
buffer can't be allocated. `finishFrame()` guards panel sleep / rotation / clear.
4. **Wi-Fi modem power save is now a pref** (`wifi_ps` in the NVS Wi-Fi store;
toggle in Wi-Fi settings, applies live once associated). Default ON everywhere
(unchanged behaviour), OFF on the R8 (USB-powered kit; lower-latency TCP/app
link and steadier association on this unit's weak 2.4 GHz path).
5. **micro-SD operating clock 4 → 20 MHz** (`SD_SPI_FAST_HZ`, include/SdFastClock.h):
after the proven 4 MHz mount, re-begin at 20 MHz and READ-VERIFY (a probe file's
first 512 B captured at 4 MHz and byte-compared after the raise; SPI-mode SD has
no data CRC so a bare mount success would not catch a marginal clock). Falls
back to 4 MHz. Wired at all three mount sites (boot adoption in main.cpp, the
UITask mount ladder, the reinsert remount). No-op on boards without the flag.
6. **Boot at 240 MHz**: the R8 env now sets `ESP32_CPU_FREQ=240`; previously all of
setup() (radio init, SD ladder, store load, mesh begin) ran at the base env's
80 MHz until UITask bumped it. Screen-off DFS to 80 MHz unchanged.
Verification aid: Settings → About on the R8 gained a `Perf:` row —
`CPU 240 · TFT 80 MHz DMA · SD 20 MHz · LNA on` is the all-green reading.
("sync" = DMA buffer alloc failed; "SD 4 MHz" = the 20 MHz verify failed and it
fell back; both are safe degradations, not faults.)
Deliberately NOT done: `-O2` (image is 3.70 MB in a 3.875 MB OTA slot), bigger data
cache (baked into Arduino's prebuilt IDF libs), radio BW/SF/CR (network parameters).