Files
wadamesh/tanmatsu/main/main.cpp
T
Kaj SchittecatandClaude Fable 5 76cd025bf5 tanmatsu: move identity + prefs to the SD card; open-probe the prefs load chain
The P4's internal FFat 'locfd' has a broken FAT metadata layer: open/
read/write work but f_stat/exists()/size() return garbage - and WHICH
garbage shifts with the build. At -Og the exists()-gated identity and
NodePrefs loads worked by luck; the -Os switch flipped the lie and the
device booted with a fresh identity and default name, and profile
changes never survived a reboot (the loads failed, not the saves).
Chat history was unaffected because it already lives on SD_MMC.

- DataStore::useSdMmcStorage(): full-store adoption of the card
  (/meshcomod root, identity store included), mirroring the T-Deck's
  useSdStorage(). The old card-root contacts3/channels2/adv_blobs from
  the secondary-FS era are renamed under /meshcomod.
- Tanmatsu boot: one-time rescue of identity + prefs off FFat by OPEN
  and READ probing (never exists()/size() on that FS), then adopt the
  card. FFat remains the no-card fallback only.
- DataStore::loadPrefs() probes candidates by opening and reading a
  byte instead of exists() - truthful on the P4, identical elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:00:54 +02:00

533 lines
28 KiB
C++

