feat(l2): Arduino-ESP32 3.3.12 / IDF 5.5 env, NimBLE and new I2S driver ports, large UI buffers in PSRAM

- New env Wio_Tracker_L2_companion_solo_lvgl_v3 on pioarduino 55.03.312-1;
  the 2.0.17 env stays until the new one passes the hardware checklist
- SerialBLEInterface: NimBLE variant (PIN pairing through encrypted,
  authenticated characteristic properties; setAccessPermissions is a no-op)
- buzzer: i2s_std driver on IDF 5 (legacy driver kept for 2.0.17)
- ESPNOWRadio: IDF 5 callback signatures
- ui-lvgl: psramBuf() for trail drawing, tile cache, map marks, list rows,
  keyboard maps and message metadata; internal heap back to 2.0.17 level

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 09:14:58 +02:00
co-authored by Claude Opus 5.5
parent ba4d01aa77
commit 331a4e64c4
12 changed files with 221 additions and 55 deletions
@@ -21,7 +21,8 @@ static lv_obj_t* s_lock_unread = nullptr;
static lv_obj_t* s_lock_slider = nullptr;
static int s_fav_slot = -1; // slot the hold / pick popup is about
static uint8_t s_pick_keys[64][PUB_KEY_SIZE];
static const int PICK_MAX = 64;
static uint8_t (*s_pick_keys)[PUB_KEY_SIZE] = psramBuf<uint8_t[PUB_KEY_SIZE]>(PICK_MAX);
enum : int { PICK_CONTACT = 100 }; // pick codes: channel index, or PICK_CONTACT + row
// "Pin" from an options popup: what gets pinned.
@@ -313,7 +314,7 @@ void UITask::favPickPopup(int slot) {
}
sectionTitle(list, "CONTACTS AND ROOMS");
int rows = 0, total = the_mesh.getNumContacts();
const int MAX_ROWS = (int)(sizeof(s_pick_keys) / sizeof(s_pick_keys[0]));
const int MAX_ROWS = PICK_MAX;
for (int pass = 0; pass < 2; pass++) {
for (int i = 0; i < total && rows < MAX_ROWS; i++) {
ContactInfo c;
+2 -2
View File
@@ -71,8 +71,8 @@ static const char* const* const ROWS[L_COUNT] = { LAT, LAT_UP, CYR, CYR_UP, GRK,
// Full lv_keyboard maps: the rows above + a bottom row assembled in
// buildMaps() (globe only when a second script is enabled).
static const int MAX_KEYS = 40;
static const char* s_maps[L_COUNT][MAX_KEYS + 8];
static lv_buttonmatrix_ctrl_t s_ctrl[L_COUNT][MAX_KEYS];
static const char* (*s_maps)[MAX_KEYS + 8] = psramBuf<const char*[MAX_KEYS + 8]>(L_COUNT); // in PSRAM
static lv_buttonmatrix_ctrl_t (*s_ctrl)[MAX_KEYS] = psramBuf<lv_buttonmatrix_ctrl_t[MAX_KEYS]>(L_COUNT);
static uint8_t s_layout = L_LAT;
static uint8_t s_script = 0; // current script (NodePrefs KB_ALPHABET_*)
static NodePrefs* s_prefs = nullptr;
+2 -2
View File
@@ -23,7 +23,7 @@ namespace mapview {
static RasterTileProvider s_raster("/sdcard/maps");
static TileProvider* s_provider = &s_raster; // the one place to swap in a vector renderer
static TileCache s_cache;
static TileCache& s_cache = *new (psramBuf<TileCache>(1)) TileCache(); // decoded tiles, in PSRAM
static TileDownloader s_dl("/sdcard/maps");
static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate
@@ -45,7 +45,7 @@ static double latToTileY(double lat, int z) {
enum : uint8_t { MK_NODE, MK_WAYPOINT, MK_LIVE };
struct Mark { int32_t lat_e6, lon_e6; int idx; uint8_t kind; lv_obj_t* obj; lv_obj_t* dot; };
static const int MAX_MARKS = 48; // >= MAX_NEARBY, and >= waypoints + live shares
static Mark s_marks[MAX_MARKS];
static Mark* s_marks = psramBuf<Mark>(MAX_MARKS);
static int s_mark_count = 0;
static int s_drag = 0; // px moved in the current press: a drag isn't a tap
static bool s_available = false; // provider has data; checked when the map opens, not per frame
+9 -2
View File
@@ -25,9 +25,10 @@ static const int TRAIL_SEGS = 8;
// Stored as float offsets from the first point (s_ox/s_oy): a float holds a
// small offset exactly enough, but not a whole 0..1 coordinate at street zoom
// (2^18 * 256 px across -- a float is off by pixels there).
static float s_nx[TrailStore::CAPACITY], s_ny[TrailStore::CAPACITY];
static float* s_nx = nullptr; // TrailStore::CAPACITY each, in PSRAM (first trail drawn)
static float* s_ny = nullptr;
static double s_ox = 0, s_oy = 0;
static lv_point_precise_t s_pts[TrailStore::CAPACITY];
static lv_point_precise_t* s_pts = nullptr;
static int s_seg_first[TRAIL_SEGS], s_seg_len[TRAIL_SEGS], s_segs = 0;
static lv_obj_t* s_trail[TRAIL_SEGS];
static lv_obj_t* s_target_line = nullptr;
@@ -191,6 +192,12 @@ void UITask::rebuildNavMarkers() {
TrailStore& ts = _core->trail.store();
navmap::s_segs = 0;
int n = ts.count();
if (!navmap::s_pts) {
navmap::s_nx = psramBuf<float>(TrailStore::CAPACITY);
navmap::s_ny = psramBuf<float>(TrailStore::CAPACITY);
navmap::s_pts = psramBuf<lv_point_precise_t>(TrailStore::CAPACITY);
}
if (!navmap::s_nx || !navmap::s_ny || !navmap::s_pts) n = 0;
for (int i = 0; i < n; i++) {
const TrailPoint& p = ts.at(i);
if (i == 0) { navmap::s_ox = navmap::normX(p.lon_1e6); navmap::s_oy = navmap::normY(p.lat_1e6); }
@@ -20,7 +20,8 @@ static const int TX_MAX = 22;
static lv_obj_t* s_overlay = nullptr; // frequency entry
static lv_obj_t* s_ta = nullptr;
static bool s_freq_rpt = false; // the entry is for the repeater profile (RepeaterScreen.h)
static char s_opts[1024];
static const int OPTS_LEN = 1024;
static char* s_opts = psramBuf<char>(OPTS_LEN); // dropdown options (LVGL copies them)
// Label + optional hint on the left of a settings row; the control goes on the right.
static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint) {
@@ -83,10 +84,10 @@ void UITask::buildRadio() {
NodePrefs* p = _prefs;
// Preset: "Custom" first (current params match none), then the list.
int o = snprintf(s_opts, sizeof(s_opts), "Custom");
int o = snprintf(s_opts, OPTS_LEN, "Custom");
const char* name; float f, b; uint8_t sf, cr;
for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < (int)sizeof(s_opts) - 24; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "\n%s", name);
for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < OPTS_LEN - 24; i++)
o += snprintf(s_opts + o, OPTS_LEN - o, "\n%s", name);
lv_obj_t* row = settingRow(body, "Preset", nullptr);
lv_obj_t* dd = rowDropdown(row, s_opts, radioctl::currentPreset(p) + 1, 200, onRadioDropdown, R_PRESET);
lv_dropdown_set_dir(dd, LV_DIR_BOTTOM);
@@ -105,21 +106,21 @@ void UITask::buildRadio() {
lv_obj_center(label(fb, fs, THEME_FONT_BODY, theme::TEXT));
o = 0;
for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n%d" : "%d", v);
for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n%d" : "%d", v);
rowDropdown(settingRow(body, "Spreading factor", "Higher = longer range"), s_opts,
p->sf >= 5 && p->sf <= 12 ? p->sf - 5 : 0, 90, onRadioDropdown, R_SF);
o = 0;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]);
o += snprintf(s_opts + o, OPTS_LEN - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]);
rowDropdown(settingRow(body, "Bandwidth", nullptr), s_opts, nearestBwIndex(p->bw), 130, onRadioDropdown, R_BW);
o = 0;
for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n4/%d" : "4/%d", v);
for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n4/%d" : "4/%d", v);
rowDropdown(settingRow(body, "Coding rate", nullptr), s_opts, p->cr >= 5 && p->cr <= 8 ? p->cr - 5 : 0, 90,
onRadioDropdown, R_CR);
sectionTitle(body, "TRANSMIT");
o = 0;
for (int v = TX_MIN; v <= TX_MAX; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > TX_MIN ? "\n%d dBm" : "%d dBm", v);
for (int v = TX_MIN; v <= TX_MAX; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > TX_MIN ? "\n%d dBm" : "%d dBm", v);
int tx = p->tx_power_dbm < TX_MIN ? TX_MIN : p->tx_power_dbm > TX_MAX ? TX_MAX : p->tx_power_dbm;
rowDropdown(settingRow(body, "TX power", p->tx_apc ? "Ceiling for auto power" : nullptr), s_opts, tx - TX_MIN, 110,
onRadioDropdown, R_TX);
@@ -11,7 +11,8 @@
namespace rptview {
enum : uint8_t { RP_ON, RP_NETWORK, RP_PRESET, RP_SF, RP_BW, RP_CR, RP_HOPS, RP_YIELD, RP_SNR };
static char s_opts[768];
static const int OPTS_LEN = 768;
static char* s_opts = psramBuf<char>(OPTS_LEN); // dropdown options (LVGL copies them)
static lv_obj_t* s_scopes_sub = nullptr; // "Extra scopes" row's count, updated from the popup
} // namespace rptview
@@ -77,10 +78,10 @@ void UITask::buildRepeater() {
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onRptNetwork, LV_EVENT_VALUE_CHANGED, NULL);
if (p->repeater_use_profile) {
int o = snprintf(s_opts, sizeof(s_opts), "Custom");
int o = snprintf(s_opts, OPTS_LEN, "Custom");
const char* name; float f, b; uint8_t sf, cr;
for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < (int)sizeof(s_opts) - 24; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "\n%s", name);
for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < OPTS_LEN - 24; i++)
o += snprintf(s_opts + o, OPTS_LEN - o, "\n%s", name);
lv_obj_t* dd = rowDropdown(settingRow(body, "Preset", nullptr), s_opts, rptctl::currentPreset(p) + 1, 200,
onRptDropdown, RP_PRESET);
lv_dropdown_set_dir(dd, LV_DIR_BOTTOM);
@@ -98,15 +99,15 @@ void UITask::buildRepeater() {
lv_obj_center(label(fb, fs, THEME_FONT_BODY, theme::TEXT));
o = 0;
for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n%d" : "%d", v);
for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n%d" : "%d", v);
rowDropdown(settingRow(body, "Spreading factor", nullptr), s_opts,
p->repeater_sf >= 5 && p->repeater_sf <= 12 ? p->repeater_sf - 5 : 0, 90, onRptDropdown, RP_SF);
o = 0;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]);
o += snprintf(s_opts + o, OPTS_LEN - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]);
rowDropdown(settingRow(body, "Bandwidth", nullptr), s_opts, nearestBwIndex(p->repeater_bw), 130, onRptDropdown, RP_BW);
o = 0;
for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n4/%d" : "4/%d", v);
for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n4/%d" : "4/%d", v);
rowDropdown(settingRow(body, "Coding rate", nullptr), s_opts,
p->repeater_cr >= 5 && p->repeater_cr <= 8 ? p->repeater_cr - 5 : 0, 90, onRptDropdown, RP_CR);
}
@@ -117,21 +118,21 @@ void UITask::buildRepeater() {
int o = 0;
for (int i = 0; i <= rptctl::MAX_HOPS; i++) {
rptctl::fmtHops(v, sizeof(v), (uint8_t)i);
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Max hops", "Hop limit for floods"), s_opts, p->repeat_max_hops, 90,
onRptDropdown, RP_HOPS);
o = 0;
for (int i = 0; i <= rptctl::MAX_YIELD; i++) {
rptctl::fmtYield(v, sizeof(v), (uint8_t)i);
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Yield", "Let fixed repeaters go first"), s_opts, p->repeat_delay_boost, 90,
onRptDropdown, RP_YIELD);
o = 0;
for (int i = 0; i < rptctl::snrChoiceCount(); i++) {
rptctl::fmtSnr(v, sizeof(v), rptctl::snrFromChoice(i));
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Min SNR", "Ignore weaker packets"), s_opts, rptctl::snrToChoice(p->repeat_min_snr), 100,
onRptDropdown, RP_SNR);
+31 -6
View File
@@ -5,6 +5,16 @@
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
#include <new>
// A zeroed buffer of `n` T in PSRAM when the board has it: internal RAM is what
// BLE, WiFi and TLS need. Allocated once (at startup or first use), never freed.
template <class T> static T* psramBuf(size_t n) {
#if defined(ESP32)
if (void* p = heap_caps_calloc(n, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) return (T*)p;
#endif
return (T*)calloc(n, sizeof(T));
}
#include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU)
#include "../ui-core/NearbyModel.h"
@@ -254,6 +264,19 @@ void UITask::loop() {
_core->loop();
drainCoreEvents();
#if defined(UI_HEAP_REPORT) && defined(ESP32)
// -D UI_HEAP_REPORT: internal / PSRAM heap once, 20 s after boot (the
// framework comparison in docs/development/l2-roadmap.md).
static bool heap_done = false;
if (!heap_done && millis() > 20000) {
heap_done = true;
Serial.printf("HEAP internal free %u largest %u min %u | psram free %u | idf %s\n",
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), esp_get_idf_version());
}
#endif
#ifdef PIN_BUZZER
_buzzer.loop();
if (soundctl::autoTick(_prefs, _buzzer, isClientConnected()) && !_asleep) refreshStatusBar();
@@ -853,7 +876,8 @@ static void onOpenChannel(lv_event_t* e) {
// DM rows carry a 4-byte prefix; kept in a static table the rows point into.
static uint8_t s_dm_rows[MessageHistory::DM_HIST_MAX][4];
static uint8_t s_contact_rows[64][PUB_KEY_SIZE];
static const int CONTACT_ROWS_MAX = 64;
static uint8_t (*s_contact_rows)[PUB_KEY_SIZE] = psramBuf<uint8_t[PUB_KEY_SIZE]>(CONTACT_ROWS_MAX);
static void onOpenDMRow(lv_event_t* e) {
s_ui->openDM(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]);
@@ -865,7 +889,8 @@ static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); }
static void onChanRowHold(lv_event_t* e); // ChannelScreen.h
static void onChanAdd(lv_event_t* e);
static void onChanThreadMenu(lv_event_t* e);
static uint8_t s_room_rows[16][PUB_KEY_SIZE];
static const int ROOM_ROWS_MAX = 16;
static uint8_t (*s_room_rows)[PUB_KEY_SIZE] = psramBuf<uint8_t[PUB_KEY_SIZE]>(ROOM_ROWS_MAX);
static void onDMRowHold(lv_event_t* e); // ConversationScreen.h
static void onRoomRow(lv_event_t* e);
static void onRoomRowHold(lv_event_t* e);
@@ -1007,7 +1032,7 @@ void UITask::buildChats() {
else snprintf(rt, sizeof(rt), "ROOMS");
sectionWithFilter(body, rt, room_fav_only, CF_ROOMS);
int nrooms = 0, total = the_mesh.getNumContacts();
const int MAX_ROOMS = (int)(sizeof(s_room_rows) / sizeof(s_room_rows[0]));
const int MAX_ROOMS = ROOM_ROWS_MAX;
for (int pass = fav_first ? 0 : 1; pass < 2; pass++) {
for (int i = 0; i < total && nrooms < MAX_ROOMS; i++) {
ContactInfo c;
@@ -1055,7 +1080,7 @@ void UITask::buildContacts() {
int rows = 0;
// +MAX_ANON_CONTACTS: getContactByIdx() takes the raw table index (see
// MessageHistory::contactByPrefix()).
for (int i = 0; i < total && rows < (int)(sizeof(s_contact_rows) / sizeof(s_contact_rows[0])); i++) {
for (int i = 0; i < total && rows < CONTACT_ROWS_MAX; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_CHAT) continue;
@@ -1723,12 +1748,12 @@ uint32_t UITask::threadSignature() const {
// for the Go / Save buttons under such a bubble.
struct MsgLoc { int32_t lat, lon; char label[WAYPOINT_LABEL_LEN * 2]; };
static const int THREAD_MAX_SHOWN = 30;
static MsgLoc s_msg_locs[THREAD_MAX_SHOWN];
static MsgLoc* s_msg_locs = psramBuf<MsgLoc>(THREAD_MAX_SHOWN);
static int s_msg_loc_n = 0;
// What a held bubble is about: its history ring entry, sender, position slot.
struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; };
static MsgMeta s_msg_meta[THREAD_MAX_SHOWN];
static MsgMeta* s_msg_meta = psramBuf<MsgMeta>(THREAD_MAX_SHOWN);
static int s_msg_meta_n = 0;
static int noteMsgMeta(int pos, bool channel, bool own, int loc, const char* from) {