From 844d8c335731ee8e0eb7b1168e4d90df1dd40bb6 Mon Sep 17 00:00:00 2001 From: Christopher Van Hoose Date: Thu, 20 Aug 2026 13:51:11 -0400 Subject: [PATCH] V4-R8 perf pass + M9 UI polish (prefs v49/v50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- include/SdFastClock.h | 94 ++++++++++++++++++ platformio.ini | 11 +++ src/helpers/esp32/TouchPrefsSchema.h | 2 +- src/helpers/esp32/TouchPrefsStore.cpp | 17 +++- src/helpers/esp32/WifiRuntimeStore.cpp | 20 ++++ src/helpers/esp32/WifiRuntimeStore.h | 8 ++ src/main.cpp | 15 ++- src/ui-touch/UITask.cpp | 126 ++++++++++++++++++++----- variants/heltec_v4/LGFXDisplay.cpp | 82 ++++++++++++++-- variants/heltec_v4/LGFXDisplay.h | 32 ++++++- variants/heltec_v4/R8_AUDIT.md | 50 ++++++++++ 11 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 include/SdFastClock.h diff --git a/include/SdFastClock.h b/include/SdFastClock.h new file mode 100644 index 0000000..04ff421 --- /dev/null +++ b/include/SdFastClock.h @@ -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 + +#if defined(ESP32) +#include +#include +#include + +#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 diff --git a/platformio.ini b/platformio.ini index a0497b0..f139c5c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/helpers/esp32/TouchPrefsSchema.h b/src/helpers/esp32/TouchPrefsSchema.h index 36addd2..dbd309b 100644 --- a/src/helpers/esp32/TouchPrefsSchema.h +++ b/src/helpers/esp32/TouchPrefsSchema.h @@ -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 diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 1a6d0f0..2f635d6 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -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; diff --git a/src/helpers/esp32/WifiRuntimeStore.cpp b/src/helpers/esp32/WifiRuntimeStore.cpp index 67180d4..9c32653 100644 --- a/src/helpers/esp32/WifiRuntimeStore.cpp +++ b/src/helpers/esp32/WifiRuntimeStore.cpp @@ -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; diff --git a/src/helpers/esp32/WifiRuntimeStore.h b/src/helpers/esp32/WifiRuntimeStore.h index 2cf3462..cebf6e8 100644 --- a/src/helpers/esp32/WifiRuntimeStore.h +++ b/src/helpers/esp32/WifiRuntimeStore.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 9afb299..b0fec9f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -51,6 +51,7 @@ static uint32_t _atoi(const char* sp) { #include #if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) #include + #include "SdFastClock.h" // post-mount operating-clock raise (SD_SPI_FAST_HZ boards) #include #if defined(TLORA_PAGER) #include @@ -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 diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 54e12d3..d35aaaf 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -62,6 +62,7 @@ #endif #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) #include // 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(color_p), + lv_disp_flush_is_last(disp_drv)); +#else display.writePixelsRGB565(area->x1, area->y1, w, h, reinterpret_cast(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; diff --git a/variants/heltec_v4/LGFXDisplay.cpp b/variants/heltec_v4/LGFXDisplay.cpp index c3d0410..0a352d0 100644 --- a/variants/heltec_v4/LGFXDisplay.cpp +++ b/variants/heltec_v4/LGFXDisplay.cpp @@ -3,14 +3,24 @@ #include "LGFXDisplay.h" #include +#include #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 } diff --git a/variants/heltec_v4/LGFXDisplay.h b/variants/heltec_v4/LGFXDisplay.h index 0665f66..f73df7d 100644 --- a/variants/heltec_v4/LGFXDisplay.h +++ b/variants/heltec_v4/LGFXDisplay.h @@ -16,6 +16,14 @@ #define LGFX_USE_V1 #include +// 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); diff --git a/variants/heltec_v4/R8_AUDIT.md b/variants/heltec_v4/R8_AUDIT.md index 61e7e10..455f1ac 100644 --- a/variants/heltec_v4/R8_AUDIT.md +++ b/variants/heltec_v4/R8_AUDIT.md @@ -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).