// wadamesh on the Tanmatsu (ESP32-P4) — the app entry point.
//
// Replaces the S3 boards' src/main.cpp setup()/loop(): it declares the app globals and runs the
// bring-up. badge-bsp owns the display/input/power; arduino-esp32 is the runtime; the LoRa is the
// TanmatsuLoraRadio bridge. The board/radio/display/sensors globals + radio_init() live in
// variants/tanmatsu/target.cpp (pulled via target.h).
#include <tanmatsu_compat.h> // adcAttachPin() shim — BEFORE target.h pulls ESP32Board.h
#include <Arduino.h>
#include <Mesh.h>
#include "MyMesh.h"
#include <new> // placement-new for the PSRAM-resident the_mesh
#include "esp_heap_caps.h" // heap_caps_malloc(MALLOC_CAP_SPIRAM)
#include "UITask.h"
#include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init()
#include <SPIFFS.h>
#include <FFat.h> // DataStore persists on the internal 'locfd' FAT partition
#include <SD_MMC.h> // microSD (slot 0): reliable store for contacts/channels/chat (internal FFat loses them on this P4)
#include "esp_partition.h" // enumerate partitions (AppFS apps boot fresh — verify locfd is visible)
#include <WiFi.h>
#include <helpers/esp32/MultiTransportCompanionInterface.h>
#include <helpers/esp32/WifiRuntimeStore.h>
#include <helpers/esp32/TouchPrefsStore.h>
#include "esp32-hal-hosted.h" // arduino's esp-hosted bring-up (shared by LoRa + WiFi on the C6)
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "lvgl.h"
extern "C" {
#include "bsp/device.h"
#include "bsp/input.h"
#include "bsp/display.h"
#include "bsp/tanmatsu.h" // bsp_tanmatsu_coprocessor_get_handle (C6 radio power)
#include "tanmatsu_coprocessor.h" // tanmatsu_coprocessor_radio_disable / _enable_application
#include "esp_hosted.h" // C6 radio co-processor link (WiFi + LoRa ride esp-hosted)
#include "esp_netif.h" // lwIP/tcpip bring-up (else WiFi calls assert "Invalid mbox")
#include "esp_event.h"
}
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#ifndef WS_PORT
#define WS_PORT 8765
#endif
// Boot-trace hooks the shared code defines on the S3 boards (in src/main.cpp, which we exclude).
volatile int g_boot_phase = 0;
extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; }
void halt() { while (1) { delay(1000); } }
// App globals — src/main.cpp declares these; we own them here (that file is excluded from the build).
// Persistence: an AppFS app boots FRESH (its own P4 reset) and does NOT inherit the launcher's VFS
// mounts, so we mount the internal 'locfd' FAT partition ourselves via FFat (an fs::FS, which is what
// DataStore takes — FILESYSTEM == fs::FS on ESP32). See the storage block in setup().
DataStore store(FFat, rtc_clock);
bool g_fs_ok = false; // true once FFat(locfd) is mounted; UITask's file browser checks this (extern)
bool g_sd_ok = false; // true once the microSD (SD_MMC) is mounted; contacts/channels/chat persist there (extern)
MultiTransportCompanionInterface serial_interface;
StdRNG fast_rng;
SimpleMeshTables tables;
UITask ui_task(&board, &serial_interface);
// the_mesh (~42 KB, dominated by the MAX_CONTACTS array) lives in PSRAM instead of the
// scarce internal DRAM — the P4 has 32 MB PSRAM. Placement-new into a heap_caps_malloc
// block + a reference, exactly like src/main.cpp on the S3 boards: the ctor still runs
// HERE at static-init (PSRAM is up first), so only the address moves. Internal-RAM
// fallback if PSRAM is somehow absent. MyMesh.h declares `extern MyMesh& the_mesh`.
static MyMesh& makeTheMesh() {
void* mem = heap_caps_malloc(sizeof(MyMesh), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!mem) mem = malloc(sizeof(MyMesh));
return *new (mem) MyMesh(radio_driver, fast_rng, rtc_clock, tables, store, &ui_task);
}
MyMesh& the_mesh = makeTheMesh();
// ---------------------------------------------------------------------------
// Boot splash — a tiny on-screen console so launching the app shows progress
// instead of a black screen (the other badge.team apps print boot lines before
// their UI comes up; ours stayed black during the long pre-LVGL bring-up:
// radio + mesh + the 28k-line UI build). We draw straight to the panel with an
// 8x8 font. The panel is native 480x800 PORTRAIT and LVGL software-rotates the
// UI to 800x480 landscape (ROT_270); we apply the SAME mapping so the boot text
// is upright in landscape, then the first LVGL frame paints over it.
// ---------------------------------------------------------------------------
#define BOOT_LW 800 // logical (landscape) width
#define BOOT_LH 480 // logical (landscape) height
#define BOOT_PW 480 // physical panel width (stride)
#define BOOT_PH 800 // physical panel height
#define BOOT_FG 0x07E0 // terminal green — pure G channel, immune to R/B order; only a full byte-swap limes it
// font8x8_basic (public domain, Daniel Hepper) — printable ASCII 0x20..0x7E.
// One glyph = 8 rows; bit 0 (LSB) is the leftmost pixel, row 0 the top.
static const uint8_t kBootFont[95][8] = {
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},{0x18,0x3C,0x3C,0x18,0x18,0x00,0x18,0x00},
{0x36,0x36,0x00,0x00,0x00,0x00,0x00,0x00},{0x36,0x36,0x7F,0x36,0x7F,0x36,0x36,0x00},
{0x0C,0x3E,0x03,0x1E,0x30,0x1F,0x0C,0x00},{0x00,0x63,0x33,0x18,0x0C,0x66,0x63,0x00},
{0x1C,0x36,0x1C,0x6E,0x3B,0x33,0x6E,0x00},{0x06,0x06,0x03,0x00,0x00,0x00,0x00,0x00},
{0x18,0x0C,0x06,0x06,0x06,0x0C,0x18,0x00},{0x06,0x0C,0x18,0x18,0x18,0x0C,0x06,0x00},
{0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00},{0x00,0x0C,0x0C,0x3F,0x0C,0x0C,0x00,0x00},
{0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x06},{0x00,0x00,0x00,0x3F,0x00,0x00,0x00,0x00},
{0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x00},{0x60,0x30,0x18,0x0C,0x06,0x03,0x01,0x00},
{0x3E,0x63,0x73,0x7B,0x6F,0x67,0x3E,0x00},{0x0C,0x0E,0x0C,0x0C,0x0C,0x0C,0x3F,0x00},
{0x1E,0x33,0x30,0x1C,0x06,0x33,0x3F,0x00},{0x1E,0x33,0x30,0x1C,0x30,0x33,0x1E,0x00},
{0x38,0x3C,0x36,0x33,0x7F,0x30,0x78,0x00},{0x3F,0x03,0x1F,0x30,0x30,0x33,0x1E,0x00},
{0x1C,0x06,0x03,0x1F,0x33,0x33,0x1E,0x00},{0x3F,0x33,0x30,0x18,0x0C,0x0C,0x0C,0x00},
{0x1E,0x33,0x33,0x1E,0x33,0x33,0x1E,0x00},{0x1E,0x33,0x33,0x3E,0x30,0x18,0x0E,0x00},
{0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x00},{0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x06},
{0x18,0x0C,0x06,0x03,0x06,0x0C,0x18,0x00},{0x00,0x00,0x3F,0x00,0x00,0x3F,0x00,0x00},
{0x06,0x0C,0x18,0x30,0x18,0x0C,0x06,0x00},{0x1E,0x33,0x30,0x18,0x0C,0x00,0x0C,0x00},
{0x3E,0x63,0x7B,0x7B,0x7B,0x03,0x1E,0x00},{0x0C,0x1E,0x33,0x33,0x3F,0x33,0x33,0x00},
{0x3F,0x66,0x66,0x3E,0x66,0x66,0x3F,0x00},{0x3C,0x66,0x03,0x03,0x03,0x66,0x3C,0x00},
{0x1F,0x36,0x66,0x66,0x66,0x36,0x1F,0x00},{0x7F,0x46,0x16,0x1E,0x16,0x46,0x7F,0x00},
{0x7F,0x46,0x16,0x1E,0x16,0x06,0x0F,0x00},{0x3C,0x66,0x03,0x03,0x73,0x66,0x7C,0x00},
{0x33,0x33,0x33,0x3F,0x33,0x33,0x33,0x00},{0x1E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00},
{0x78,0x30,0x30,0x30,0x33,0x33,0x1E,0x00},{0x67,0x66,0x36,0x1E,0x36,0x66,0x67,0x00},
{0x0F,0x06,0x06,0x06,0x46,0x66,0x7F,0x00},{0x63,0x77,0x7F,0x7F,0x6B,0x63,0x63,0x00},
{0x63,0x67,0x6F,0x7B,0x73,0x63,0x63,0x00},{0x1C,0x36,0x63,0x63,0x63,0x36,0x1C,0x00},
{0x3F,0x66,0x66,0x3E,0x06,0x06,0x0F,0x00},{0x1E,0x33,0x33,0x33,0x3B,0x1E,0x38,0x00},
{0x3F,0x66,0x66,0x3E,0x36,0x66,0x67,0x00},{0x1E,0x33,0x07,0x0E,0x38,0x33,0x1E,0x00},
{0x3F,0x2D,0x0C,0x0C,0x0C,0x0C,0x1E,0x00},{0x33,0x33,0x33,0x33,0x33,0x33,0x3F,0x00},
{0x33,0x33,0x33,0x33,0x33,0x1E,0x0C,0x00},{0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00},
{0x63,0x63,0x36,0x1C,0x1C,0x36,0x63,0x00},{0x33,0x33,0x33,0x1E,0x0C,0x0C,0x1E,0x00},
{0x7F,0x63,0x31,0x18,0x4C,0x66,0x7F,0x00},{0x1E,0x06,0x06,0x06,0x06,0x06,0x1E,0x00},
{0x03,0x06,0x0C,0x18,0x30,0x60,0x40,0x00},{0x1E,0x18,0x18,0x18,0x18,0x18,0x1E,0x00},
{0x08,0x1C,0x36,0x63,0x00,0x00,0x00,0x00},{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF},
{0x0C,0x0C,0x18,0x00,0x00,0x00,0x00,0x00},{0x00,0x00,0x1E,0x30,0x3E,0x33,0x6E,0x00},
{0x07,0x06,0x06,0x3E,0x66,0x66,0x3B,0x00},{0x00,0x00,0x1E,0x33,0x03,0x33,0x1E,0x00},
{0x38,0x30,0x30,0x3E,0x33,0x33,0x6E,0x00},{0x00,0x00,0x1E,0x33,0x3F,0x03,0x1E,0x00},
{0x1C,0x36,0x06,0x0F,0x06,0x06,0x0F,0x00},{0x00,0x00,0x6E,0x33,0x33,0x3E,0x30,0x1F},
{0x07,0x06,0x36,0x6E,0x66,0x66,0x67,0x00},{0x0C,0x00,0x0E,0x0C,0x0C,0x0C,0x1E,0x00},
{0x30,0x00,0x30,0x30,0x30,0x33,0x33,0x1E},{0x07,0x06,0x66,0x36,0x1E,0x36,0x67,0x00},
{0x0E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00},{0x00,0x00,0x33,0x7F,0x7F,0x6B,0x63,0x00},
{0x00,0x00,0x1F,0x33,0x33,0x33,0x33,0x00},{0x00,0x00,0x1E,0x33,0x33,0x33,0x1E,0x00},
{0x00,0x00,0x3B,0x66,0x66,0x3E,0x06,0x0F},{0x00,0x00,0x6E,0x33,0x33,0x3E,0x30,0x78},
{0x00,0x00,0x3B,0x6E,0x66,0x06,0x0F,0x00},{0x00,0x00,0x3E,0x03,0x1E,0x30,0x1F,0x00},
{0x08,0x0C,0x3E,0x0C,0x0C,0x2C,0x18,0x00},{0x00,0x00,0x33,0x33,0x33,0x33,0x6E,0x00},
{0x00,0x00,0x33,0x33,0x33,0x1E,0x0C,0x00},{0x00,0x00,0x63,0x6B,0x7F,0x7F,0x36,0x00},
{0x00,0x00,0x63,0x36,0x1C,0x36,0x63,0x00},{0x00,0x00,0x33,0x33,0x33,0x3E,0x30,0x1F},
{0x00,0x00,0x3F,0x19,0x0C,0x26,0x3F,0x00},{0x38,0x0C,0x0C,0x07,0x0C,0x0C,0x38,0x00},
{0x18,0x18,0x18,0x00,0x18,0x18,0x18,0x00},{0x07,0x0C,0x0C,0x38,0x0C,0x0C,0x07,0x00},
{0x6E,0x3B,0x00,0x00,0x00,0x00,0x00,0x00},
};
static uint16_t* s_boot_fb = nullptr; // 480x800 RGB565 portrait framebuffer (PSRAM)
static int s_boot_y = 0; // next text row, in landscape coords
static char s_boot_cur[48] = {0}; // the in-progress line, so the animator can append dots to it
static int s_boot_cur_y = 0;
static volatile bool s_boot_anim_run = false;
static SemaphoreHandle_t s_boot_lock = nullptr; // guards s_boot_fb + the panel between threads
// Map one logical (landscape) pixel into the portrait panel buffer. The on-device
// result showed the ROT_270 formula was 180° off, so use its opposite (ROT_90):
// phys_x = (PW-1) - ly, phys_y = lx.
static inline void bootPx(int lx, int ly, uint16_t c) {
if ((unsigned)lx >= BOOT_LW || (unsigned)ly >= BOOT_LH) return;
s_boot_fb[lx * BOOT_PW + (BOOT_PW - 1 - ly)] = c;
}
static void bootDrawText(int x, int y, const char* msg, uint16_t col, int scale) {
for (const char* p = msg; *p; p++) {
char ch = *p;
if (ch < 0x20 || ch > 0x7E) ch = ' ';
const uint8_t* g = kBootFont[ch - 0x20];
for (int gy = 0; gy < 8; gy++)
for (int gx = 0; gx < 8; gx++)
if (g[gy] & (1 << gx))
for (int sy = 0; sy < scale; sy++)
for (int sx = 0; sx < scale; sx++)
bootPx(x + gx * scale + sx, y + gy * scale + sy, col);
x += 8 * scale;
if (x + 8 * scale > BOOT_LW) break;
}
}
// Push the whole framebuffer to the panel in horizontal bands. A single full-frame
// (480x800) blit only showed partially on-device — the proven LVGL flush path always
// writes sub-full bands, so do the same here.
static void bootBlit() {
if (!s_boot_fb) return;
const int BAND = 40; // match the proven LVGL flush height (draw buf is 800x24 px -> <=480x40 portrait)
for (int y = 0; y < BOOT_PH; y += BAND) {
int h = (y + BAND > BOOT_PH) ? (BOOT_PH - y) : BAND;
bsp_display_blit(0, y, BOOT_PW, y + h, s_boot_fb + (size_t)y * BOOT_PW);
}
}
// Redraw the trailing "..." on the in-progress line with `ndots` dots, then blit only the few
// rows those dots occupy. Called from the animator (and to finalize a line at 3 dots).
static void bootDrawDots(int ndots) {
if (!s_boot_fb || !s_boot_cur[0]) return;
const int S = 2;
const int dotstart = 16 + (int)strlen(s_boot_cur) * 8 * S; // lx just past the text
const int zonew = 4 * 8 * S; // room for up to 4 dots
for (int lx = dotstart; lx < dotstart + zonew && lx < BOOT_LW; lx++) // clear the dot zone
for (int ly = s_boot_cur_y; ly < s_boot_cur_y + 8 * S; ly++)
bootPx(lx, ly, 0x0000);
if (ndots > 4) ndots = 4;
char d[8]; int j = 0; for (int k = 0; k < ndots; k++) d[j++] = '.'; d[j] = 0;
if (ndots > 0) bootDrawText(dotstart, s_boot_cur_y, d, BOOT_FG, S);
int r0 = dotstart - (dotstart % 40), r1 = dotstart + zonew; // lx maps to fb rows (py)
if (r1 > BOOT_PH) r1 = BOOT_PH;
for (int y = r0; y < r1; y += 40) {
int h = (y + 40 > BOOT_PH) ? (BOOT_PH - y) : 40;
bsp_display_blit(0, y, BOOT_PW, y + h, s_boot_fb + (size_t)y * BOOT_PW);
}
}
// Cycles ".", "..", "..." on the current line so a long phase (e.g. the slow C6 radio bring-up)
// shows activity instead of looking frozen. Runs on core 1 while the boot thread blocks on core 0.
static void bootAnimTask(void*) {
int phase = 0;
while (s_boot_anim_run) {
if (s_boot_lock && xSemaphoreTake(s_boot_lock, pdMS_TO_TICKS(60)) == pdTRUE) {
if (s_boot_anim_run && s_boot_fb) bootDrawDots((phase % 3) + 1);
xSemaphoreGive(s_boot_lock);
}
phase++;
vTaskDelay(pdMS_TO_TICKS(280));
}
vTaskDelete(NULL);
}
static void bootSplashBegin() {
s_boot_fb = (uint16_t*)heap_caps_malloc(BOOT_PW * BOOT_PH * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!s_boot_fb) return;
size_t hh = 0, vv = 0; bsp_display_color_format_t cf = (bsp_display_color_format_t)0;
bsp_display_endianness_t en = (bsp_display_endianness_t)0;
bsp_display_get_parameters(&hh, &vv, &cf, &en);
printf("[BOOT] panel %ux%u fmt=%d endian=%d\n", (unsigned)hh, (unsigned)vv, (int)cf, (int)en);
for (int i = 0; i < BOOT_PW * BOOT_PH; i++) s_boot_fb[i] = 0x0000; // black
bootDrawText(16, 28, "WADAMESH", BOOT_FG, 3); // caps wordmark
bootDrawText(16, 64, "============", BOOT_FG, 2); // terminal rule under it
s_boot_y = 100;
bootBlit();
s_boot_lock = xSemaphoreCreateMutex();
s_boot_anim_run = true;
xTaskCreatePinnedToCore(bootAnimTask, "bootanim", 3072, NULL, 1, NULL, 1);
}
// Start a new boot-progress line ("> MSG"), finalizing the previous one at a steady "...". The
// animator then cycles dots on this line until the next bootLog().
static void bootLog(const char* msg) {
Serial.printf("[BOOT] %s\n", msg);
printf("[BOOT] %s\n", msg); // Serial is swallowed on the IDF console
if (!s_boot_fb) return;
if (s_boot_lock) xSemaphoreTake(s_boot_lock, portMAX_DELAY);
bootDrawDots(3); // freeze the previous line at "..."
char line[48];
int j = 0; line[j++] = '>'; line[j++] = ' ';
for (const char* p = msg; *p && j < (int)sizeof(line) - 1; p++) {
char c = *p; if (c >= 'a' && c <= 'z') c -= 32; // uppercase for the boot-ROM look
line[j++] = c;
}
line[j] = 0;
const int S = 2, LH = 8 * S + 6;
strncpy(s_boot_cur, line, sizeof(s_boot_cur) - 1); s_boot_cur[sizeof(s_boot_cur) - 1] = 0;
s_boot_cur_y = s_boot_y;
bootDrawText(16, s_boot_cur_y, line, BOOT_FG, S);
s_boot_y += LH;
if (s_boot_y + LH > BOOT_LH) s_boot_y = 100;
bootBlit();
if (s_boot_lock) xSemaphoreGive(s_boot_lock);
}
static void bootSplashEnd() {
s_boot_anim_run = false;
vTaskDelay(pdMS_TO_TICKS(320)); // let the animator observe the flag + self-delete
if (s_boot_lock) xSemaphoreTake(s_boot_lock, portMAX_DELAY);
if (s_boot_fb) {
// Clean black handoff so no green text lingers; LVGL is then forced to repaint (see app setup).
for (int i = 0; i < BOOT_PW * BOOT_PH; i++) s_boot_fb[i] = 0x0000;
bootBlit();
heap_caps_free(s_boot_fb);
s_boot_fb = nullptr;
}
if (s_boot_lock) xSemaphoreGive(s_boot_lock);
}
// Bring up the esp-hosted SDIO link to the ESP32-C6 radio co-processor that BOTH LoRa and WiFi ride.
//
// esp_hosted is force-initialized before app_main by an unconditional __attribute__((constructor))
// in esp_hosted (port_esp_hosted_host_init.c) — there's no Kconfig to disable it. That pre-init is
// what made Arduino WiFi impossible: arduino's WiFi.mode -> hostedInit() calls esp_hosted_sdio_set_config(),
// which returns ESP_ERR_NOT_ALLOWED once esp_hosted is already configured, and this arduino build treats
// that as fatal -> esp_wifi_init() never runs (WiFi stuck at WIFI_NOT_INIT). And nobody ever called
// connect_to_slave(), so LoRa timed out too.
//
// LoRa fix: call connect_to_slave() (esp_hosted is already inited by the constructor) — that
// identifies the C6 slave so the LoRa custom-RPC channel works. WiFi needs more: arduino's WiFi.mode
// init bails because esp_hosted is pre-inited (esp_hosted_sdio_set_config -> ESP_ERR_NOT_ALLOWED).
// The code-only deinit workaround crashes (esp_hosted_deinit heap bug), so WiFi stays blocked pending
// a one-line arduino-core patch (esp32-hal-hosted.c:326 tolerate NOT_ALLOWED).
static void hostedConnectC6() {
esp_hosted_init(); // no-op (constructor already did it)
int rc = -1;
for (int i = 0; i < 4 && rc != 0; i++) {
rc = esp_hosted_connect_to_slave();
if (rc != 0) { Serial.printf("[BOOT] C6 connect try %d -> %d\n", i, rc); delay(250); }
}
Serial.printf("[BOOT] C6 hosted link: %s\n", rc == 0 ? "UP" : "FAILED");
}
static void wadameshSetup() {
// Do NOT call Serial.begin() on the Tanmatsu (P4). The launcher already owns the USB-Serial-JTAG
// console; re-initialising it here crashed the app during boot (before the splash could even draw
// — the beta_21+ symptom of "blue screen, no boot log"). Serial output is swallowed on this board's
// IDF console anyway, so printf (not Serial) carries the [BOOT] logs. See tanmatsu-serial-begin memory.
delay(150);
printf("[BOOT] wadamesh / tanmatsu\n");
// badge-bsp: panel + input + power. The panel must be up before LVGL flushes to it; ask for
// RGB565 to match LVGL's 16-bit color depth (lvglFlush -> writePixelsRGB565 -> bsp_display_blit).
const bsp_configuration_t bspcfg = {
.display = { .requested_color_format = BSP_DISPLAY_COLOR_FORMAT_16_565RGB, .num_fbs = 1 },
};
if (bsp_device_initialize(&bspcfg) != ESP_OK) Serial.println("[BOOT] bsp_device_initialize FAILED");
bootSplashBegin(); // panel is up — start the on-screen boot log
board.begin();
// Bring up lwIP/tcpip + the default event loop before any WiFi/netif call. On the S3 boards
// Arduino's WiFi path does this implicitly; on the P4/esp_wifi_remote it isn't, so WiFi.mode()
// / WiFiServer would hit lwIP with no tcpip mbox and assert. Both are idempotent.
esp_netif_init();
esp_event_loop_create_default();
bootLog("C6 radio coproc");
// Power-cycle the C6 through the CH32 so the app ALWAYS gets a fresh C6. Without this,
// re-launching the app (especially right after a flash) onto a C6 still in its previous
// state wedges esp-hosted ("Not able to connect with ESP-Hosted slave") into a P4 reboot
// loop that previously only a manual power cycle cleared — this does the same in software.
{
tanmatsu_coprocessor_handle_t cph = nullptr;
if (bsp_tanmatsu_coprocessor_get_handle(&cph) == ESP_OK && cph) {
tanmatsu_coprocessor_radio_disable(cph);
delay(150);
tanmatsu_coprocessor_radio_enable_application(cph); // boot the C6 into its esp-hosted slave fw
delay(600); // let the C6 come up before we attach
}
}
hostedConnectC6(); // identify the C6 slave so WiFi + LoRa work (must precede radio_init)
bootLog("Radio link");
if (!radio_init()) { bootLog("radio init FAILED"); Serial.println("[BOOT] radio_init FAILED"); }
DisplayDriver* disp = &display;
// Persistence: point DataStore at a writable directory the launcher has already mounted (see
// discoverPersistMount + the PersistFS globals). DataStore (profile name, NodePrefs, contacts,
// channels, chat history) is otherwise lost on reboot; NVS-backed stores (Wi-Fi creds, TouchPrefs)
// persist independently. FAT is hierarchical (unlike the flat SPIFFS the other boards use), so the
// subdirectories DataStore writes into must exist first — DataStore::begin() makes /bl, but on
// ESP32 it never calls identity_store.begin() (RP2040-only), so /identity must be created here or
// the node keypair can't be saved and a fresh identity is generated every boot.
bootLog("Storage");
// An AppFS app boots fresh, so enumerate the partitions we actually see, then mount the internal
// 'locfd' FAT partition with FFat. printf (NOT Serial — Arduino Serial is swallowed on this board's
// IDF console) so the result lands in the serial monitor.
printf("[storage] partitions visible to the app:\n");
esp_partition_iterator_t pit = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
for (; pit != NULL; pit = esp_partition_next(pit)) {
const esp_partition_t* pp = esp_partition_get(pit);
printf("[storage] %-10s type=%d sub=%d off=0x%06lx size=0x%06lx\n",
pp->label, (int)pp->type, (int)pp->subtype, (unsigned long)pp->address, (unsigned long)pp->size);
}
if (pit) esp_partition_iterator_release(pit);
g_fs_ok = FFat.begin(true, "/ffat", 10, "locfd");
printf("[storage] FFat.begin(locfd) = %s\n", g_fs_ok ? "OK" : "FAILED");
if (g_fs_ok) {
printf("[storage] FFat %u KB used / %u KB total\n",
(unsigned)(FFat.usedBytes() / 1024), (unsigned)(FFat.totalBytes() / 1024));
// FAT is hierarchical: DataStore::begin() makes /bl, but on ESP32 it never calls
// identity_store.begin() (RP2040-only), so /identity is missing — without it the node keypair
// can't be saved and a fresh identity is generated every boot.
FFat.mkdir("/identity");
FFat.mkdir("/bl");
} else {
printf("[storage] persistence disabled this boot (DataStore opens fail gracefully)\n");
}
// The internal FFat 'locfd' on this P4 has a broken FAT metadata layer (see the tile-cache notes):
// open/read/write work but f_stat/exists()/size() return garbage — and WHICH garbage shifts with
// the build (-Og "worked by luck"; -Os made the exists()-gated identity + prefs loads come up
// empty: fresh node identity, default name, profile changes lost every reboot). The card's FAT
// metadata is truthful, so the WHOLE store lives there now; FFat remains only the no-card fallback.
g_sd_ok = SD_MMC.begin("/sdcard", false /*1-bit*/) && SD_MMC.cardType() != CARD_NONE;
printf("[storage] SD_MMC.begin = %s\n", g_sd_ok ? "OK" : "no card");
if (g_sd_ok) {
SD_MMC.mkdir("/meshcomod");
SD_MMC.mkdir("/meshcomod/identity");
SD_MMC.mkdir("/meshcomod/bl");
if (g_fs_ok) {
// One-time rescue of identity + prefs off FFat: probe by OPEN + READ (never
// exists()/size() on this FS). Files the card already holds win — SD metadata
// is honest, so exists() is safe THERE.
static const char* k_mig[] = { "/new_prefs", "/new_prefs.tmp", "/node_prefs",
"/identity/_main.id" };
for (const char* nm : k_mig) {
char dst[48];
snprintf(dst, sizeof dst, "/meshcomod%s", nm);
if (SD_MMC.exists(dst)) continue;
File s = FFat.open(nm, FILE_READ);
if (!s) continue;
uint8_t buf[512];
size_t n = s.read(buf, sizeof buf);
if (n == 0) { s.close(); continue; } // ghost/empty entry — nothing to keep
File d = SD_MMC.open(dst, FILE_WRITE);
if (!d) { s.close(); continue; }
size_t total = 0;
do { d.write(buf, n); total += n; n = s.read(buf, sizeof buf); } while (n > 0);
d.close();
s.close();
printf("[storage] migrated %s -> SD:/meshcomod (%u B)\n", nm, (unsigned)total);
}
}
store.useSdMmcStorage(); // identity/prefs/contacts/channels all on the card
}
// No card: FFat stays the store, as before — identity/prefs loads there depend
// on the broken metadata layer and may be unreliable, but it is the only
// persistent option on a card-less unit.
store.begin();
bootLog("Mesh stack");
the_mesh.begin(disp != NULL);
bootLog("Companion interface");
serial_interface.begin(Serial, TCP_PORT, WS_PORT);
serial_interface.setBroadcastResponses(true);
the_mesh.startInterface(serial_interface);
#if defined(BLE_PIN_CODE)
// BLE companion over the C6: bring up the remote BLE controller (esp-hosted), then the NimBLE host
// (esp-nimble-cpp). hostedInitBLE() is arduino's controller bring-up; our build.sh patch lets its
// hostedInit() tolerate the already-configured esp_hosted (from hostedConnectC6()).
bootLog("Bluetooth");
if (hostedInitBLE()) {
char* nm = the_mesh.getNodePrefs()->node_name;
serial_interface.prepareBle("wadamesh-", nm, the_mesh.getBLEPin());
if (wifiConfigGetBleEnabled())
serial_interface.beginBle("wadamesh-", nm, the_mesh.getBLEPin());
} else {
Serial.println("[BOOT] hostedInitBLE FAILED");
}
#endif
bootLog("Sensors");
sensors.begin();
bootLog("Building UI");
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs());
board.onBootComplete();
bootLog("Ready");
bootSplashEnd(); // stop the animator + clean black handoff
// The splash wrote straight to the panel framebuffer behind LVGL's back, so LVGL still believes
// the screen is unchanged and would leave our black fill on top of the UI. Invalidate every layer
// so the first lv_timer_handler() repaints the whole UI over the handoff.
if (lv_disp_t* d = lv_disp_get_default()) {
lv_obj_invalidate(lv_disp_get_scr_act(d));
lv_obj_invalidate(lv_disp_get_layer_top(d));
lv_obj_invalidate(lv_disp_get_layer_sys(d));
}
Serial.println("[BOOT] setup done");
}
extern "C" void app_main(void) {
initArduino();
wadameshSetup();
// WiFi state machine + SNTP + TCP companion server. On the S3 boards this lives in
// src/main.cpp's loop() — which the Tanmatsu build excludes — so without it the UI's
// wifiConfigRequestApply() is never consumed and WiFi.begin() never runs (radio sits in
// "connecting" forever). Ported faithfully here.
bool wifi_started = false, wifi_radio_prev = true, wifi_radio_inited = false;
bool sntp_kicked = false, sntp_pushed = false, modem_sleep_set = false;
uint32_t last_wifi_retry_ms = 0, sntp_kick_ms = 0;
const uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
for (;;) {
ui_task.loop(); // UI first (splash/flush)
bool wifi_radio_en = wifiConfigWantsWifi();
if (!wifi_radio_inited) { wifi_radio_inited = true; wifi_radio_prev = wifi_radio_en; }
else if (wifi_radio_en != wifi_radio_prev) {
wifi_radio_prev = wifi_radio_en;
if (!wifi_radio_en) { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); }
wifi_started = false;
}
if (wifiConfigConsumeApplyRequest()) { // UI changed SSID/PWD or toggled the radio
if (wifi_started) {
if (!wifi_radio_en) { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); }
else { WiFi.disconnect(false, false); delay(50); }
}
wifi_started = false; last_wifi_retry_ms = 0;
}
if (wifi_radio_en) {
if (!wifi_started) {
wifi_started = true;
WiFi.mode(WIFI_STA);
if (wifiConfigHasRuntime()) {
char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX];
wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd));
if (strlen(ssid) > 0) { WiFi.begin(ssid, pwd[0] ? pwd : nullptr); last_wifi_retry_ms = millis(); }
}
}
if (wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED) { // periodic reconnect
uint32_t now = millis();
if ((uint32_t)(now - last_wifi_retry_ms) >= WIFI_RETRY_INTERVAL_MS) {
last_wifi_retry_ms = now;
char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX];
wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd));
if (strlen(ssid) > 0) { WiFi.disconnect(false, true); WiFi.begin(ssid, pwd[0] ? pwd : nullptr); }
}
}
if (WiFi.status() == WL_CONNECTED) {
if (!modem_sleep_set) { WiFi.setSleep(true); modem_sleep_set = true; }
// Start the TCP (+ WebSocket) companion server now the STA is associated. Deferred to here on
// purpose: starting WiFiServer before association asserts in lwIP (the old reason this was
// stubbed out). startTcpServer() is idempotent — it no-ops once the server is up — so calling
// it on every connected tick is safe.
serial_interface.startTcpServer(true);
if (!sntp_kicked) {
char tz[48]; touchPrefsBuildLocalTz(tz, sizeof tz);
configTzTime(tz, "pool.ntp.org", "time.google.com");
sntp_kicked = true; sntp_kick_ms = millis();
} else if (!sntp_pushed && (uint32_t)(millis() - sntp_kick_ms) >= 1500) {
time_t t = time(nullptr);
if (t > 1700000000) { rtc_clock.setCurrentTime((uint32_t)t); sntp_pushed = true; }
}
} else if (sntp_kicked && !sntp_pushed) { sntp_kicked = false; }
}
serial_interface.tickWebSocketHandshake(); // accept WS clients/handshakes (no-op until the WS server is up)
the_mesh.loop(); // mesh + companion servers
vTaskDelay(1);
}
}