Compare commits
No files matched your search
+12
-1
@@ -272,7 +272,12 @@ build_flags =
|
||||
-D PIN_BOARD_SDA=17
|
||||
-D PIN_BOARD_SCL=18
|
||||
-D PIN_SD_CS=3
|
||||
-D HELTEC_V4_BUZZER_PIN=-1
|
||||
; Buzzer: the Expansion Kit V2 integrates a piezo behind a PAM8904 driver (U9). Per Heltec's
|
||||
; Expansion_board_V2.03.pdf schematic: DIN = net "4" = GPIO4 (P2 header pin 15); EN1/EN2 are
|
||||
; NOT GPIOs -- strapped high via 10K pull-ups (3x boost, always enabled) with DIP switch SW1
|
||||
; on the expansion board as a HARDWARE MUTE (closed = EN grounded = silent). So the stock V4
|
||||
; tone() chime works as-is on DIN. If a tester still hears nothing: check SW1 on their kit.
|
||||
-D HELTEC_V4_BUZZER_PIN=4
|
||||
-D FIRMWARE_OTA_ENV='"heltec_v4_r8_tft_companion_radio_usb_tcp_touch"'
|
||||
lib_deps =
|
||||
${env:heltec_v4_tft_companion_radio_usb_tcp_touch.lib_deps}
|
||||
@@ -665,6 +670,9 @@ build_flags =
|
||||
; --- T-LoRa Pager board ---
|
||||
-I variants/lilygo_tlora_pager
|
||||
-D TLORA_PAGER=1
|
||||
-D LV_FONT_MONTSERRAT_18=1
|
||||
-D LV_FONT_MONTSERRAT_20=1
|
||||
-D LV_FONT_MONTSERRAT_24=1
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
; 0 = silence ARDUHAL [E] spam, same rationale as the other two envs.
|
||||
-D CORE_DEBUG_LEVEL=0
|
||||
@@ -887,6 +895,9 @@ build_flags =
|
||||
; --- T-LoRa Pager board (SX1262 variant) ---
|
||||
-I variants/lilygo_tlora_pager
|
||||
-D TLORA_PAGER=1
|
||||
-D LV_FONT_MONTSERRAT_18=1
|
||||
-D LV_FONT_MONTSERRAT_20=1
|
||||
-D LV_FONT_MONTSERRAT_24=1
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
; 0 = silence ARDUHAL [E] spam, same rationale as the other envs.
|
||||
-D CORE_DEBUG_LEVEL=0
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# beta_54 - T-Display P4 screen flash fixed, P4 GPS fixed, Romanian, V4-R8 landscape rescue
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- T-Display P4 ----------------------------------------------------------------------
|
||||
The whole-screen blue flash (#167) is fixed. It was never the display: writing to the SD card delays the interrupt that restarts the screen's frame timer, and the panel shows the late frame as a flash. The fix moves your contacts, channels and chat history to fast internal storage - the SD card now carries only map tiles, backups and exports. Your data migrates across automatically on first boot; the card is not modified and everything on it stays as a backup.
|
||||
Because the card no longer holds your identity, swapping or removing the SD card can never again change who your node is or lose your contacts.
|
||||
GPS is really fixed this time: the firmware never actually read what the GPS module was saying (the sensor loop was missing on this board). It reads it now - fixes confirmed outdoors, and the T-Deck-style quick fix behaviour returns after the module has had sky time.
|
||||
The GPS settings page now shows live satellite info (in view, in use, signal strength) and updates while open, so "Searching" is no longer a black box.
|
||||
Storage writes now run on a dedicated background worker, so a big contact save cannot make the interface stutter.
|
||||
|
||||
# --- Romanian (community) ---------------------------------------------------------------
|
||||
Romanian is the 13th interface language, with its own keyboard layout: ă on the home row, ş/ţ on the deck, â/î via the accent popups. Contributed by @jcyrio (#188, #189) - mulţumim!
|
||||
|
||||
# --- Heltec V4-R8 -----------------------------------------------------------------------
|
||||
Fixed landscape mode registering every touch in the wrong place, which could leave the device stuck in landscape. The R8's screen sits rotated differently from the V4's, and the touch mapping assumed the V4. If you were stuck: this build boots back to portrait automatically.
|
||||
For safety the R8 returns to portrait on every boot for now; landscape can be re-tested from Settings and we will make it stick once a tester confirms the fix.
|
||||
|
||||
# --- General ----------------------------------------------------------------------------
|
||||
The Airtime and Snake apps got a crash fix when closing.
|
||||
@@ -0,0 +1,17 @@
|
||||
# beta_55 - settings that stay put, fast SD boots, Pager text sizes, R8 sound
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- Reliability (community) -----------------------------------------------------------
|
||||
Settings can no longer silently reset themselves. An interrupted settings write could truncate the settings file - the sound toggle turning itself back on, the setup wizard reappearing, a mystery #unknown channel. Settings now save through paired snapshots with checksums, written in the background: a torn write can only lose the very last change, never your configuration. By @PixPMusic (#195, #196).
|
||||
Settings changes also no longer cause interface hitches - saves happen off the interface thread.
|
||||
Note if you downgrade after running this build: the older firmware reads the settings file which can be one save behind.
|
||||
Fixed the 3-minute boot with an SD card inserted (T-Deck, mostly Launcher installs). A card that woke up at a low speed kept that speed for the whole session, so contacts and history loaded at ~25 KB/s - and the file manager showed the card as 0KB/empty. The mount now raises the clock back to full speed after a slow wake, the standard way. Thanks to scsirob for the report and testing (#194).
|
||||
|
||||
# --- T-Pager (community) ---------------------------------------------------------------
|
||||
New UI size presets for the T-Pager: Small (current look, default), Medium and Large - bigger text without pushing controls off the short screen, with encoder scrolling for tall content. By @PixPMusic (#197). Please try Medium/Large and report anything that clips.
|
||||
|
||||
# --- Heltec V4-R8 -----------------------------------------------------------------------
|
||||
The buzzer works. The Expansion Kit V2's buzzer sat behind a driver chip whose wiring was unconfirmed, so it shipped disabled - R8 users have never heard a chime. Heltec's schematic settled it. If it is still silent on your kit, check the small SW1 switch on the expansion board: it is a hardware mute.
|
||||
|
||||
# --- General ----------------------------------------------------------------------------
|
||||
Long-pressing the GPS tile in the control centre now opens the GPS settings page instead of the radio page.
|
||||
@@ -0,0 +1,13 @@
|
||||
# beta_56 - duty-cycle fix (important), Pager SD storage, issue-sweep fixes
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- Read this first -------------------------------------------------------------------
|
||||
IMPORTANT - if you ever picked a region preset, your radio was transmitting at roughly 9x the duty limit (about 91% instead of 10%). The preset math set the airtime limit 10x too loose; the home-screen meter showing "91% max duty cycle" was correctly reporting it (#161). This build fixes the presets AND automatically corrects devices whose settings exactly match a preset. If you tuned your airtime factor by hand, nothing is touched.
|
||||
|
||||
# --- T-Pager ----------------------------------------------------------------------------
|
||||
The SD card is now actually used (#193). The card mounted and browsed fine but nothing stored to it - the storage feature was never enabled for this board. The Pager now gets the same "Store data on SD" option, boot storage selection and migration as the T-Deck.
|
||||
|
||||
# --- Fixes across boards ----------------------------------------------------------------
|
||||
Message details now show the region scope for scoped direct messages, and no longer show "Scope 0000" for senders that carry the region in the alternate slot (#157).
|
||||
The Discover app keeps the last-heard time on screen with long node names - names are trimmed and node types shown as single letters (#191).
|
||||
The screen-unlock animation says WADAMESH instead of the old MESHCOMOD (#165).
|
||||
@@ -46,6 +46,9 @@ const char* DataStore::_rp(const char* name) {
|
||||
}
|
||||
|
||||
File DataStore::openWrite(FILESYSTEM* fs, const char* filename) {
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
printf("[SDW] %lu core%d store w %s\n", (unsigned long)millis(), xPortGetCoreID(), filename);
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
fs->remove(_rp(filename));
|
||||
return fs->open(_rp(filename), FILE_O_WRITE);
|
||||
@@ -192,6 +195,9 @@ File DataStore::openRead(const char* filename) {
|
||||
}
|
||||
|
||||
File DataStore::openRead(FILESYSTEM* fs, const char* filename) {
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
printf("[SDR] %lu core%d store r %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), filename);
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return fs->open(_rp(filename), FILE_O_READ);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
@@ -365,6 +371,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
}
|
||||
|
||||
bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; const NodePrefs* p; double la, lo; bool r; } a{ this, &_prefs, node_lat, node_lon, false };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->savePrefs(*a->p, a->la, a->lo); }, &a);
|
||||
return a.r;
|
||||
}
|
||||
#endif
|
||||
// Write to a temp file first and swap it in afterwards: a reboot / power cut
|
||||
// mid-write can then never destroy the only copy (the loader recovers from
|
||||
// whichever file survived). SPIFFS has no atomic rename-over, so the swap is
|
||||
@@ -468,6 +481,13 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; DataStoreHost* h; bool (*f)(const ContactInfo&); } a{ this, host, filter };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveContacts(a->h, a->f); }, &a);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if defined(ESP32)
|
||||
// A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC
|
||||
// pass that disables the flash cache and starves core 0's idle task, tripping
|
||||
@@ -608,6 +628,13 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; DataStoreHost* h; } a{ this, host };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->d->saveChannels(a->h); }, &a);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
File file = openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
@@ -778,6 +805,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b
|
||||
}
|
||||
|
||||
bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a);
|
||||
return a.r;
|
||||
}
|
||||
#endif
|
||||
if (len < PUB_KEY_SIZE+4+SIGNATURE_SIZE || len > MAX_ADVERT_PKT_LEN) return false;
|
||||
checkAdvBlobFile();
|
||||
File file = _getContactsChannelsFS()->open("/adv_blobs", FILE_O_WRITE);
|
||||
@@ -815,6 +849,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
return false; // error
|
||||
}
|
||||
bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a);
|
||||
return a.r;
|
||||
}
|
||||
#endif
|
||||
return true; // this is just a stub on NRF52/STM32 platforms
|
||||
}
|
||||
#else
|
||||
@@ -841,6 +882,13 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b
|
||||
}
|
||||
|
||||
bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; const uint8_t* k; int kl; const uint8_t* b; uint8_t l; bool r; } a{ this, key, key_len, src_buf, len, false };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->putBlobByKey(a->k, a->kl, a->b, a->l); }, &a);
|
||||
return a.r;
|
||||
}
|
||||
#endif
|
||||
char path[64];
|
||||
makeBlobPath(key, key_len, path, sizeof(path));
|
||||
|
||||
@@ -856,6 +904,13 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
}
|
||||
|
||||
bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
if (!p4OnStorageTask()) {
|
||||
struct A { DataStore* d; const uint8_t* k; int kl; bool r; } a{ this, key, key_len, false };
|
||||
p4StorageCall([](void* p){ auto* a = (A*)p; a->r = a->d->deleteBlobByKey(a->k, a->kl); }, &a);
|
||||
return a.r;
|
||||
}
|
||||
#endif
|
||||
char path[64];
|
||||
makeBlobPath(key, key_len, path, sizeof(path));
|
||||
|
||||
@@ -912,3 +967,57 @@ bool DataStore::useSdMmcStorage() {
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
// #167: core-0 storage dispatcher. The DSI panel's frame-restart ISR lives on core 1 (where
|
||||
// Arduino's loop task also runs); SDMMC/FATFS write paths entered FROM core 1 mask that core's
|
||||
// interrupts inside their critical sections long enough to drop a display frame -- the
|
||||
// whole-screen flash. Tiles write the same card from the core-0 tile task without ever
|
||||
// flashing; this moves every hot store write onto the same quiet core. The caller BLOCKS until
|
||||
// the write completes, so call-site semantics are exactly as before.
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
struct P4StoreJob { void (*fn)(void*); void* arg; SemaphoreHandle_t done; };
|
||||
static QueueHandle_t s_p4store_q = nullptr;
|
||||
|
||||
static void p4StoreTaskFn(void*) {
|
||||
P4StoreJob j;
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_p4store_q, &j, portMAX_DELAY) == pdTRUE) {
|
||||
j.fn(j.arg);
|
||||
xSemaphoreGive(j.done);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static TaskHandle_t s_p4store_task = nullptr;
|
||||
|
||||
bool p4OnStorageTask() { return xTaskGetCurrentTaskHandle() == s_p4store_task && s_p4store_task != nullptr; }
|
||||
|
||||
void p4StorageCall(void (*fn)(void*), void* arg) {
|
||||
// Guard on TASK IDENTITY, not core. The original core guard was written believing the main
|
||||
// task ran on core 1; it runs on core 0 (ESP_MAIN_TASK_AFFINITY_CPU0), so the guard
|
||||
// short-circuited and every "hopped" write actually ran inline on the main task -- proven by
|
||||
// the sector-level trace (all SDIO on task 'main'). The storage task is pinned to CORE 1,
|
||||
// which is essentially empty on this board: the DSI frame-restart ISR, the main (UI+mesh)
|
||||
// task, the SDMMC ISR and the tile task all live on core 0, so SD critical sections executed
|
||||
// on core 1 can never mask the display's interrupt regardless of what the UI is rendering.
|
||||
if (p4OnStorageTask()) { fn(arg); return; } // re-entry on the storage task
|
||||
static SemaphoreHandle_t s_mtx = nullptr;
|
||||
if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); // races only at first-ever call during boot (single-threaded)
|
||||
xSemaphoreTake(s_mtx, portMAX_DELAY);
|
||||
if (!s_p4store_q) {
|
||||
s_p4store_q = xQueueCreate(1, sizeof(P4StoreJob));
|
||||
xTaskCreatePinnedToCore(p4StoreTaskFn, "store1", 8192, nullptr, 3, &s_p4store_task, 0); // core 0: the SDMMC completion ISR lives there; core-1 I/O crawls on cross-core wakeups
|
||||
}
|
||||
static SemaphoreHandle_t s_done = nullptr; // one in flight (serialized by s_mtx)
|
||||
if (!s_done) s_done = xSemaphoreCreateBinary();
|
||||
P4StoreJob j{ fn, arg, s_done };
|
||||
xQueueSend(s_p4store_q, &j, portMAX_DELAY);
|
||||
xSemaphoreTake(s_done, portMAX_DELAY);
|
||||
xSemaphoreGive(s_mtx);
|
||||
}
|
||||
#endif
|
||||
@@ -88,3 +88,9 @@ public:
|
||||
private:
|
||||
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
|
||||
};
|
||||
|
||||
#if defined(HAS_TDISPLAY_P4)
|
||||
// #167: run fn(arg) on the dedicated CORE-1 storage task, blocking the caller. See DataStore.cpp.
|
||||
void p4StorageCall(void (*fn)(void*), void* arg);
|
||||
bool p4OnStorageTask(); // true when the current task IS the storage task (wrapper re-entry guard)
|
||||
#endif
|
||||
+6
-1
@@ -602,7 +602,12 @@ public:
|
||||
}
|
||||
const uint8_t rt = pkt ? pkt->getRouteType() : 0xFF;
|
||||
_last_rx_has_scope = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT);
|
||||
_last_rx_scope = (_last_rx_has_scope && pkt) ? pkt->transport_codes[0] : 0;
|
||||
// #157: some senders carry the region in transport_codes[1] (reply-region hint) with
|
||||
// codes[0] zero -- the Info popup then showed "Scope 0000" for a genuinely scoped message.
|
||||
// Show whichever code is set; [0] (the scope proper) wins when both are.
|
||||
_last_rx_scope = 0;
|
||||
if (_last_rx_has_scope && pkt)
|
||||
_last_rx_scope = pkt->transport_codes[0] ? pkt->transport_codes[0] : pkt->transport_codes[1];
|
||||
}
|
||||
|
||||
/** Track a freshly-sent flood TXT fingerprint (called from sendFloodScoped). */
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
#include <FS.h>
|
||||
#include <SD.h>
|
||||
#include <SPIFFS.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
#include <new>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// ----------------------------- backend selection -----------------------------
|
||||
@@ -18,12 +24,248 @@ static bool s_file_mode = false;
|
||||
static int s_legacy = -1; // legacy probe cache: -1 undecided, 0 SD, 1 NVS
|
||||
static int s_migrate = -1; // -1 undecided, 0 = no NVS legacy data, 1 = yes
|
||||
|
||||
static constexpr uint32_t PREFS_MAGIC = 0x32504657u; // "WFP2"
|
||||
static constexpr uint16_t PREFS_FORMAT = 1;
|
||||
static constexpr uint32_t PREFS_QUIET_MS = 750;
|
||||
static constexpr uint32_t PREFS_MAX_WAIT_MS = 5000;
|
||||
// These are settings namespaces, not a general object store. A hard ceiling
|
||||
// bounds the RAM snapshot + worker copy on boards without PSRAM; current touch
|
||||
// and discovered payloads are both well below 16 KB.
|
||||
static constexpr size_t PREFS_MAX_PAYLOAD = 64u * 1024u;
|
||||
|
||||
struct __attribute__((packed)) SnapshotFooter {
|
||||
uint32_t magic;
|
||||
uint16_t format;
|
||||
uint16_t footer_size;
|
||||
uint32_t generation;
|
||||
uint32_t payload_size;
|
||||
uint32_t payload_crc;
|
||||
uint32_t footer_crc;
|
||||
};
|
||||
|
||||
struct FileCache {
|
||||
fs::FS* fs = nullptr;
|
||||
char legacy_path[40] = {0};
|
||||
std::vector<SdNvsPrefs::Kv> kv;
|
||||
uint32_t generation = 0;
|
||||
uint32_t revision = 0;
|
||||
uint32_t dirty_first_ms = 0;
|
||||
uint32_t dirty_last_ms = 0;
|
||||
uint32_t retry_after_ms = 0;
|
||||
char active_slot = 0;
|
||||
bool loaded = false;
|
||||
bool dirty = false;
|
||||
bool writing = false;
|
||||
bool legacy_present = false;
|
||||
};
|
||||
|
||||
struct WriteJob {
|
||||
fs::FS* fs = nullptr;
|
||||
char legacy_path[40] = {0};
|
||||
std::vector<SdNvsPrefs::Kv> kv;
|
||||
uint32_t generation = 0;
|
||||
uint32_t revision = 0;
|
||||
char slot = 'a';
|
||||
};
|
||||
|
||||
static std::vector<FileCache> s_caches;
|
||||
static SemaphoreHandle_t s_cache_mutex = nullptr;
|
||||
static QueueHandle_t s_write_queue = nullptr;
|
||||
static TaskHandle_t s_write_task = nullptr;
|
||||
|
||||
static uint32_t crc32Update(uint32_t crc, const uint8_t* data, size_t len) {
|
||||
crc = ~crc;
|
||||
while (len--) {
|
||||
crc ^= *data++;
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & (uint32_t)-(int32_t)(crc & 1u));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
static uint32_t crc32(const void* data, size_t len) {
|
||||
return crc32Update(0, static_cast<const uint8_t*>(data), len);
|
||||
}
|
||||
|
||||
static bool slotPath(const char* legacy_path, char slot, char* out, size_t cap) {
|
||||
if (!legacy_path || !out || cap == 0) return false;
|
||||
if (slot == 'a') return snprintf(out, cap, "%s", legacy_path) < (int)cap;
|
||||
const size_t n = strlen(legacy_path);
|
||||
if (n >= 3 && strcmp(legacy_path + n - 3, ".kv") == 0)
|
||||
return snprintf(out, cap, "%.*s.alt", (int)(n - 3), legacy_path) < (int)cap;
|
||||
return snprintf(out, cap, "%s.alt", legacy_path) < (int)cap;
|
||||
}
|
||||
|
||||
static bool serializeKv(const std::vector<SdNvsPrefs::Kv>& kv, std::vector<uint8_t>& out) {
|
||||
size_t total = 0;
|
||||
for (const auto& e : kv) {
|
||||
const size_t kl = strnlen(e.key, sizeof(e.key));
|
||||
if (kl == 0 || kl > 15 || e.val.size() > 2048) return false;
|
||||
total += 1 + kl + 2 + e.val.size();
|
||||
if (total > PREFS_MAX_PAYLOAD) return false;
|
||||
}
|
||||
out.clear();
|
||||
out.reserve(total);
|
||||
for (const auto& e : kv) {
|
||||
const size_t kl = strnlen(e.key, sizeof(e.key));
|
||||
const size_t vl = e.val.size();
|
||||
out.push_back((uint8_t)kl);
|
||||
out.insert(out.end(), e.key, e.key + kl);
|
||||
out.push_back((uint8_t)(vl & 0xFF));
|
||||
out.push_back((uint8_t)((vl >> 8) & 0xFF));
|
||||
out.insert(out.end(), e.val.begin(), e.val.end());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parsePayload(const uint8_t* data, size_t len, std::vector<SdNvsPrefs::Kv>& out) {
|
||||
out.clear();
|
||||
size_t pos = 0;
|
||||
while (pos < len) {
|
||||
if (out.size() >= 256 || pos + 1 > len) return false;
|
||||
const size_t kl = data[pos++];
|
||||
if (kl == 0 || kl > 15 || pos + kl + 2 > len) return false;
|
||||
SdNvsPrefs::Kv e{};
|
||||
memcpy(e.key, data + pos, kl);
|
||||
e.key[kl] = '\0';
|
||||
pos += kl;
|
||||
const size_t vl = (size_t)data[pos] | ((size_t)data[pos + 1] << 8);
|
||||
pos += 2;
|
||||
if (vl > 2048 || pos + vl > len) return false;
|
||||
e.val.assign(data + pos, data + pos + vl);
|
||||
pos += vl;
|
||||
out.push_back(std::move(e));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool readLegacyFile(fs::FS* fs, const char* path, std::vector<SdNvsPrefs::Kv>& out) {
|
||||
if (!fs || !fs->exists(path)) return false;
|
||||
File f = fs->open(path, FILE_READ);
|
||||
if (!f) return false;
|
||||
const size_t size = f.size();
|
||||
if (size > PREFS_MAX_PAYLOAD) { f.close(); return false; }
|
||||
std::vector<uint8_t> payload(size);
|
||||
const bool ok = (size == 0 || f.read(payload.data(), size) == (int)size);
|
||||
f.close();
|
||||
return ok && parsePayload(payload.data(), payload.size(), out);
|
||||
}
|
||||
|
||||
static bool readSnapshot(fs::FS* fs, const char* path, std::vector<SdNvsPrefs::Kv>& out,
|
||||
uint32_t& generation) {
|
||||
if (!fs || !fs->exists(path)) return false;
|
||||
File f = fs->open(path, FILE_READ);
|
||||
if (!f) return false;
|
||||
const size_t size = f.size();
|
||||
if (size < sizeof(SnapshotFooter) || size - sizeof(SnapshotFooter) > PREFS_MAX_PAYLOAD) {
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
const size_t payload_size = size - sizeof(SnapshotFooter);
|
||||
std::vector<uint8_t> payload(payload_size);
|
||||
SnapshotFooter footer{};
|
||||
bool ok = (payload_size == 0 || f.read(payload.data(), payload_size) == (int)payload_size) &&
|
||||
f.read((uint8_t*)&footer, sizeof(footer)) == (int)sizeof(footer);
|
||||
f.close();
|
||||
if (!ok || footer.magic != PREFS_MAGIC || footer.format != PREFS_FORMAT ||
|
||||
footer.footer_size != sizeof(footer) || footer.payload_size != payload_size ||
|
||||
footer.payload_crc != crc32(payload.data(), payload.size()) ||
|
||||
footer.footer_crc != crc32(&footer, offsetof(SnapshotFooter, footer_crc))) return false;
|
||||
if (!parsePayload(payload.data(), payload.size(), out)) return false;
|
||||
generation = footer.generation;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool writeSnapshot(fs::FS* fs, const char* legacy_path, char slot, uint32_t generation,
|
||||
const std::vector<SdNvsPrefs::Kv>& kv) {
|
||||
if (!fs) return false;
|
||||
char path[44];
|
||||
if (!slotPath(legacy_path, slot, path, sizeof(path))) return false;
|
||||
char dir[40];
|
||||
strncpy(dir, legacy_path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0';
|
||||
char* slash = strrchr(dir, '/');
|
||||
if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); }
|
||||
|
||||
std::vector<uint8_t> payload;
|
||||
if (!serializeKv(kv, payload)) return false;
|
||||
SnapshotFooter footer{PREFS_MAGIC, PREFS_FORMAT, (uint16_t)sizeof(SnapshotFooter),
|
||||
generation, (uint32_t)payload.size(), crc32(payload.data(), payload.size()), 0};
|
||||
footer.footer_crc = crc32(&footer, offsetof(SnapshotFooter, footer_crc));
|
||||
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
printf("[SDW] %lu core%d prefs %s\n", (unsigned long)millis(), (int)xPortGetCoreID(), path);
|
||||
#endif
|
||||
File f = fs->open(path, FILE_WRITE);
|
||||
if (!f) return false;
|
||||
bool ok = (payload.empty() || f.write(payload.data(), payload.size()) == payload.size()) &&
|
||||
f.write((const uint8_t*)&footer, sizeof(footer)) == sizeof(footer);
|
||||
f.flush();
|
||||
f.close();
|
||||
if (!ok) return false;
|
||||
|
||||
std::vector<SdNvsPrefs::Kv> verify;
|
||||
uint32_t verify_generation = 0;
|
||||
return readSnapshot(fs, path, verify, verify_generation) && verify_generation == generation;
|
||||
}
|
||||
|
||||
static bool newerGeneration(uint32_t lhs, uint32_t rhs) {
|
||||
return (int32_t)(lhs - rhs) > 0;
|
||||
}
|
||||
|
||||
static FileCache* findCacheLocked(const char* path) {
|
||||
for (auto& cache : s_caches)
|
||||
if (strncmp(cache.legacy_path, path, sizeof(cache.legacy_path)) == 0) return &cache;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void prefsWriterTask(void*) {
|
||||
for (;;) {
|
||||
WriteJob* job = nullptr;
|
||||
if (xQueueReceive(s_write_queue, &job, portMAX_DELAY) != pdTRUE || !job) continue;
|
||||
const uint32_t started = millis();
|
||||
const bool ok = writeSnapshot(job->fs, job->legacy_path, job->slot, job->generation, job->kv);
|
||||
const uint32_t elapsed = millis() - started;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
if (FileCache* cache = findCacheLocked(job->legacy_path)) {
|
||||
cache->writing = false;
|
||||
if (ok) {
|
||||
cache->generation = job->generation;
|
||||
cache->active_slot = job->slot;
|
||||
if (cache->revision == job->revision) cache->dirty = false;
|
||||
cache->retry_after_ms = 0;
|
||||
} else {
|
||||
cache->dirty = true;
|
||||
cache->dirty_first_ms = cache->dirty_last_ms = millis();
|
||||
cache->retry_after_ms = millis() + 30000;
|
||||
Serial.printf("[PREFS] snapshot write failed %s.%c\n", job->legacy_path, job->slot);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
if (ok && elapsed >= 200)
|
||||
Serial.printf("[PREFS] slow snapshot %s slot %c: %lums\n", job->legacy_path,
|
||||
job->slot, (unsigned long)elapsed);
|
||||
delete job;
|
||||
}
|
||||
}
|
||||
|
||||
static bool ensureWorker() {
|
||||
if (!s_cache_mutex) s_cache_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_write_queue) s_write_queue = xQueueCreate(2, sizeof(WriteJob*));
|
||||
if (!s_cache_mutex || !s_write_queue) return false;
|
||||
if (!s_write_task) {
|
||||
if (xTaskCreatePinnedToCore(prefsWriterTask, "prefs_writer", 6144, nullptr, 2,
|
||||
&s_write_task, 0) != pdPASS) s_write_task = nullptr;
|
||||
}
|
||||
return s_write_task != nullptr;
|
||||
}
|
||||
|
||||
void SdNvsPrefs::useFile(fs::FS* fs, const char* dir) {
|
||||
if (!fs) return;
|
||||
s_file_fs = fs;
|
||||
strncpy(s_file_dir, (dir && dir[0]) ? dir : "/prefs", sizeof(s_file_dir) - 1);
|
||||
s_file_dir[sizeof(s_file_dir) - 1] = '\0';
|
||||
s_file_mode = true;
|
||||
ensureWorker();
|
||||
Serial.printf("[PREFS] backend = FILE %s\n", s_file_dir);
|
||||
}
|
||||
|
||||
@@ -59,23 +301,28 @@ bool SdNvsPrefs::useNvs() {
|
||||
// ----------------------------- begin / end -----------------------------
|
||||
bool SdNvsPrefs::begin(const char* ns, bool readOnly) {
|
||||
_read_only = readOnly;
|
||||
_sd.clear();
|
||||
_sd_loaded = false;
|
||||
if (_nvs_open) { _nvs.end(); _nvs_open = false; }
|
||||
|
||||
if (fileMode()) {
|
||||
snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns);
|
||||
sdLoad();
|
||||
_sd_loaded = true;
|
||||
if (!ns || snprintf(_path, sizeof(_path), "%s/%s.kv", s_file_dir, ns) >= (int)sizeof(_path))
|
||||
return false;
|
||||
const bool loaded = sdLoad();
|
||||
if (nvsHasLegacyData()) _nvs_open = _nvs.begin(ns, true); // RO migration source
|
||||
return true;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
if (useNvs()) { _nvs_open = _nvs.begin(ns, readOnly); return _nvs_open; }
|
||||
snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns);
|
||||
sdLoad();
|
||||
_sd_loaded = true;
|
||||
return true;
|
||||
if (useNvs()) {
|
||||
_sd.clear();
|
||||
_sd_loaded = false;
|
||||
_nvs_open = _nvs.begin(ns, readOnly);
|
||||
return _nvs_open;
|
||||
}
|
||||
if (!ns || snprintf(_path, sizeof(_path), "/meshcomod/%s.kv", ns) >= (int)sizeof(_path))
|
||||
return false;
|
||||
const bool had_cache = _sd_loaded;
|
||||
const bool loaded = sdLoad();
|
||||
if (loaded) _sd_loaded = true;
|
||||
return loaded || (readOnly && had_cache);
|
||||
}
|
||||
|
||||
void SdNvsPrefs::end() {
|
||||
@@ -84,70 +331,282 @@ void SdNvsPrefs::end() {
|
||||
|
||||
// ----------------------------- file helpers -----------------------------
|
||||
SdNvsPrefs::Kv* SdNvsPrefs::sdFind(const char* key) {
|
||||
if (fileMode()) {
|
||||
if (!s_cache_mutex) return nullptr;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
FileCache* cache = findCacheLocked(_path);
|
||||
Kv* found = nullptr;
|
||||
if (cache) {
|
||||
for (auto& e : cache->kv)
|
||||
if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; }
|
||||
}
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return found;
|
||||
}
|
||||
for (auto& e : _sd) if (strncmp(e.key, key, sizeof(e.key)) == 0) return &e;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) {
|
||||
bool SdNvsPrefs::sdSet(const char* key, const uint8_t* data, size_t len) {
|
||||
if (_read_only || !key || key[0] == '\0' || strlen(key) > 15 || len > 2048 ||
|
||||
(len > 0 && !data)) return false;
|
||||
if (fileMode()) {
|
||||
if (!s_cache_mutex) return false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
FileCache* cache = findCacheLocked(_path);
|
||||
if (!cache || !cache->loaded) { xSemaphoreGive(s_cache_mutex); return false; }
|
||||
Kv* e = nullptr;
|
||||
for (auto& item : cache->kv)
|
||||
if (strncmp(item.key, key, sizeof(item.key)) == 0) { e = &item; break; }
|
||||
if (!e) {
|
||||
if (cache->kv.size() >= 256) { xSemaphoreGive(s_cache_mutex); return false; }
|
||||
cache->kv.push_back(Kv{});
|
||||
e = &cache->kv.back();
|
||||
strncpy(e->key, key, sizeof(e->key) - 1);
|
||||
}
|
||||
if (len == 0) e->val.clear(); else e->val.assign(data, data + len);
|
||||
const uint32_t now = millis();
|
||||
if (!cache->dirty) cache->dirty_first_ms = now;
|
||||
cache->dirty = true;
|
||||
cache->dirty_last_ms = now;
|
||||
cache->retry_after_ms = 0;
|
||||
++cache->revision;
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return true;
|
||||
}
|
||||
Kv* e = sdFind(key);
|
||||
bool added = false;
|
||||
std::vector<uint8_t> old;
|
||||
if (!e) {
|
||||
_sd.push_back(Kv{});
|
||||
e = &_sd.back();
|
||||
added = true;
|
||||
strncpy(e->key, key, sizeof(e->key) - 1);
|
||||
e->key[sizeof(e->key) - 1] = '\0';
|
||||
} else {
|
||||
old = e->val;
|
||||
}
|
||||
e->val.assign(data, data + len);
|
||||
sdSave();
|
||||
if (len == 0) e->val.clear();
|
||||
else e->val.assign(data, data + len);
|
||||
if (sdSave()) return true;
|
||||
if (added) _sd.pop_back();
|
||||
else e->val = std::move(old);
|
||||
return false;
|
||||
}
|
||||
|
||||
void SdNvsPrefs::sdLoad() {
|
||||
_sd.clear();
|
||||
bool SdNvsPrefs::sdLoad() {
|
||||
fs::FS* fs = activeFs();
|
||||
if (!fs) return;
|
||||
File f = fs->open(_path, FILE_READ);
|
||||
if (!f) return;
|
||||
// record = [keylen u8][key bytes][vallen u16 LE][val bytes]. Sanity caps stop a
|
||||
// corrupt file from a huge alloc; on any bad record we stop and keep what loaded.
|
||||
while (f.available() > 0 && _sd.size() < 256) {
|
||||
int kl = f.read();
|
||||
if (kl <= 0 || kl > 15) break;
|
||||
char k[16] = {0};
|
||||
if (f.read((uint8_t*)k, kl) != kl) break;
|
||||
int lo = f.read(), hi = f.read();
|
||||
if (lo < 0 || hi < 0) break;
|
||||
size_t vl = (size_t)lo | ((size_t)hi << 8);
|
||||
if (vl > 2048) break;
|
||||
std::vector<uint8_t> v(vl);
|
||||
if (vl && f.read(v.data(), vl) != (int)vl) break;
|
||||
Kv e; strncpy(e.key, k, sizeof(e.key) - 1); e.key[sizeof(e.key) - 1] = '\0';
|
||||
e.val = std::move(v);
|
||||
_sd.push_back(std::move(e));
|
||||
if (!fs) return false;
|
||||
if (!fileMode()) {
|
||||
// Legacy fallback before useFile(): retain the old single-file behaviour.
|
||||
std::vector<Kv> loaded;
|
||||
if (!fs->exists(_path)) { _sd.clear(); return true; }
|
||||
if (!readLegacyFile(fs, _path, loaded)) return false;
|
||||
_sd = std::move(loaded);
|
||||
return true;
|
||||
}
|
||||
f.close();
|
||||
|
||||
if (!ensureWorker()) return false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
if (FileCache* existing = findCacheLocked(_path)) {
|
||||
const bool ok = existing->loaded;
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return ok;
|
||||
}
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
|
||||
char path_a[44], path_b[44];
|
||||
if (!slotPath(_path, 'a', path_a, sizeof(path_a)) ||
|
||||
!slotPath(_path, 'b', path_b, sizeof(path_b))) return false;
|
||||
std::vector<Kv> a, b, loaded;
|
||||
uint32_t gen_a = 0, gen_b = 0;
|
||||
const bool ok_a = readSnapshot(fs, path_a, a, gen_a);
|
||||
const bool ok_b = readSnapshot(fs, path_b, b, gen_b);
|
||||
uint32_t generation = 0;
|
||||
char active = 0;
|
||||
bool legacy_present = false;
|
||||
bool migrate = false;
|
||||
if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) {
|
||||
loaded = std::move(a); generation = gen_a; active = 'a';
|
||||
} else if (ok_b) {
|
||||
loaded = std::move(b); generation = gen_b; active = 'b';
|
||||
} else {
|
||||
// Migrate the released single-file format. Parsing is all-or-nothing;
|
||||
// defaults never replace a corrupt namespace.
|
||||
if (readLegacyFile(fs, _path, loaded)) {
|
||||
legacy_present = true;
|
||||
migrate = true;
|
||||
} else if (fs->exists(_path) || fs->exists(path_a) || fs->exists(path_b)) {
|
||||
Serial.printf("[PREFS] no valid snapshot for %s\n", _path);
|
||||
return false;
|
||||
}
|
||||
// No file at all is a valid fresh namespace.
|
||||
}
|
||||
|
||||
FileCache cache;
|
||||
cache.fs = fs;
|
||||
strncpy(cache.legacy_path, _path, sizeof(cache.legacy_path) - 1);
|
||||
cache.kv = std::move(loaded);
|
||||
cache.generation = generation;
|
||||
cache.active_slot = active;
|
||||
cache.loaded = true;
|
||||
cache.legacy_present = legacy_present;
|
||||
if (migrate) {
|
||||
cache.dirty = true;
|
||||
cache.dirty_first_ms = cache.dirty_last_ms = millis();
|
||||
++cache.revision;
|
||||
}
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
s_caches.push_back(std::move(cache));
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SdNvsPrefs::sdSave() {
|
||||
if (_read_only) return;
|
||||
bool SdNvsPrefs::sdSave() {
|
||||
if (_read_only) return false;
|
||||
if (fileMode()) return true; // sdSet/remove/clear already dirtied the shared cache
|
||||
fs::FS* fs = activeFs();
|
||||
if (!fs) return;
|
||||
// Create the parent dir (SD needs it; SPIFFS is flat and treats the whole path
|
||||
// as a filename, so mkdir is a harmless no-op there).
|
||||
char dir[40];
|
||||
strncpy(dir, _path, sizeof(dir) - 1); dir[sizeof(dir) - 1] = '\0';
|
||||
char* slash = strrchr(dir, '/');
|
||||
if (slash && slash != dir) { *slash = '\0'; fs->mkdir(dir); }
|
||||
File f = fs->open(_path, FILE_WRITE); // truncate + rewrite the whole file
|
||||
if (!f) return;
|
||||
for (auto& e : _sd) {
|
||||
size_t kl = strnlen(e.key, sizeof(e.key));
|
||||
size_t vl = e.val.size();
|
||||
f.write((uint8_t)kl);
|
||||
f.write((const uint8_t*)e.key, kl);
|
||||
f.write((uint8_t)(vl & 0xFF));
|
||||
f.write((uint8_t)((vl >> 8) & 0xFF));
|
||||
if (vl) f.write(e.val.data(), vl);
|
||||
}
|
||||
if (!fs) return false;
|
||||
std::vector<uint8_t> payload;
|
||||
if (!serializeKv(_sd, payload)) return false;
|
||||
File f = fs->open(_path, FILE_WRITE);
|
||||
if (!f) return false;
|
||||
const bool ok = payload.empty() || f.write(payload.data(), payload.size()) == payload.size();
|
||||
f.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool scheduleOne(uint32_t now, bool force) {
|
||||
if (!fileMode() || !ensureWorker()) return false;
|
||||
WriteJob* job = nullptr;
|
||||
char armed_path[40] = {0};
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
for (auto& cache : s_caches) {
|
||||
if (!cache.loaded || !cache.dirty || cache.writing) continue;
|
||||
if (!force && cache.retry_after_ms && (int32_t)(now - cache.retry_after_ms) < 0) continue;
|
||||
const bool due = force || (uint32_t)(now - cache.dirty_last_ms) >= PREFS_QUIET_MS ||
|
||||
(uint32_t)(now - cache.dirty_first_ms) >= PREFS_MAX_WAIT_MS;
|
||||
if (!due) continue;
|
||||
job = new (std::nothrow) WriteJob();
|
||||
if (!job) break;
|
||||
job->fs = cache.fs;
|
||||
strncpy(job->legacy_path, cache.legacy_path, sizeof(job->legacy_path) - 1);
|
||||
job->kv = cache.kv;
|
||||
job->generation = cache.generation + 1;
|
||||
job->revision = cache.revision;
|
||||
job->slot = cache.active_slot == 'a' ? 'b'
|
||||
: cache.active_slot == 'b' ? 'a'
|
||||
: cache.legacy_present ? 'b' : 'a';
|
||||
cache.writing = true;
|
||||
strncpy(armed_path, cache.legacy_path, sizeof(armed_path) - 1);
|
||||
break;
|
||||
}
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
if (!job) return false;
|
||||
if (xQueueSend(s_write_queue, &job, 0) != pdTRUE) {
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
if (FileCache* cache = findCacheLocked(armed_path)) cache->writing = false;
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
delete job;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SdNvsPrefs::tick(uint32_t now_ms) {
|
||||
if (!fileMode()) return;
|
||||
if (now_ms == 0) now_ms = millis();
|
||||
scheduleOne(now_ms, false);
|
||||
}
|
||||
|
||||
bool SdNvsPrefs::flush(uint32_t timeout_ms) {
|
||||
if (!fileMode()) return true;
|
||||
const uint32_t start = millis();
|
||||
for (;;) {
|
||||
scheduleOne(millis(), true);
|
||||
bool pending = false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
for (const auto& cache : s_caches)
|
||||
if (cache.dirty || cache.writing) { pending = true; break; }
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
if (!pending) return true;
|
||||
if ((uint32_t)(millis() - start) >= timeout_ms) return false;
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
bool SdNvsPrefs::busy() {
|
||||
if (!fileMode() || !s_cache_mutex) return false;
|
||||
bool result = false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
for (const auto& cache : s_caches)
|
||||
if (cache.writing) { result = true; break; }
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool explicitNamespacePath(const char* dir, const char* ns, char* out, size_t cap) {
|
||||
if (!dir || !ns || !out) return false;
|
||||
return snprintf(out, cap, "%s/%s.kv", dir, ns) < (int)cap;
|
||||
}
|
||||
|
||||
static bool loadExplicitNamespace(fs::FS* fs, const char* legacy_path,
|
||||
std::vector<SdNvsPrefs::Kv>& kv,
|
||||
uint32_t& generation, char& active) {
|
||||
char path_a[44], path_b[44];
|
||||
if (!slotPath(legacy_path, 'a', path_a, sizeof(path_a)) ||
|
||||
!slotPath(legacy_path, 'b', path_b, sizeof(path_b))) return false;
|
||||
std::vector<SdNvsPrefs::Kv> a, b;
|
||||
uint32_t gen_a = 0, gen_b = 0;
|
||||
const bool ok_a = readSnapshot(fs, path_a, a, gen_a);
|
||||
const bool ok_b = readSnapshot(fs, path_b, b, gen_b);
|
||||
if (ok_a && (!ok_b || newerGeneration(gen_a, gen_b))) {
|
||||
kv = std::move(a); generation = gen_a; active = 'a'; return true;
|
||||
}
|
||||
if (ok_b) { kv = std::move(b); generation = gen_b; active = 'b'; return true; }
|
||||
generation = 0;
|
||||
active = 0;
|
||||
return readLegacyFile(fs, legacy_path, kv);
|
||||
}
|
||||
|
||||
bool SdNvsPrefs::readFileBool(fs::FS* fs, const char* dir, const char* ns,
|
||||
const char* key, bool& value) {
|
||||
char legacy_path[40];
|
||||
if (!fs || !key || !explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false;
|
||||
std::vector<Kv> kv;
|
||||
uint32_t generation = 0;
|
||||
char active = 0;
|
||||
if (!loadExplicitNamespace(fs, legacy_path, kv, generation, active)) return false;
|
||||
for (const auto& e : kv) {
|
||||
if (strncmp(e.key, key, sizeof(e.key)) != 0) continue;
|
||||
if (e.val.empty()) return false;
|
||||
value = e.val[0] != 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SdNvsPrefs::writeFileBool(fs::FS* fs, const char* dir, const char* ns,
|
||||
const char* key, bool value) {
|
||||
char legacy_path[40];
|
||||
if (!fs || !key || key[0] == '\0' || strlen(key) > 15 ||
|
||||
!explicitNamespacePath(dir, ns, legacy_path, sizeof(legacy_path))) return false;
|
||||
std::vector<Kv> kv;
|
||||
uint32_t generation = 0;
|
||||
char active = 0;
|
||||
const bool existed = loadExplicitNamespace(fs, legacy_path, kv, generation, active);
|
||||
Kv* found = nullptr;
|
||||
for (auto& e : kv)
|
||||
if (strncmp(e.key, key, sizeof(e.key)) == 0) { found = &e; break; }
|
||||
if (!found) {
|
||||
kv.push_back(Kv{});
|
||||
found = &kv.back();
|
||||
strncpy(found->key, key, sizeof(found->key) - 1);
|
||||
}
|
||||
found->val.assign(1, value ? 1 : 0);
|
||||
const char slot = active == 'a' ? 'b' : active == 'b' ? 'a' : existed ? 'b' : 'a';
|
||||
if (!writeSnapshot(fs, legacy_path, slot, generation + 1, kv)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) {
|
||||
@@ -162,8 +621,7 @@ uint64_t SdNvsPrefs::sdGetInt(const char* key, uint64_t def, int width) {
|
||||
size_t SdNvsPrefs::sdPutInt(const char* key, uint64_t v, int width) {
|
||||
uint8_t b[8];
|
||||
for (int i = 0; i < width; i++) b[i] = (uint8_t)(v >> (8 * i));
|
||||
sdSet(key, b, width);
|
||||
return width;
|
||||
return sdSet(key, b, width) ? width : 0;
|
||||
}
|
||||
|
||||
// ----------------------------- API -----------------------------
|
||||
@@ -178,15 +636,51 @@ bool SdNvsPrefs::isKey(const char* key) {
|
||||
|
||||
bool SdNvsPrefs::remove(const char* key) {
|
||||
if (!fileMode() && useNvs()) return _nvs_open && _nvs.remove(key);
|
||||
for (size_t i = 0; i < _sd.size(); i++)
|
||||
if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) == 0) { _sd.erase(_sd.begin() + i); sdSave(); return true; }
|
||||
if (fileMode()) {
|
||||
if (_read_only || !s_cache_mutex) return false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
FileCache* cache = findCacheLocked(_path);
|
||||
if (!cache) { xSemaphoreGive(s_cache_mutex); return false; }
|
||||
for (size_t i = 0; i < cache->kv.size(); ++i) {
|
||||
if (strncmp(cache->kv[i].key, key, sizeof(cache->kv[i].key)) != 0) continue;
|
||||
cache->kv.erase(cache->kv.begin() + i);
|
||||
const uint32_t now = millis();
|
||||
if (!cache->dirty) cache->dirty_first_ms = now;
|
||||
cache->dirty = true;
|
||||
cache->dirty_last_ms = now;
|
||||
cache->retry_after_ms = 0;
|
||||
++cache->revision;
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return true;
|
||||
}
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < _sd.size(); i++) {
|
||||
if (strncmp(_sd[i].key, key, sizeof(_sd[i].key)) != 0) continue;
|
||||
Kv old = _sd[i];
|
||||
_sd.erase(_sd.begin() + i);
|
||||
if (sdSave()) return true;
|
||||
_sd.insert(_sd.begin() + i, std::move(old));
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SdNvsPrefs::clear() {
|
||||
if (fileMode()) {
|
||||
_sd.clear();
|
||||
sdSave();
|
||||
if (_read_only || !s_cache_mutex) return false;
|
||||
xSemaphoreTake(s_cache_mutex, portMAX_DELAY);
|
||||
FileCache* cache = findCacheLocked(_path);
|
||||
if (!cache) { xSemaphoreGive(s_cache_mutex); return false; }
|
||||
cache->kv.clear();
|
||||
const uint32_t now = millis();
|
||||
if (!cache->dirty) cache->dirty_first_ms = now;
|
||||
cache->dirty = true;
|
||||
cache->dirty_last_ms = now;
|
||||
cache->retry_after_ms = 0;
|
||||
++cache->revision;
|
||||
xSemaphoreGive(s_cache_mutex);
|
||||
// Wipe any legacy NVS copy too, so a factory reset is permanent. Derive the
|
||||
// namespace from "<dir>/<ns>.kv".
|
||||
if (nvsHasLegacyData()) {
|
||||
@@ -201,7 +695,11 @@ bool SdNvsPrefs::clear() {
|
||||
return true;
|
||||
}
|
||||
if (useNvs()) return _nvs_open && _nvs.clear();
|
||||
_sd.clear(); sdSave(); return true;
|
||||
std::vector<Kv> old = _sd;
|
||||
_sd.clear();
|
||||
if (sdSave()) return true;
|
||||
_sd = std::move(old);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t SdNvsPrefs::getUChar(const char* k, uint8_t def) {
|
||||
@@ -296,9 +794,9 @@ size_t SdNvsPrefs::getString(const char* k, char* buf, size_t maxLen) {
|
||||
return n;
|
||||
}
|
||||
size_t SdNvsPrefs::putString(const char* k, const char* v) {
|
||||
if (fileMode()) { size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n; }
|
||||
if (fileMode()) { size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0; }
|
||||
if (useNvs()) return _nvs.putString(k, v);
|
||||
size_t n = v ? strlen(v) : 0; sdSet(k, (const uint8_t*)v, n); return n;
|
||||
size_t n = v ? strlen(v) : 0; return sdSet(k, (const uint8_t*)v, n) ? n : 0;
|
||||
}
|
||||
|
||||
size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) {
|
||||
@@ -316,9 +814,9 @@ size_t SdNvsPrefs::getBytes(const char* k, void* buf, size_t maxLen) {
|
||||
return n;
|
||||
}
|
||||
size_t SdNvsPrefs::putBytes(const char* k, const void* buf, size_t len) {
|
||||
if (fileMode()) { sdSet(k, (const uint8_t*)buf, len); return len; }
|
||||
if (fileMode()) return sdSet(k, (const uint8_t*)buf, len) ? len : 0;
|
||||
if (useNvs()) return _nvs.putBytes(k, buf, len);
|
||||
sdSet(k, (const uint8_t*)buf, len); return len;
|
||||
return sdSet(k, (const uint8_t*)buf, len) ? len : 0;
|
||||
}
|
||||
|
||||
#endif // ESP32
|
||||
@@ -11,12 +11,12 @@ namespace fs { class FS; } // forward decl only — keep this header's layout
|
||||
// keep the touch app's settings OFF NVS.
|
||||
//
|
||||
// File mode (set by SdNvsPrefs::useFile() once at boot, after the SD/SPIFFS
|
||||
// storage decision): every namespace lives in a flat <dir>/<ns>.kv file on the
|
||||
// chosen filesystem — SD (/meshcomod) when a card is the active data store, else
|
||||
// SPIFFS (/prefs). Nothing new is written to NVS; NVS is read-only, used only to
|
||||
// migrate settings written by an older NVS build (they move to the file on their
|
||||
// next save). Legacy mode (before useFile, e.g. the early boot-rotation read):
|
||||
// NVS if it works, else /meshcomod/<ns>.kv on SD — the previous behaviour.
|
||||
// storage decision): every namespace is held in RAM and committed to alternating
|
||||
// <dir>/<ns>.kv / .alt snapshots. Setters only dirty the RAM copy; a worker writes
|
||||
// the inactive slot after a short quiet period, keeping filesystem latency off
|
||||
// the UI task. Each snapshot has a generation and CRC, so a reset can only lose
|
||||
// the newest pending update — it cannot turn an interrupted write into defaults.
|
||||
// Legacy <dir>/<ns>.kv and NVS data are read for migration.
|
||||
//
|
||||
// IMPORTANT: the monorepo lib ships a STALE copy of this header that other
|
||||
// translation units may pick up via an angle include. To avoid an ODR/layout
|
||||
@@ -25,11 +25,20 @@ namespace fs { class FS; } // forward decl only — keep this header's layout
|
||||
// The file backend therefore uses file-static state in the .cpp, not a member.
|
||||
// Only the layout-neutral static useFile() is new.
|
||||
//
|
||||
// On-disk file format is unchanged: [keylen u8][key][vallen u16 LE][val].
|
||||
class SdNvsPrefs {
|
||||
public:
|
||||
static void useFile(fs::FS* fs, const char* dir); // route prefs to <dir>/<ns>.kv
|
||||
static fs::FS* fileFs(); // the active file-mode fs, or nullptr when NVS-backed
|
||||
static void tick(uint32_t now_ms = 0); // schedule due snapshots; never blocks on I/O
|
||||
static bool flush(uint32_t timeout_ms = 12000); // force all queued snapshots before reset/sleep
|
||||
static bool busy(); // a worker currently owns a filesystem handle
|
||||
|
||||
// Early boot needs `use_sd` before the regular backend is selected. These
|
||||
// helpers use the same A/B format on an explicitly supplied filesystem.
|
||||
static bool readFileBool(fs::FS* fs, const char* dir, const char* ns,
|
||||
const char* key, bool& value);
|
||||
static bool writeFileBool(fs::FS* fs, const char* dir, const char* ns,
|
||||
const char* key, bool value);
|
||||
|
||||
bool begin(const char* ns, bool readOnly = false);
|
||||
void end();
|
||||
@@ -57,6 +66,10 @@ public:
|
||||
size_t getBytes(const char* key, void* buf, size_t maxLen);
|
||||
size_t putBytes(const char* key, const void* buf, size_t len);
|
||||
|
||||
// Public only so the file-static snapshot worker can preserve this class's
|
||||
// ABI while sharing namespace caches across short-lived instances.
|
||||
struct Kv { char key[16]; std::vector<uint8_t> val; };
|
||||
|
||||
private:
|
||||
// --- NVS-backed path --- (LAYOUT MUST MATCH the lib's stale header — see above)
|
||||
Preferences _nvs;
|
||||
@@ -64,16 +77,15 @@ private:
|
||||
bool _read_only = false;
|
||||
|
||||
// --- file-backed path ---
|
||||
struct Kv { char key[16]; std::vector<uint8_t> val; };
|
||||
std::vector<Kv> _sd; // in-RAM mirror of <dir>/<ns>.kv
|
||||
char _path[40] = {0};
|
||||
bool _sd_loaded = false;
|
||||
|
||||
bool useNvs(); // legacy probe (NVS vs SD), cached globally
|
||||
Kv* sdFind(const char* key);
|
||||
void sdSet(const char* key, const uint8_t* data, size_t len);
|
||||
void sdLoad();
|
||||
void sdSave();
|
||||
bool sdSet(const char* key, const uint8_t* data, size_t len);
|
||||
bool sdLoad();
|
||||
bool sdSave(); // queue current RAM mirror (no filesystem I/O)
|
||||
uint64_t sdGetInt(const char* key, uint64_t def, int width);
|
||||
size_t sdPutInt(const char* key, uint64_t v, int width);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <SPIFFS.h>
|
||||
#include <stddef.h> // offsetof
|
||||
#include <string.h> // memcpy
|
||||
#include <vector> // writeUseSdToSpiffsKv record list
|
||||
|
||||
static const char* TOUCH_NS = "touch";
|
||||
|
||||
@@ -30,15 +29,14 @@ static bool s_begun = false;
|
||||
// keys too. "use_sd" is mirrored to NVS on every UI toggle (main.cpp reads it at
|
||||
// boot via touchPrefsReadUseSdAtBoot); "setup_ok" is still NVS-only.
|
||||
//
|
||||
// On first run with the blob absent we read every legacy per-key into s_cfg, write
|
||||
// "cfg" ONCE, and only after that durable write do we remove() the legacy keys to
|
||||
// reclaim their entries. The write-before-remove ordering makes the migration
|
||||
// crash-safe and idempotent (a power-cut before the write just re-migrates next
|
||||
// boot; one after it finds "cfg" present and skips). `magic` rejects a garbage /
|
||||
// On first run with the blob absent we read every legacy per-key into s_cfg, add
|
||||
// "cfg", and remove the superseded file keys in the same queued RAM snapshot.
|
||||
// The released .kv remains the fallback until that complete A/B snapshot verifies,
|
||||
// making the migration crash-safe and idempotent. `magic` rejects a garbage /
|
||||
// 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 = 0x5743; // 'WC' (WadaCfg)
|
||||
static const uint8_t TOUCH_CFG_VER = 42; // 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)
|
||||
static const uint8_t TOUCH_CFG_VER = 43; // 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)
|
||||
|
||||
// Defaults (kept identical to the historical per-key defaults).
|
||||
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
|
||||
@@ -83,7 +81,7 @@ struct __attribute__((packed)) TouchCfg {
|
||||
uint8_t map_show_tilexyz; // show the zoom + tile z/x/y line on the map (bool) — v6
|
||||
uint8_t map_show_contacts;// show contact markers on the map (bool) — v6
|
||||
uint8_t app_grid_large; // app drawer: large grid (one fewer column, bigger icons) — v7
|
||||
uint8_t ui_scale; // UI resolution scale: 0=100% 1=150% 2=200% (Tanmatsu, applied at boot) — v8
|
||||
uint8_t ui_scale; // UI-size preset 0..2 (board-specific font/geometry mapping, applied at boot) — v8
|
||||
uint8_t kbd_nav; // T-Deck keyboard ESDFX nav: 0=off (default), 1=on (E/X/S/F move focus, D select, Q back) — v9 (was tb_keypad)
|
||||
uint8_t sleep_idle; // idle light-sleep feature on/off (bool) — v10 (trailing so existing blobs default it OFF)
|
||||
uint8_t nav_keys[5]; // keyboard-nav tab hotkeys (ASCII), one per main tab [chat,contacts,home,map,settings] — v11 (trailing)
|
||||
@@ -172,7 +170,11 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
c.map_show_tilexyz = 1;
|
||||
c.map_show_contacts = 1;
|
||||
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
|
||||
c.ui_scale = 1; // default: 150% "Large" UI scale (Tanmatsu; S3 boards ignore this)
|
||||
#if defined(TLORA_PAGER)
|
||||
c.ui_scale = 0; // Pager: Small/current typography by default
|
||||
#else
|
||||
c.ui_scale = 1; // large-screen boards keep their existing 150% default
|
||||
#endif
|
||||
#if defined(HAS_TANMATSU)
|
||||
c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on
|
||||
#else
|
||||
@@ -215,8 +217,8 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
c.hist_sync_after = 2; // chat flush: 2 failed background writes -> synchronous loop-task fallback
|
||||
}
|
||||
|
||||
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
|
||||
// discipline every setter in this file uses. Returns true on a durable write.
|
||||
// Update the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
|
||||
// discipline every setter in this file uses. File mode queues a coalesced write.
|
||||
static bool cfgFlush() {
|
||||
s_prefs.end();
|
||||
if (!s_prefs.begin(TOUCH_NS, false)) { s_begun = false; return false; }
|
||||
@@ -284,6 +286,12 @@ static void cfgLoadOrMigrate() {
|
||||
if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle)
|
||||
if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in)
|
||||
if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default
|
||||
#if defined(TLORA_PAGER)
|
||||
// ui_scale existed before the Pager exposed the selector, so every old
|
||||
// Pager inherited the unrelated large-screen default (1) while ignoring
|
||||
// it. Reset it once so upgrading cannot enlarge the UI without consent.
|
||||
if (s_cfg.ver < 43) s_cfg.ui_scale = 0;
|
||||
#endif
|
||||
s_cfg.ver = TOUCH_CFG_VER;
|
||||
s_cfg.magic = TOUCH_CFG_MAGIC;
|
||||
cfgFlush(); // rewrite with new fields defaulted-in
|
||||
@@ -327,9 +335,8 @@ static void cfgLoadOrMigrate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Write "cfg" ONCE. Only after a durable write do we reclaim the legacy keys.
|
||||
// If the write fails (e.g. NVS full / SD missing) we keep the legacy keys
|
||||
// intact and retry the whole migration on the next boot.
|
||||
// Add "cfg" and retire the old file keys in one RAM transaction. The A/B
|
||||
// worker commits the complete result; until then the released .kv is intact.
|
||||
if (cfgFlush()) {
|
||||
s_prefs.end();
|
||||
if (s_prefs.begin(TOUCH_NS, false)) {
|
||||
@@ -377,6 +384,10 @@ void touchPrefsReload() {
|
||||
touchPrefsBegin();
|
||||
}
|
||||
|
||||
void touchPrefsTick(uint32_t now_ms) { SdNvsPrefs::tick(now_ms); }
|
||||
bool touchPrefsFlush(uint32_t timeout_ms) { return SdNvsPrefs::flush(timeout_ms); }
|
||||
bool touchPrefsIoBusy() { return SdNvsPrefs::busy(); }
|
||||
|
||||
// Arduino's Preferences::getString()/getBytes() emit an [E] nvs_get_* "NOT_FOUND"
|
||||
// log every time a key is absent — which floods the (USB-CDC) console on a fresh
|
||||
// device and on every empty Wi-Fi-slot read. isKey() (getType → raw nvs probes)
|
||||
@@ -1052,7 +1063,7 @@ bool touchPrefsSetAppGridLarge(bool on) {
|
||||
|
||||
uint8_t touchPrefsGetUiScale() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale; // 0=100% 1=150% 2=200%
|
||||
return s_cfg.ui_scale > 2 ? 0 : s_cfg.ui_scale;
|
||||
}
|
||||
bool touchPrefsSetUiScale(uint8_t scale) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
@@ -1234,36 +1245,23 @@ bool touchPrefsSetHomeIsDrawer(bool on) {
|
||||
// the data loads, so changing it needs a reboot. Key "use_sd" in the "touch"
|
||||
// namespace — main.cpp must see the same value the UI toggle writes.
|
||||
static const char* KEY_USE_SD_STORAGE = "use_sd";
|
||||
static const char* TOUCH_KV_BOOT_PATH = "/prefs/touch.kv";
|
||||
|
||||
// SdNvsPrefs file mode writes touch.kv only — parse a bool for boot migration.
|
||||
static bool readBoolFromTouchKvFile(const char* want_key) {
|
||||
if (!SPIFFS.exists(TOUCH_KV_BOOT_PATH)) return false;
|
||||
File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ);
|
||||
if (!f) return false;
|
||||
while (f.available() > 0) {
|
||||
int kl = f.read();
|
||||
if (kl <= 0 || kl > 15) break;
|
||||
char k[16] = {0};
|
||||
if (f.read((uint8_t*)k, kl) != kl) break;
|
||||
int lo = f.read(), hi = f.read();
|
||||
if (lo < 0 || hi < 0) break;
|
||||
size_t vl = (size_t)lo | ((size_t)hi << 8);
|
||||
if (vl > 2048) break;
|
||||
if (strncmp(k, want_key, sizeof k) == 0) {
|
||||
const bool on = (vl >= 1) && (f.read() != 0);
|
||||
f.close();
|
||||
return on;
|
||||
}
|
||||
for (size_t i = 0; i < vl; ++i) {
|
||||
if (f.read() < 0) { f.close(); return false; }
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
static const char* BOOT_PREFS_NS = "bootprefs";
|
||||
|
||||
bool touchPrefsReadUseSdAtBoot() {
|
||||
bool file_val = false;
|
||||
bool found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS,
|
||||
KEY_USE_SD_STORAGE, file_val);
|
||||
// Once present, the explicit boot namespace is authoritative. This lets a
|
||||
// Launcher device recover even if its best-effort NVS mirror is stale.
|
||||
if (found) {
|
||||
Preferences mirror;
|
||||
if (mirror.begin(TOUCH_NS, false)) {
|
||||
mirror.putBool(KEY_USE_SD_STORAGE, file_val);
|
||||
mirror.end();
|
||||
}
|
||||
return file_val;
|
||||
}
|
||||
|
||||
bool nvs_val = false;
|
||||
Preferences p;
|
||||
if (p.begin(TOUCH_NS, true)) {
|
||||
@@ -1271,15 +1269,17 @@ bool touchPrefsReadUseSdAtBoot() {
|
||||
p.end();
|
||||
}
|
||||
if (nvs_val) return true;
|
||||
const bool file_val = readBoolFromTouchKvFile(KEY_USE_SD_STORAGE);
|
||||
if (file_val) {
|
||||
Serial.println("[BOOT] use_sd read from /prefs/touch.kv (syncing to NVS)");
|
||||
// One-time migration for builds that stored the boot flag inside touch.kv.
|
||||
found = SdNvsPrefs::readFileBool((fs::FS*)&SPIFFS, "/prefs", TOUCH_NS,
|
||||
KEY_USE_SD_STORAGE, file_val);
|
||||
if (found && file_val) {
|
||||
Serial.println("[BOOT] use_sd read from SPIFFS boot prefs (syncing to NVS)");
|
||||
if (p.begin(TOUCH_NS, false)) {
|
||||
p.putBool(KEY_USE_SD_STORAGE, true);
|
||||
p.end();
|
||||
}
|
||||
}
|
||||
return file_val;
|
||||
return found && file_val;
|
||||
}
|
||||
|
||||
bool touchPrefsGetUseSdStorage() {
|
||||
@@ -1287,55 +1287,6 @@ bool touchPrefsGetUseSdStorage() {
|
||||
return s_prefs.getBool(KEY_USE_SD_STORAGE, false); // default = SPIFFS
|
||||
}
|
||||
|
||||
// Rewrite (or create) the SPIFFS copy of touch.kv with use_sd set, keeping every
|
||||
// other record. Needed because the boot storage decision can only read SPIFFS
|
||||
// (SD isn't mounted yet, see touchPrefsReadUseSdAtBoot): when prefs actively
|
||||
// live on the SD card, the toggle must land in BOTH file copies — otherwise
|
||||
// installs with unusable NVS (Launcher) could turn SD storage on but never OFF
|
||||
// again (the stale SPIFFS true would win every boot). PR #123 follow-up.
|
||||
static void writeUseSdToSpiffsKv(bool on) {
|
||||
struct Rec { char k[16]; std::vector<uint8_t> v; };
|
||||
std::vector<Rec> recs;
|
||||
File f = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_READ);
|
||||
if (f) {
|
||||
while (f.available() > 0 && recs.size() < 256) {
|
||||
int kl = f.read();
|
||||
if (kl <= 0 || kl > 15) break;
|
||||
Rec r{};
|
||||
if (f.read((uint8_t*)r.k, kl) != kl) break;
|
||||
int lo = f.read(), hi = f.read();
|
||||
if (lo < 0 || hi < 0) break;
|
||||
size_t vl = (size_t)lo | ((size_t)hi << 8);
|
||||
if (vl > 2048) break;
|
||||
r.v.resize(vl);
|
||||
if (vl && f.read(r.v.data(), vl) != (int)vl) break;
|
||||
recs.push_back(std::move(r));
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
bool found = false;
|
||||
for (auto& r : recs) {
|
||||
if (strncmp(r.k, KEY_USE_SD_STORAGE, sizeof r.k) == 0) { r.v.assign(1, on ? 1 : 0); found = true; }
|
||||
}
|
||||
if (!found) {
|
||||
Rec r{};
|
||||
strncpy(r.k, KEY_USE_SD_STORAGE, sizeof r.k - 1);
|
||||
r.v.assign(1, on ? 1 : 0);
|
||||
recs.push_back(std::move(r));
|
||||
}
|
||||
File w = SPIFFS.open(TOUCH_KV_BOOT_PATH, FILE_WRITE); // truncate + rewrite
|
||||
if (!w) return;
|
||||
for (auto& r : recs) {
|
||||
size_t kl = strnlen(r.k, sizeof r.k), vl = r.v.size();
|
||||
w.write((uint8_t)kl);
|
||||
w.write((const uint8_t*)r.k, kl);
|
||||
w.write((uint8_t)(vl & 0xFF));
|
||||
w.write((uint8_t)((vl >> 8) & 0xFF));
|
||||
if (vl) w.write(r.v.data(), vl);
|
||||
}
|
||||
w.close();
|
||||
}
|
||||
|
||||
bool touchPrefsSetUseSdStorage(bool use_sd) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_prefs.end();
|
||||
@@ -1351,11 +1302,12 @@ bool touchPrefsSetUseSdStorage(bool use_sd) {
|
||||
nvs.putBool(KEY_USE_SD_STORAGE, use_sd);
|
||||
nvs.end();
|
||||
}
|
||||
// When prefs live on the SD card, boot's fallback still reads the SPIFFS
|
||||
// copy — keep it in sync so the toggle works in BOTH directions there.
|
||||
fs::FS* ffs = SdNvsPrefs::fileFs();
|
||||
if (ffs && ffs != (fs::FS*)&SPIFFS) writeUseSdToSpiffsKv(use_sd);
|
||||
return ok;
|
||||
// The boot storage decision happens before SD is mounted. Keep a tiny,
|
||||
// crash-safe SPIFFS A/B namespace as the Launcher-safe fallback regardless
|
||||
// of where the rest of the preferences currently live.
|
||||
const bool boot_ok = SdNvsPrefs::writeFileBool((fs::FS*)&SPIFFS, "/prefs", BOOT_PREFS_NS,
|
||||
KEY_USE_SD_STORAGE, use_sd);
|
||||
return ok && boot_ok;
|
||||
}
|
||||
|
||||
// UI language index (UiLang enum in i18n.h; 0 = English). Read at boot to pick
|
||||
|
||||
@@ -13,6 +13,11 @@ void touchPrefsBegin();
|
||||
// Force a fresh load of the settings blob. Call after SdNvsPrefs::useFile() at
|
||||
// boot: an earlier pref read may have cached the blob from the legacy backend.
|
||||
void touchPrefsReload();
|
||||
// Drive/force the deferred file-backed snapshot writer without exposing the
|
||||
// vendored core's stale SdNvsPrefs header to UI translation units.
|
||||
void touchPrefsTick(uint32_t now_ms);
|
||||
bool touchPrefsFlush(uint32_t timeout_ms = 12000);
|
||||
bool touchPrefsIoBusy();
|
||||
|
||||
/** Screen timeout in seconds; 0 = never sleep. Default 20. */
|
||||
uint16_t touchPrefsGetScreenTimeoutSecs();
|
||||
@@ -146,8 +151,8 @@ bool touchPrefsSetAppGridLarge(bool on);
|
||||
bool touchPrefsGetSleepIdle();
|
||||
bool touchPrefsSetSleepIdle(bool on);
|
||||
|
||||
/* UI resolution scale (Tanmatsu): 0=100% (native 800x480), 1=150%, 2=200%. Applied at boot —
|
||||
* LVGL renders at a lower resolution and the flush upscales to the panel. Reboot to apply. */
|
||||
/* UI-size preset, 0..2. Large-screen boards retain their percentage mapping;
|
||||
* the T-Pager exposes Small/Medium/Large font presets. Reboot to apply. */
|
||||
uint8_t touchPrefsGetUiScale();
|
||||
bool touchPrefsSetUiScale(uint8_t scale);
|
||||
|
||||
|
||||
@@ -84,12 +84,22 @@ void heltecV4CapTouchSetRotation(uint8_t lvgl_rot) { s_ui_rotation = lvgl_rot &
|
||||
// identity. (Verified on hardware — without the flip, taps landed at the
|
||||
// diagonally-opposite point.)
|
||||
static void rotateSwipeDelta(int dx, int dy, int* odx, int* ody) {
|
||||
#if defined(HELTEC_LORA_V4_R8)
|
||||
// R8: no +180 panel baseline (see applyPointRotation) -- plain rotations, V4 cases swapped.
|
||||
switch (s_ui_rotation) {
|
||||
case 1: *odx = dy; *ody = -dx; break; // ROT_90 (baseline-0 panel)
|
||||
case 2: *odx = -dx; *ody = -dy; break; // 180
|
||||
case 3: *odx = -dy; *ody = dx; break; // ROT_270
|
||||
default: *odx = dx; *ody = dy; break; // portrait
|
||||
}
|
||||
#else
|
||||
switch (s_ui_rotation) {
|
||||
case 1: *odx = -dy; *ody = dx; break; // ROT_90 (panel-rot-2 baseline)
|
||||
case 2: *odx = -dx; *ody = -dy; break; // 180
|
||||
case 3: *odx = dy; *ody = -dx; break; // ROT_270
|
||||
default: *odx = dx; *ody = dy; break; // portrait
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Hardware-rotation touch-point transform. When the panel is rotated in
|
||||
@@ -107,6 +117,20 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) {
|
||||
const int H = 320; // V4 panel portrait height
|
||||
const int px = *x, py = *y;
|
||||
int lx, ly;
|
||||
#if defined(HELTEC_LORA_V4_R8)
|
||||
// R8 (LovyanGFX panel, offset_rotation 0, portrait = setRotation(0)): the panel has NO +180
|
||||
// baseline, so the plain 90/270 maps apply -- exactly the V4's two landscape cases swapped.
|
||||
// The V4-tuned maps below landed every landscape touch point-mirrored on this board (the
|
||||
// stuck-in-landscape report). TESTER-VERIFY: confirmed geometry, unconfirmed sign -- if
|
||||
// landscape touch is still mirrored, swap case 1 and case 3 back. Boot forces portrait on
|
||||
// this board (UITask), so a wrong guess costs a reboot, not a stuck device.
|
||||
switch (s_point_rotation) {
|
||||
case 1: lx = py; ly = (W - 1) - px; break; // ROT_90 -> 320x240 (plain 90)
|
||||
case 2: lx = (W - 1) - px; ly = (H - 1) - py; break; // 180
|
||||
case 3: lx = (H - 1) - py; ly = px; break; // ROT_270 -> 320x240 (plain 270)
|
||||
default: lx = px; ly = py; break;
|
||||
}
|
||||
#else
|
||||
// Same +180 baseline as rotateSwipeDelta: ROT_90 uses the 270-degree map.
|
||||
switch (s_point_rotation) {
|
||||
case 1: lx = (H - 1) - py; ly = px; break; // ROT_90 -> 320x240
|
||||
@@ -114,6 +138,7 @@ static void applyPointRotation(uint16_t* x, uint16_t* y) {
|
||||
case 3: lx = py; ly = (W - 1) - px; break; // ROT_270 -> 320x240
|
||||
default: lx = px; ly = py; break;
|
||||
}
|
||||
#endif
|
||||
if (lx < 0) lx = 0;
|
||||
if (ly < 0) ly = 0;
|
||||
*x = (uint16_t)lx;
|
||||
|
||||
+32
-5
@@ -48,7 +48,7 @@ static uint32_t _atoi(const char* sp) {
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER)
|
||||
#include <SD.h>
|
||||
#include <Preferences.h>
|
||||
#ifndef PIN_SD_CS
|
||||
@@ -196,7 +196,7 @@ void halt() {
|
||||
|
||||
#include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56)
|
||||
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER)
|
||||
// ---- SPIFFS -> SD migration (fixes the beta_36 "lost my profile" upgrades) ----
|
||||
// Users who flipped "Store data on SD" before beta_36 ran with the toggle IGNORED
|
||||
// (the flag never survived a reboot), so their identity/prefs/contacts kept living
|
||||
@@ -466,10 +466,12 @@ void setup() {
|
||||
// failure falls back to SPIFFS so the device always boots.
|
||||
bool spiffs_ok = SPIFFS.begin(false); // try first WITHOUT auto-format
|
||||
bool sd_storage = false;
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER)
|
||||
{
|
||||
#if defined(HELTEC_LORA_V4_R8)
|
||||
extern SPIClass* heltecV4R8SharedSPI(); // FSPI, shared with the TFT (CS=3)
|
||||
#elif defined(TLORA_PAGER)
|
||||
extern SPIClass* tloraPagerSharedSPI(); // the display/radio's shared SPIClass (#193)
|
||||
#else
|
||||
extern SPIClass* tdeckSharedSPI(); // LoRa SPI bus
|
||||
#endif
|
||||
@@ -497,6 +499,8 @@ void setup() {
|
||||
|
||||
#if defined(HELTEC_LORA_V4_R8)
|
||||
SPIClass* _spi = heltecV4R8SharedSPI();
|
||||
#elif defined(TLORA_PAGER)
|
||||
SPIClass* _spi = tloraPagerSharedSPI();
|
||||
#else
|
||||
SPIClass* _spi = tdeckSharedSPI();
|
||||
#endif
|
||||
@@ -519,11 +523,34 @@ void setup() {
|
||||
{ 300, 1000000 }, { 450, 1000000 }, { 650, 400000 }, { 900, 400000 },
|
||||
};
|
||||
int tries = want_full_sd ? 7 : 3;
|
||||
uint32_t mounted_hz = 0;
|
||||
for (int a = 0; a < tries && !sd_mounted; ++a) {
|
||||
SD.end();
|
||||
delay(kBootMount[a].settle_ms);
|
||||
if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE)
|
||||
if (SD.begin(PIN_SD_CS, *_spi, kBootMount[a].hz, "/sd", 6) && SD.cardType() != CARD_NONE) {
|
||||
sd_mounted = true;
|
||||
mounted_hz = kBootMount[a].hz;
|
||||
}
|
||||
}
|
||||
// RENEGOTIATE UPWARD after a slow-rung success (GH #194). Standard SD bring-up is
|
||||
// "initialise at a conservative clock, then raise it" — but SD.begin's clock is the
|
||||
// operating clock for the whole session, so a card that only WOKE at 400 kHz then ran
|
||||
// its entire life at 400 kHz (~25 KB/s). With the card as the primary store that was a
|
||||
// ~3-minute boot (contacts + history at modem speed), runtime f_getfree timeouts (the
|
||||
// file manager showing an inserted card as 0 KB/empty), and wedged backups. An
|
||||
// initialised card almost always sustains 4 MHz even when its power-up handshake needed
|
||||
// 400 kHz; if the fast re-begin fails, fall back to the clock that just worked.
|
||||
if (sd_mounted && mounted_hz < 4000000) {
|
||||
SD.end();
|
||||
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);
|
||||
} else {
|
||||
SD.end();
|
||||
delay(120);
|
||||
sd_mounted = SD.begin(PIN_SD_CS, *_spi, mounted_hz, "/sd", 6) && SD.cardType() != CARD_NONE;
|
||||
if (sd_mounted) Serial.printf("[BOOT] SD stays at %lu Hz (4 MHz renegotiation failed)\n", (unsigned long)mounted_hz);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sd_mounted) {
|
||||
@@ -590,7 +617,7 @@ void setup() {
|
||||
// Route touch settings + Wi-Fi creds to the active filesystem (SD when that's
|
||||
// the data store, else SPIFFS) instead of NVS. Old NVS values still load and
|
||||
// migrate on their next save, so this is a transparent in-place upgrade.
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
|
||||
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8) || defined(TLORA_PAGER)
|
||||
SdNvsPrefs::useFile(sd_storage ? (fs::FS*)&SD : (fs::FS*)&SPIFFS,
|
||||
sd_storage ? "/meshcomod" : "/prefs");
|
||||
#else
|
||||
|
||||
@@ -370,6 +370,39 @@ static const lv_btnmatrix_ctrl_t kb_it_upper_ctrl[] = {
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
|
||||
};
|
||||
|
||||
/* Romanian keeps QWERTY and promotes the three letters the accent popup can't
|
||||
* offer today straight onto the deck: ă on the home row (like Spanish's ñ) and
|
||||
* ş / ţ on the bottom row (like Italian's è / ì). â and î stay reachable from
|
||||
* the a / i accent popups. Cedilla forms (ş/ţ) match the bundled font coverage;
|
||||
* switch to comma-below (ș/ț) if the extras_* fonts ever gain U+0218-021B. */
|
||||
static const char* const kb_ro_lower[] = {
|
||||
"1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
|
||||
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "ă", LV_SYMBOL_NEW_LINE, "\n",
|
||||
"ş", "ţ", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
|
||||
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
|
||||
};
|
||||
|
||||
static const lv_btnmatrix_ctrl_t kb_ro_lower_ctrl[] = {
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
|
||||
};
|
||||
|
||||
static const char* const kb_ro_upper[] = {
|
||||
"1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
|
||||
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "Ă", LV_SYMBOL_NEW_LINE, "\n",
|
||||
"Ş", "Ţ", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
|
||||
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
|
||||
};
|
||||
|
||||
static const lv_btnmatrix_ctrl_t kb_ro_upper_ctrl[] = {
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
|
||||
};
|
||||
|
||||
struct OsKeyboardLayout {
|
||||
KeyboardLayoutId id;
|
||||
const char* name;
|
||||
@@ -416,6 +449,9 @@ static const OsKeyboardLayout k_os_layouts[] = {
|
||||
{ KeyboardLayoutId::IT, "IT",
|
||||
kb_it_lower, kb_it_lower_ctrl,
|
||||
kb_it_upper, kb_it_upper_ctrl },
|
||||
{ KeyboardLayoutId::RO, "RO",
|
||||
kb_ro_lower, kb_ro_lower_ctrl,
|
||||
kb_ro_upper, kb_ro_upper_ctrl },
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
@@ -440,6 +476,7 @@ static const HwKeyboardLayout k_hw_layouts[] = {
|
||||
{ KeyboardLayoutId::DE, "DE" },
|
||||
{ KeyboardLayoutId::ES, "ES" },
|
||||
{ KeyboardLayoutId::IT, "IT" },
|
||||
{ KeyboardLayoutId::RO, "RO" },
|
||||
};
|
||||
|
||||
/* Bulgarian phonetic mapping for T-Deck.
|
||||
@@ -671,6 +708,21 @@ static const char* hw_it_upper[26] = {
|
||||
static const char* hw_it_digits[10] = { nullptr, "à", "è", "ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
|
||||
static const char* hw_it_digits_shift[10] = { nullptr, "À", "È", "Ì", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
|
||||
|
||||
/* Romanian keeps the standard alpha matrix; the number row surfaces the five
|
||||
* special letters on compact hardware (same trick as Italian's accented vowels). */
|
||||
static const char* hw_ro_lower[26] = {
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
||||
"u", "v", "w", "x", "y", "z"
|
||||
};
|
||||
static const char* hw_ro_upper[26] = {
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
|
||||
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
|
||||
"U", "V", "W", "X", "Y", "Z"
|
||||
};
|
||||
static const char* hw_ro_digits[10] = { nullptr, "ă", "â", "î", "ş", "ţ", nullptr, nullptr, nullptr, nullptr };
|
||||
static const char* hw_ro_digits_shift[10] = { nullptr, "Ă", "Â", "Î", "Ş", "Ţ", nullptr, nullptr, nullptr, nullptr };
|
||||
|
||||
/* ================================================================
|
||||
* Runtime state
|
||||
* ================================================================ */
|
||||
@@ -755,6 +807,7 @@ static const HwPhoneticMap k_hw_maps[KEYBOARD_LAYOUT_COUNT] = {
|
||||
/* DE */ { hw_de_lower, hw_de_upper, hw_de_digits, hw_de_digits_shift },
|
||||
/* ES */ { hw_es_lower, hw_es_upper, hw_es_digits, hw_es_digits_shift },
|
||||
/* IT */ { hw_it_lower, hw_it_upper, hw_it_digits, hw_it_digits_shift },
|
||||
/* RO */ { hw_ro_lower, hw_ro_upper, hw_ro_digits, hw_ro_digits_shift },
|
||||
};
|
||||
|
||||
const char* keyboardLayoutMapHwKey(KeyboardLayoutId id, int key, bool shifted) {
|
||||
|
||||
@@ -24,6 +24,7 @@ enum class KeyboardLayoutId : uint8_t {
|
||||
DE = 9, // German (QWERTZ)
|
||||
ES = 10, // Spanish (QWERTY + accent popups)
|
||||
IT = 11, // Italian (QWERTY + accent popups)
|
||||
RO = 12, // Romanian (QWERTY, ă/ş/ţ on the deck; â/î via accent popups)
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
+537
-100
File diff suppressed because it is too large.
Load diff
@@ -459,6 +459,13 @@ public:
|
||||
bool getGpsFix();
|
||||
/** Satellites currently in view, or -1 if unknown / no GPS hardware. */
|
||||
int getGpsSats();
|
||||
/** UTC epoch the GPS itself has decoded, or 0 if it has none yet. A receiver decodes TIME from
|
||||
* the satellite stream well before it can solve a POSITION, so a valid time with no fix is
|
||||
* positive proof the module is alive and tracking, not dead. That is the distinction the GPS
|
||||
* page could not previously show, and it is what "acquiring..." was hiding. */
|
||||
uint32_t getGpsTime();
|
||||
/** Altitude in metres from the last fix (0 when there is no fix). */
|
||||
int getGpsAltitude();
|
||||
/** True once a valid fix has been seen this session. */
|
||||
bool getGpsHadFix() const { return _gps_had_fix; }
|
||||
double getNodeLat() const { return _sensors ? _sensors->node_lat : 0.0; }
|
||||
|
||||
@@ -44,11 +44,12 @@
|
||||
// file manager's SD-vs-FFat backend selection) is still hardcoded to
|
||||
// HAS_TDECK_GT911/HAS_TANMATSU specifically, never migrated to be CAP_SD-
|
||||
// generic — turning these on here just hits "SD"/"CARD_NONE"/"fmSdTryMount"
|
||||
// undeclared, not real SD support. A real mount needs pager-specific wiring
|
||||
// (CS 21, its own shared-SPI helper), which is unscheduled follow-up work,
|
||||
// not part of this milestone.
|
||||
#define CAP_SD 0
|
||||
#define CAP_FILESYSTEM 0
|
||||
// SD support shipped after this note was written: fmSdTryMount has a pager branch
|
||||
// (TFT_eSPI shared SPIClass + XL9555 card-detect) and the tile cache falls back to the
|
||||
// card under Launcher. Flags flipped for #193 so the Files app + "Store data on SD"
|
||||
// machinery light up like the T-Deck's.
|
||||
#define CAP_SD 1
|
||||
#define CAP_FILESYSTEM 1
|
||||
#define CAP_GPS 1 // u-blox MIA-M10Q
|
||||
#define CAP_OTA 1 // dual-OTA partition layout, same shape as the T-Deck
|
||||
#define CAP_LOCK_SCREEN 1
|
||||
@@ -139,6 +140,15 @@
|
||||
#define CAP_LOCK_SCREEN 0
|
||||
#endif
|
||||
|
||||
// Persisted, restart-to-apply UI-size selector. Large-screen boards already
|
||||
// expose it; the Pager adds font-only presets because its 480x222 viewport is
|
||||
// wide enough for larger type but too short for global geometry scaling.
|
||||
#if CAP_LARGE_SCREEN || defined(TLORA_PAGER)
|
||||
#define CAP_UI_SIZE 1
|
||||
#else
|
||||
#define CAP_UI_SIZE 0
|
||||
#endif
|
||||
|
||||
// ---- Derived input capabilities ---------------------------------------------
|
||||
// Physical keyboard: T-Deck matrix, Tanmatsu keypad, the pager's TCA8418, or
|
||||
// the ThinkNode M9 keyboard.
|
||||
|
||||
+730
-730
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -9,7 +9,7 @@
|
||||
// Keep this enum in sync with kUiLangNames + the table column order in i18n.cpp.
|
||||
enum UiLang : uint8_t {
|
||||
LANG_EN = 0, LANG_NL, LANG_DE, LANG_FR, LANG_ES, LANG_IT,
|
||||
LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_COUNT
|
||||
LANG_RU, LANG_UK, LANG_BG, LANG_SR, LANG_EL, LANG_PT_BR, LANG_RO, LANG_COUNT
|
||||
};
|
||||
|
||||
// Native names for the language picker (e.g. "Nederlands", "Русский", "Ελληνικά").
|
||||
|
||||
@@ -71,6 +71,6 @@ WADA_FW_DATE="$(date '+%-d %b %Y')"
|
||||
|
||||
exec idf.py -B build/tdisplay_p4 \
|
||||
-DDEVICE=tdisplay_p4 \
|
||||
-DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/tdisplay_p4;sdkconfigs/wadamesh" \
|
||||
-DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/wadamesh;sdkconfigs/tdisplay_p4" \
|
||||
-DWADA_FW_TAG="$WADA_FW_TAG" -DWADA_FW_DATE="$WADA_FW_DATE" \
|
||||
-DIDF_TARGET=esp32p4 "$@"
|
||||
@@ -25,6 +25,14 @@ idf_component_register(
|
||||
# -Wno-error does not cancel it, so strip it outright (warnings stay visible, non-fatal).
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-variable" APPEND)
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-const-variable" APPEND)
|
||||
# -O2 (the #167 underrun fix stack) enables deeper flow analysis that trips new warnings in
|
||||
# VENDORED code (esp-modbus and friends) which builds clean at -Os. Same policy as above:
|
||||
# keep the warnings visible, never fatal, for code we consume as a library.
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-truncation" APPEND)
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=stringop-overflow" APPEND)
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=array-bounds" APPEND)
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=restrict" APPEND)
|
||||
idf_build_set_property(COMPILE_OPTIONS "-Wno-error=maybe-uninitialized" APPEND)
|
||||
idf_build_get_property(_co COMPILE_OPTIONS)
|
||||
list(REMOVE_ITEM _co "-Werror=all")
|
||||
idf_build_set_property(COMPILE_OPTIONS "${_co}")
|
||||
@@ -103,6 +111,46 @@ endforeach()
|
||||
# One-off C6-reflash helper: build with `C6_FLASH_HELPER=1 ./build.sh build` to boot a P4 image that
|
||||
# never touches the C6 (no C6_EN reset pulse, no AT worker), so the C6 can be held in ROM download mode
|
||||
# and reflashed over its own USB. Harmless when the env var is unset (normal builds are unaffected).
|
||||
# #167 SD-coupling experiments: WADA_SD_KHZ=<khz> / WADA_SD_1BIT=1 derate the primary SD mount
|
||||
# (clock and/or bus width) to isolate + mitigate the SD-activity screen flash.
|
||||
if(DEFINED ENV{WADA_SD_KHZ})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_KHZ=$ENV{WADA_SD_KHZ}" APPEND)
|
||||
message(STATUS "wadamesh: SD primary rung clock = $ENV{WADA_SD_KHZ} kHz")
|
||||
endif()
|
||||
if(DEFINED ENV{WADA_SD_1BIT})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_1BIT=true" APPEND)
|
||||
message(STATUS "wadamesh: SD primary rung = 1-bit bus")
|
||||
endif()
|
||||
|
||||
# #167: WADA_DSI_MBPS=<n> overrides the RM69A10 DSI lane bit rate (default 750 -- see the
|
||||
# RM_BITRATE note in RM69A10Display.cpp; 1000 = the LilyGo/Meck value with the thin eye).
|
||||
if(DEFINED ENV{WADA_DSI_MBPS})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DRM_BITRATE_MBPS=$ENV{WADA_DSI_MBPS}" APPEND)
|
||||
message(STATUS "wadamesh: DSI lane rate = $ENV{WADA_DSI_MBPS} Mbps")
|
||||
endif()
|
||||
|
||||
# #167: WADA_SD_STORE_TEST=1 puts the store back on the SD card (writes hop to core 0).
|
||||
if(DEFINED ENV{WADA_SD_STORE_TEST})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_SD_STORE_TEST=1" APPEND)
|
||||
message(STATUS "wadamesh: SD-STORE-ON-CORE-0 experiment build")
|
||||
endif()
|
||||
|
||||
# Companion diagnostic (#167): `WADA_P4_POKE_TRACE=1 ./build.sh build` logs every runtime poke at
|
||||
# the panel or the XL9535 expander (DCS brightness writes, disp on/off, expander bit writes) so
|
||||
# visible flashes can be correlated against them on the console.
|
||||
if(DEFINED ENV{WADA_P4_POKE_TRACE})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_POKE_TRACE=1" APPEND)
|
||||
message(STATUS "wadamesh: POKE TRACE enabled (diagnostic build, do not ship)")
|
||||
endif()
|
||||
|
||||
# Diagnostic build for the whole-screen colour flash (#167): `WADA_P4_FLUSH_TRACE=1 ./build.sh build`
|
||||
# logs every large, mostly-single-colour band pushed to the panel, with its RGB565 value. If the
|
||||
# screen flashes and nothing logs, the cause is below LVGL and the UI is the wrong place to look.
|
||||
if(DEFINED ENV{WADA_P4_FLUSH_TRACE})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_FLUSH_TRACE=1" APPEND)
|
||||
message(STATUS "wadamesh: FLUSH TRACE enabled (diagnostic build, do not ship)")
|
||||
endif()
|
||||
|
||||
if(DEFINED ENV{C6_FLASH_HELPER})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-DTDP4_C6_FLASH_HELPER=1" APPEND)
|
||||
message(STATUS "wadamesh: BUILDING C6-FLASH HELPER (P4 leaves the C6 free)")
|
||||
|
||||
+180
-29
@@ -14,7 +14,12 @@
|
||||
#include "esp_heap_caps.h"
|
||||
#include "UITask.h"
|
||||
#include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init()
|
||||
#include <FFat.h> // internal 'storage' FAT partition (no-card fallback)
|
||||
#include <LittleFS.h> // internal 'storage' partition, mounted as LittleFS (label lookup is
|
||||
// subtype-agnostic, so the ex-FAT partition converts in place -- no
|
||||
// partition-table change, OTA-safe). The P4's FAT-on-flash layer has
|
||||
// documented broken metadata (exists/size/f_getfree lie) and v3 of the
|
||||
// #167 migration proved even its WRITES cannot be trusted; LittleFS is
|
||||
// the same battle-proven backend every S3 board uses internally.
|
||||
#include <SD_MMC.h> // microSD on SDMMC slot 0 (primary store)
|
||||
#include "esp_partition.h"
|
||||
#include <WiFi.h>
|
||||
@@ -72,8 +77,8 @@ volatile int g_boot_phase = 0;
|
||||
extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; }
|
||||
|
||||
// App globals — src/main.cpp declares these; we own them here (that file is excluded from the build).
|
||||
DataStore store(FFat, rtc_clock);
|
||||
bool g_fs_ok = false; // FFat('storage') mounted (extern — UITask file browser)
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
bool g_fs_ok = false; // internal 'storage' (LittleFS) mounted (extern — UITask file browser)
|
||||
bool g_sd_ok = false; // microSD mounted (extern)
|
||||
MultiTransportCompanionInterface serial_interface;
|
||||
StdRNG fast_rng;
|
||||
@@ -99,9 +104,11 @@ static void hostedConnectC6() {
|
||||
|
||||
// Recursive FS→FS copy for the one-time SD adoption migration (FFat store → SD /meshcomod).
|
||||
// Skips files that already exist at the destination, so a partial earlier run just completes.
|
||||
static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp) {
|
||||
File probe = dst.open(dp, FILE_READ);
|
||||
if (probe) { probe.close(); return; }
|
||||
static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp, bool overwrite = false) {
|
||||
if (!overwrite) {
|
||||
File probe = dst.open(dp, FILE_READ);
|
||||
if (probe) { probe.close(); return; }
|
||||
}
|
||||
File in = src.open(sp, FILE_READ);
|
||||
if (!in) return;
|
||||
File out = dst.open(dp, FILE_WRITE);
|
||||
@@ -112,7 +119,7 @@ static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const ch
|
||||
out.close(); in.close();
|
||||
printf("[storage] migrated %s\n", sp);
|
||||
}
|
||||
static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir) {
|
||||
static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir, bool overwrite = false) {
|
||||
File d = src.open(sdir);
|
||||
if (!d || !d.isDirectory()) { if (d) d.close(); return; }
|
||||
dst.mkdir(ddir);
|
||||
@@ -126,8 +133,8 @@ static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char
|
||||
snprintf(dp, sizeof dp, "%s/%s", ddir, leaf);
|
||||
bool is_dir = f.isDirectory();
|
||||
f.close();
|
||||
if (is_dir) migrateFsDir(src, sp, dst, dp);
|
||||
else migrateFsCopyFile(src, sp, dst, dp);
|
||||
if (is_dir) migrateFsDir(src, sp, dst, dp, overwrite);
|
||||
else migrateFsCopyFile(src, sp, dst, dp, overwrite);
|
||||
}
|
||||
d.close();
|
||||
}
|
||||
@@ -164,9 +171,12 @@ static void wadameshSetup() {
|
||||
DisplayDriver* disp = &display;
|
||||
|
||||
// 6. Storage — microSD (SDMMC slot 0) primary, internal FFat 'storage' as the no-card fallback.
|
||||
g_fs_ok = FFat.begin(true, "/ffat", 10, "storage");
|
||||
printf("[storage] FFat(storage) = %s\n", g_fs_ok ? "OK" : "FAILED");
|
||||
if (g_fs_ok) { FFat.mkdir("/identity"); FFat.mkdir("/bl"); }
|
||||
// formatOnFail=true: the first boot of this build finds FAT data in the partition, fails the
|
||||
// littlefs mount, and formats it — the deliberate one-way conversion (store contents come back
|
||||
// from the SD card via the v4 migration below).
|
||||
g_fs_ok = LittleFS.begin(true, "/lfs", 10, "storage");
|
||||
printf("[storage] LittleFS(storage) = %s\n", g_fs_ok ? "OK" : "FAILED");
|
||||
if (g_fs_ok) { LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl"); }
|
||||
// The slot's VDD is gated by the XL9535's SD_EN (IO15), ACTIVE-LOW — powerOnSequence() drives it
|
||||
// low. (Root-caused 2026-07-15 with a GPIO pad sweep: SD_EN high = all six SD pads clamped LOW
|
||||
// through the unpowered card's ESD diodes -> 0x107/0x109 on every mount; SD_EN low = pads high,
|
||||
@@ -180,9 +190,24 @@ static void wadameshSetup() {
|
||||
for (int p : {44, 39, 40, 41, 42}) gpio_pullup_en((gpio_num_t)p);
|
||||
// Mount ladder: 4-bit@20 MHz normally succeeds first try now that the slot is powered; the
|
||||
// fallbacks stay for marginal cards. (1-bit skips D1..D3; 5 MHz derates signal margin.)
|
||||
//
|
||||
// #167: SD activity IS the whole-screen flash. Proven by elimination on-device: identical
|
||||
// firmware with the card removed never flashes, while every reported trigger (message arrives,
|
||||
// opening an unread chat, contact add/delete, boot pre-splash) ends in an SD write burst or the
|
||||
// mount probe. The display stack itself measured clean throughout (framebuffer content, bridge
|
||||
// underrun, host protocol errors, DCS traffic, expander writes -- all silent during visible
|
||||
// flashes; DSI config + panel init identical to the flash-free Meck-P4). Remaining coupling is
|
||||
// electrical: the 4-bit 20 MHz SDMMC bus and/or the card's write-current spikes on the shared
|
||||
// rail. TDP4_SD_KHZ / TDP4_SD_1BIT (build-time) tune the first mount rung to test/derate.
|
||||
{
|
||||
#ifndef TDP4_SD_KHZ
|
||||
#define TDP4_SD_KHZ SDMMC_FREQ_DEFAULT // 20 MHz
|
||||
#endif
|
||||
#ifndef TDP4_SD_1BIT
|
||||
#define TDP4_SD_1BIT false
|
||||
#endif
|
||||
struct { bool onebit; int khz; const char *tag; } tries[] = {
|
||||
{ false, SDMMC_FREQ_DEFAULT, "4-bit 20MHz" },
|
||||
{ TDP4_SD_1BIT, TDP4_SD_KHZ, "primary (TDP4_SD_KHZ)" },
|
||||
{ false, 5000, "4-bit 5MHz" },
|
||||
{ true, SDMMC_FREQ_DEFAULT, "1-bit 20MHz" },
|
||||
{ true, 5000, "1-bit 5MHz" },
|
||||
@@ -197,20 +222,79 @@ static void wadameshSetup() {
|
||||
}
|
||||
printf("[storage] SD_MMC = %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");
|
||||
// One-time ADOPTION MIGRATION (the beta_36 lesson: never flip a storage root without migrating).
|
||||
// This device has been running with the store on FFat while the SD init was broken — if the card
|
||||
// has no identity yet but FFat does, copy the whole FFat store across BEFORE switching the root,
|
||||
// or the node would boot with a fresh identity and "lose" contacts/prefs/history.
|
||||
File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ);
|
||||
bool sd_has_id = (bool)sp; if (sp) sp.close();
|
||||
File fp = FFat.open("/identity/_main.id", FILE_READ);
|
||||
bool ff_has_id = (bool)fp; if (fp) fp.close();
|
||||
if (!sd_has_id && ff_has_id) {
|
||||
printf("[storage] first SD adoption -> migrating FFat store to SD /meshcomod\n");
|
||||
migrateFsDir(FFat, "/", SD_MMC, "/meshcomod");
|
||||
// #167 RESOLUTION -- the hot store lives on INTERNAL FFat; the SD carries only bulk, gentle
|
||||
// writers (map tiles, backups). Root cause, established by on-device elimination: SD-card
|
||||
// WRITE bursts electrically disturb the AMOLED (whole-screen blue flash). The display stack
|
||||
// measured clean throughout -- framebuffer content, DSI bridge underrun, DSI host protocol
|
||||
// errors, DCS traffic, expander writes: all silent during visible flashes -- and the flash
|
||||
// survived every digital derate (4->1-bit bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane
|
||||
// rate) on EVERY card tried, at any brightness, yet vanished the moment the card was removed
|
||||
// or left unwritten. The card's supply rail is shared with the panel (XL9535 SD_EN switches
|
||||
// it off the same 3V3 domain), so its program-current spikes reach the panel and firmware
|
||||
// cannot filter them -- it can only stop hammering the card. Streaming tile writes never
|
||||
// flashed, so the SD keeps those. This is also why the flash-free Meck build proves nothing
|
||||
// about the board: it keeps its store internal and never writes the SD at all.
|
||||
//
|
||||
// One-time REVERSE migration: earlier P4 builds kept the live store on SD /meshcomod, so if
|
||||
// the card carries an identity and this build hasn't migrated yet, copy the store back to
|
||||
// FFat, OVERWRITING FFat's stale pre-adoption copies (the SD is the current truth -- booting
|
||||
// cardless on the old FFat set showed long-deleted contacts). SD data is left in place,
|
||||
// untouched, as a rollback.
|
||||
// v4 migration -- one lesson per predecessor: v1 trusted whichever card was in and copied a
|
||||
// blank store; v2 copied the whole SD tree and filled the partition before contacts3 landed;
|
||||
// v3 did format+whitelist correctly and STILL read back empty -- because the P4's FAT-on-flash
|
||||
// driver itself is broken (its stat layer lying was already documented; v3 proved writes are
|
||||
// no better). v4 = the same clean-slate whitelist copy, onto LittleFS. Card is never written.
|
||||
File mk = LittleFS.open("/.hot_store_on_lfs_v4", FILE_READ);
|
||||
bool migrated = (bool)mk; if (mk) mk.close();
|
||||
if (!migrated) {
|
||||
File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ);
|
||||
bool sd_has_id = (bool)sp; if (sp) sp.close();
|
||||
auto fsize = [](fs::FS &f, const char *path) -> size_t {
|
||||
File h = f.open(path, FILE_READ); size_t n = h ? h.size() : 0; if (h) h.close(); return n;
|
||||
};
|
||||
size_t sd_meshcomod = fsize(SD_MMC, "/meshcomod/contacts3");
|
||||
size_t sd_root = fsize(SD_MMC, "/contacts3");
|
||||
size_t lf_contacts = fsize(LittleFS, "/contacts3");
|
||||
size_t sd_contacts = sd_meshcomod > sd_root ? sd_meshcomod : sd_root;
|
||||
if (sd_has_id && sd_contacts > lf_contacts) {
|
||||
printf("[storage] #167 v4: store SD -> LittleFS (contacts3 /meshcomod=%uB root=%uB, lfs had %uB)\n",
|
||||
(unsigned)sd_meshcomod, (unsigned)sd_root, (unsigned)lf_contacts);
|
||||
LittleFS.mkdir("/identity"); LittleFS.mkdir("/bl");
|
||||
const char *sub = sd_meshcomod >= sd_root ? "/meshcomod" : ""; // live layout root on the card
|
||||
char sp2[64];
|
||||
migrateFsDir(SD_MMC, "/meshcomod/identity", LittleFS, "/identity", true);
|
||||
migrateFsDir(SD_MMC, "/meshcomod/bl", LittleFS, "/bl", true);
|
||||
static const char *k_files[] = { "/contacts3", "/channels2", "/adv_blobs", "/new_prefs",
|
||||
"/new_prefs.tmp", "/regions2", "/ui_chat_history_v1.bin" };
|
||||
for (const char *nm : k_files) {
|
||||
snprintf(sp2, sizeof sp2, "%s%s", sub, nm);
|
||||
File probe = SD_MMC.open(sp2, FILE_READ); bool have = (bool)probe; if (probe) probe.close();
|
||||
if (!have && sub[0]) { snprintf(sp2, sizeof sp2, "%s", nm); }
|
||||
else if (!have) { snprintf(sp2, sizeof sp2, "/meshcomod%s", nm); }
|
||||
migrateFsCopyFile(SD_MMC, sp2, LittleFS, nm, true);
|
||||
}
|
||||
printf("[storage] #167 v4: done -- lfs used %u / %u KB, contacts3 readback %uB\n",
|
||||
(unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024),
|
||||
(unsigned)fsize(LittleFS, "/contacts3"));
|
||||
File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE);
|
||||
if (w) { w.write((const uint8_t*)"1", 1); w.close(); }
|
||||
} else {
|
||||
printf("[storage] #167 v4: no migration (sd_id=%d sd=%uB lfs=%uB) -- LittleFS is the store\n",
|
||||
(int)sd_has_id, (unsigned)sd_contacts, (unsigned)lf_contacts);
|
||||
File w = LittleFS.open("/.hot_store_on_lfs_v4", FILE_WRITE);
|
||||
if (w) { w.write((const uint8_t*)"1", 1); w.close(); }
|
||||
}
|
||||
}
|
||||
#if defined(TDP4_SD_STORE_TEST)
|
||||
// #167 EXPERIMENT: store back on the SD card with all hot writes hopped to core 0 (the tile
|
||||
// task's core, which has never flashed). One variable vs the known-flashing config: the
|
||||
// executing core. Uses the card's existing (slightly stale) store; migration skipped.
|
||||
printf("[storage] TDP4_SD_STORE_TEST: store on SD, writes on core 0\n");
|
||||
store.useSdMmcStorage();
|
||||
#else
|
||||
// Deliberately NOT calling store.useSdMmcStorage(): the internal store stays the DataStore root.
|
||||
#endif
|
||||
}
|
||||
store.begin();
|
||||
|
||||
@@ -237,11 +321,67 @@ static void wadameshSetup() {
|
||||
// here — the P4 has RAM to spare, and GPS detection itself needs an intact first second.
|
||||
Serial1.setRxBufferSize(4096);
|
||||
sensors.begin();
|
||||
{ // GPS detect visibility (the "gps" setting is only registered when NMEA was heard)
|
||||
bool has_gps = false;
|
||||
{ // ---- GPS reality check -------------------------------------------------------------
|
||||
// The line that used to be here reported whether the "gps" SETTING got registered, which on
|
||||
// this board is FORCED true by ENV_SKIP_GPS_DETECT (we set that because the P4's heavy boot
|
||||
// misses the core's 1 s detect window, and a false negative hides the GPS toggle entirely).
|
||||
// So it printed DETECTED unconditionally and proved nothing, including across several rounds
|
||||
// of "GPS still isn't working". Read the UART directly instead and report what arrives.
|
||||
bool has_setting = false;
|
||||
for (int i = 0; i < sensors.getNumSettings(); i++)
|
||||
if (strcmp(sensors.getSettingName(i), "gps") == 0) has_gps = true;
|
||||
printf("[GPS] L76K %s\n", has_gps ? "DETECTED (NMEA on GPIO22)" : "NOT detected (no NMEA in the 1s window)");
|
||||
if (strcmp(sensors.getSettingName(i), "gps") == 0) has_setting = true;
|
||||
|
||||
// Drain a SHORT window of NMEA. Bytes at all => pins, rails, wake and baud are ALL correct and
|
||||
// a stuck "acquiring" is a FIX problem (antenna / sky view). Zero bytes => the link is dead.
|
||||
// 1.2 s, not longer: this runs on every boot and it CONSUMES bytes the NMEA parser would
|
||||
// otherwise see, so it is deliberately just long enough to catch one of each sentence type
|
||||
// (the L76K repeats the full set about once a second).
|
||||
uint32_t bytes = 0, lines = 0;
|
||||
char first[96]; first[0] = '\0';
|
||||
char cur[96]; size_t cl = 0;
|
||||
// Keep one example of each distinct GSV/GGA/RMC talker so every constellation is represented
|
||||
// without dumping the whole stream (the same 5 sentence types repeat every second).
|
||||
char kept[10][96]; int kept_n = 0;
|
||||
const uint32_t until = millis() + 1200;
|
||||
while ((int32_t)(millis() - until) < 0) {
|
||||
while (Serial1.available() > 0) {
|
||||
const int c = Serial1.read();
|
||||
if (c < 0) break;
|
||||
++bytes;
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (cl) {
|
||||
cur[cl] = '\0';
|
||||
++lines;
|
||||
if (!first[0] && cur[0] == '$') { strncpy(first, cur, sizeof first - 1); first[sizeof first - 1] = '\0'; }
|
||||
// Keep the first of each sentence TYPE (talker + type, e.g. "$GPGSV", "$GNGGA").
|
||||
if (cur[0] == '$' && cl >= 6 && kept_n < 10) {
|
||||
bool seen = false;
|
||||
for (int k = 0; k < kept_n; ++k) if (strncmp(kept[k], cur, 6) == 0) { seen = true; break; }
|
||||
if (!seen) { strncpy(kept[kept_n], cur, sizeof kept[0] - 1); kept[kept_n][sizeof kept[0] - 1] = '\0'; ++kept_n; }
|
||||
}
|
||||
cl = 0;
|
||||
}
|
||||
} else if (cl < sizeof(cur) - 1) {
|
||||
cur[cl++] = (char)c;
|
||||
}
|
||||
}
|
||||
delay(5);
|
||||
}
|
||||
printf("[GPS] setting_registered=%d (forced by ENV_SKIP_GPS_DETECT, proves nothing)\n", (int)has_setting);
|
||||
printf("[GPS] UART probe: %lu bytes, %lu lines in 1.2s @ %d baud, module TX -> GPIO%d\n",
|
||||
(unsigned long)bytes, (unsigned long)lines, (int)GPS_BAUD_RATE, (int)PIN_GPS_TX);
|
||||
if (first[0]) printf("[GPS] first sentence: %s\n", first);
|
||||
else if (bytes) printf("[GPS] bytes but no complete '$' sentence -> wrong baud or line noise\n");
|
||||
else printf("[GPS] NOTHING on the UART -> check wake (XL9535 IO11 HIGH), rails, RX pin\n");
|
||||
// Satellite visibility across ALL constellations, plus the fix line. One GxGSV is not enough:
|
||||
// GLGSV is GLONASS only, so "00" there says nothing about GPS/Galileo/BeiDou. GSV field 3 is
|
||||
// sats-in-view for that constellation; GGA field 6 is fix quality (0 = no fix) and field 7 the
|
||||
// sats used. Non-zero in-view with quality 0 means it IS hearing satellites and just needs
|
||||
// time or better sky. All-zero in-view points at the antenna or shielding.
|
||||
if (kept_n) {
|
||||
printf("[GPS] --- satellite view (%d sentences) ---\n", kept_n);
|
||||
for (int i = 0; i < kept_n; ++i) printf("[GPS] %s\n", kept[i]);
|
||||
}
|
||||
}
|
||||
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs());
|
||||
board.onBootComplete();
|
||||
@@ -384,6 +524,17 @@ extern "C" void app_main(void) {
|
||||
}
|
||||
serial_interface.tickWebSocketHandshake();
|
||||
|
||||
// Service the sensor manager. This was MISSING on this board: sensors.begin() ran at boot and
|
||||
// then nothing ever pumped it again, while every S3 board calls this each iteration (see
|
||||
// src/main.cpp, which is excluded from this build — that is how the call got lost in the port).
|
||||
//
|
||||
// sensors.loop() is what drains the GPS UART into MicroNMEA. Without it the parser never sees a
|
||||
// byte, so isValid() stays false forever and the UI sits on "searching" no matter how good the
|
||||
// sky is — while the module itself is perfectly happy and does acquire a fix (proven with a raw
|
||||
// UART probe: a valid 3D fix at HDOP 2.2 that the firmware never knew about). It also starves
|
||||
// everything else the manager provides: telemetry sensors and the GPS->RTC time sync.
|
||||
sensors.loop();
|
||||
|
||||
the_mesh.loop();
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
@@ -47,3 +47,53 @@ CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y
|
||||
CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y
|
||||
CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y
|
||||
CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=64
|
||||
|
||||
# --- Whole-screen flicker (#167) -------------------------------------------------------------
|
||||
# Execute .text/.rodata from PSRAM instead of memory-mapped flash.
|
||||
#
|
||||
# This board's MIPI-DSI panel is a DPI/video one: its single ~1.4 MB framebuffer can only live in
|
||||
# PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. PSRAM sits behind the same cache as
|
||||
# flash, and any flash write has to DISABLE that cache while it runs -- so for the duration of the
|
||||
# write the DMA cannot fetch the framebuffer, the panel underruns, and one frame comes out as a
|
||||
# whole-screen colour flash.
|
||||
#
|
||||
# That is why the reported triggers are all storage writes and nothing to do with what is drawn:
|
||||
# every received message (history write), opening a channel with unread messages (mark-read write),
|
||||
# and deleting a contact -- which writes repeatedly and therefore flickers continuously until it
|
||||
# finishes. It also explains why it appears on every screen and has been there since the port
|
||||
# began, and why moving the flush scratch buffer out of PSRAM (7aaff80) did not help: that reduced
|
||||
# bandwidth contention, but the cache is disabled outright here, so bandwidth was never the issue.
|
||||
#
|
||||
# With XIP enabled, code and constants are relocated into PSRAM at startup so the cache no longer
|
||||
# has to be dropped for a flash write. Supported on this SoC (CONFIG_SOC_SPIRAM_XIP_SUPPORTED=y);
|
||||
# IDF ships the same pairing as a P4 reference config
|
||||
# (esp_mm/test_apps/mm/sdkconfig.ci.xip_psram_esp32p4).
|
||||
CONFIG_SPIRAM_XIP_FROM_PSRAM=y
|
||||
|
||||
# --- esp-hosted C6 reset: callback, NOT a GPIO -------------------------------------------------
|
||||
# These were only ever present in the GENERATED sdkconfig, never in this tracked fragment, so a
|
||||
# regeneration silently dropped them and esp-hosted fell back to resetting the slave via GPIO 54.
|
||||
# There is no such reset line on this board: the C6's enable/reset is C6_EN on the XL9535 expander
|
||||
# (see Xl9535::powerOnSequence), which can only be driven through a callback. Currently inert
|
||||
# because TDP4_C6_READY is 0 and the C6 runs factory ESP-AT via c6_at rather than esp-hosted, but
|
||||
# pinned here so the config is reproducible from the fragments and a future C6 bring-up does not
|
||||
# inherit a bogus GPIO reset.
|
||||
CONFIG_ESP_HOSTED_RESET_SLAVE_USING_CALLBACK=y
|
||||
CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_USING_CALLBACK=y
|
||||
|
||||
# --- #167 whole-screen blue flash: Espressif's documented DPI-underrun mitigations. -------------
|
||||
# The esp_lcd DSI driver's own ISR comment says an underrun turns the display blue, and the
|
||||
# esp-iot-solution MIPI-DSI guide prescribes exactly these three for "can't fetch data from
|
||||
# external memory fast enough": a 256 KB L2 cache with 128 B lines (fewer, larger PSRAM
|
||||
# transactions -> more headroom for the continuous ~120 MB/s DSI fetch), and perf optimization.
|
||||
# The shared 'wadamesh' fragment sets -Os for the Tanmatsu's AppFS size ceiling; this board is a
|
||||
# plain 4 MB OTA slot with ~1.1 MB free, so it can afford -O2 (build.sh orders this fragment last
|
||||
# so board overrides win). SPIRAM_SPEED_200M + XIP_FROM_PSRAM, the other two recommendations,
|
||||
# were already set.
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=n
|
||||
CONFIG_COMPILER_OPTIMIZATION_PERF=y
|
||||
CONFIG_CACHE_L2_CACHE_128KB=n
|
||||
CONFIG_CACHE_L2_CACHE_256KB=y
|
||||
CONFIG_CACHE_L2_CACHE_LINE_64B=n
|
||||
CONFIG_CACHE_L2_CACHE_LINE_128B=y
|
||||
|
||||
@@ -125,3 +125,10 @@ mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
|
||||
// Shared-SPI accessor for main.cpp's boot-time SD storage mount (#193): the micro-SD rides the
|
||||
// SAME SPIClass the display/radio already own (see the bus-sharing note at the top of this file).
|
||||
// UITask's runtime mount uses TFT_eSPI::getSPIinstance() directly; main.cpp cannot cheaply
|
||||
// include TFT_eSPI, so it links this instead.
|
||||
#include <TFT_eSPI.h>
|
||||
SPIClass* tloraPagerSharedSPI() { return &TFT_eSPI::getSPIinstance(); }
|
||||
@@ -3,12 +3,17 @@
|
||||
#include "RM69A10Display.h"
|
||||
#include <Arduino.h>
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_cache.h" // esp_cache_msync — flush the memset framebuffer out to the DSI DMA
|
||||
#include "esp_ldo_regulator.h"
|
||||
#include "Xl9535.h" // SCREEN_RST lives on the expander (reset_gpio_num = -1)
|
||||
#include "esp_lcd_mipi_dsi.h"
|
||||
#include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51)
|
||||
#include "esp_lcd_panel_ops.h"
|
||||
#include "esp_lcd_rm69a10.h"
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
#include "soc/mipi_dsi_host_struct.h" // MIPI_DSI_HOST — raw host error status (poke-trace poller)
|
||||
#include "freertos/task.h"
|
||||
#endif
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
@@ -19,6 +24,22 @@
|
||||
// the frame buffer as horizontal black/garbage bands, worst during transitions (many rapid
|
||||
// flushes). This binary semaphore makes each flush synchronous: draw_bitmap → wait for done.
|
||||
static SemaphoreHandle_t s_flush_sem = nullptr;
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
// #167 hunt, round 3: the esp_lcd driver watches ONLY the bridge-FIFO underrun (proven silent
|
||||
// during visible flashes). The DSI HOST has its own never-read error latches: int_st0 = the
|
||||
// panel acking packets WITH ERRORS, int_st1 = ECC/CRC/timeout/payload-write errors INCLUDING
|
||||
// dpi_buff_pld_under (the host payload buffer underflowing -- a different FIFO than the bridge).
|
||||
// Both are read-clear, so a 25 ms poll gives per-event capture. If these latch when the screen
|
||||
// flashes, we have a hardware flash-detector and can bisect subsystems without eyes on the panel.
|
||||
static void dsiErrPollTask(void*) {
|
||||
for (;;) {
|
||||
const uint32_t st0 = MIPI_DSI_HOST.int_st0.val; // RC
|
||||
const uint32_t st1 = MIPI_DSI_HOST.int_st1.val; // RC
|
||||
if (st0 || st1) printf("[DSIERR] %lu st0=%08X st1=%08X\n", (unsigned long)millis(), (unsigned)st0, (unsigned)st1);
|
||||
vTaskDelay(pdMS_TO_TICKS(25));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel_event_data_t*, void*) {
|
||||
BaseType_t hpw = pdFALSE;
|
||||
if (s_flush_sem) xSemaphoreGiveFromISR(s_flush_sem, &hpw);
|
||||
@@ -37,7 +58,19 @@ static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel
|
||||
#endif
|
||||
#define RM_DPI_MHZ 60
|
||||
#define RM_LANES 2
|
||||
#define RM_BITRATE 1000 // Mbps per lane — TODO(device): confirm vs LilyGo RM69A10 define
|
||||
// Lane bit rate. LilyGo/Meck ship 1000 Mbps (confirmed against both sources 2026-07-31), and the
|
||||
// panel needs only ~550 Mbps of it at DPI 60 MHz / RGB565 / 2 lanes -- the rest is eye margin.
|
||||
// #167 made that margin matter: SD-card write bursts load the P4's on-chip LDO4 and disturb the
|
||||
// sibling LDO3 that powers the DSI PHY, and at a 1 ns unit interval that wobble was enough to
|
||||
// glitch the link -- the panel loses a frame to a whole-screen blue flash (proven by elimination:
|
||||
// no flash with the card out or unwritten; framebuffer/underrun/host-error/DCS all measured clean;
|
||||
// identical timings+init to the flash-free Meck build). A 750 Mbps derate was tried against #167 and changed nothing
|
||||
// (the disturbance reaches the panel around the link, not through it), so this stays at the
|
||||
// vendor value. Overridable per build: -DRM_BITRATE_MBPS=<n>.
|
||||
#ifndef RM_BITRATE_MBPS
|
||||
#define RM_BITRATE_MBPS 1000
|
||||
#endif
|
||||
#define RM_BITRATE RM_BITRATE_MBPS
|
||||
#define RM_HSYNC 50
|
||||
#define RM_HBP 150
|
||||
#define RM_HFP 50
|
||||
@@ -109,6 +142,19 @@ bool RM69A10Display::begin() {
|
||||
|
||||
esp_lcd_panel_reset(_panel);
|
||||
if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[RM69A10] panel_init fail"); return false; }
|
||||
|
||||
// Blank the framebuffer BEFORE switching the panel on. It is freshly allocated PSRAM, so it holds
|
||||
// garbage; turning the panel on first meant the DSI streamed that garbage to the screen until the
|
||||
// clear below finished -- the colour flash testers see before the boot splash (#167). Write the
|
||||
// buffer directly (memset + a cache flush to make it visible to the DSI's DMA) instead of pushing
|
||||
// strips through draw_bitmap: it is one pass with the panel still dark, and it avoids a burst of
|
||||
// 39 back-to-back DMA transfers into a buffer the panel is scanning out.
|
||||
void* fb = nullptr;
|
||||
if (esp_lcd_dpi_panel_get_frame_buffer(_panel, 1, &fb) == ESP_OK && fb) {
|
||||
const size_t fb_bytes = (size_t)RM_W * RM_H * 2;
|
||||
memset(fb, 0, fb_bytes);
|
||||
esp_cache_msync(fb, fb_bytes, ESP_CACHE_MSYNC_FLAG_DIR_C2M);
|
||||
}
|
||||
esp_lcd_panel_disp_on_off(_panel, true);
|
||||
|
||||
// Brightness: the init array ships 0x51=0 (placeholder), and RM69A10 brightness is driven at
|
||||
@@ -125,20 +171,9 @@ bool RM69A10Display::begin() {
|
||||
cbs.on_color_trans_done = rm69a10TransDone;
|
||||
esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr);
|
||||
|
||||
// Clear the panel framebuffer to black once, so nothing stale shows before LVGL's first flush.
|
||||
{
|
||||
const int SH = 32;
|
||||
uint16_t* strip = (uint16_t*)heap_caps_calloc((size_t)RM_W * SH, 2, MALLOC_CAP_SPIRAM);
|
||||
if (strip) {
|
||||
for (int y0 = 0; y0 < RM_H; y0 += SH) {
|
||||
int hh = (y0 + SH <= RM_H) ? SH : (RM_H - y0);
|
||||
esp_lcd_panel_draw_bitmap(_panel, 0, y0, RM_W, y0 + hh, strip);
|
||||
if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // let the copy finish before reusing/freeing strip
|
||||
}
|
||||
free(strip);
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
xTaskCreatePinnedToCore(dsiErrPollTask, "dsierr", 3072, nullptr, 3, nullptr, 1);
|
||||
#endif
|
||||
Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H);
|
||||
return true;
|
||||
}
|
||||
@@ -221,10 +256,20 @@ void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_
|
||||
|
||||
void RM69A10Display::setBrightness(uint8_t b) {
|
||||
if (!_dbi_io) return;
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
// #167 hunt: DCS 0x51 is a low-power command injected into the LIVE video stream -- if the
|
||||
// panel glitches a frame per command, these lines will pace the visible flashes exactly.
|
||||
printf("[BRI] %lu val=%u\n", (unsigned long)millis(), b);
|
||||
#endif
|
||||
esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
|
||||
}
|
||||
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
void RM69A10Display::turnOn() { printf("[DSP] %lu on\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
|
||||
void RM69A10Display::turnOff() { printf("[DSP] %lu off\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
|
||||
#else
|
||||
void RM69A10Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
|
||||
void RM69A10Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
|
||||
#endif
|
||||
|
||||
#endif // HAS_TDISPLAY_P4
|
||||
@@ -51,6 +51,11 @@ void Xl9535::write(uint8_t io, bool high) {
|
||||
uint8_t port, bit; split(io, port, bit);
|
||||
if (high) _out[port] |= (1 << bit);
|
||||
else _out[port] &= ~(1 << bit);
|
||||
#if defined(TDP4_POKE_TRACE)
|
||||
// #167 hunt: any expander write while the panel streams is a glitch suspect (this is an
|
||||
// UNLOCKED shadow RMW -- a cross-task race can transiently drop SCREEN_RST or a rail bit).
|
||||
printf("[XLW] %lu io=%u v=%d out=%02X%02X\n", (unsigned long)millis(), io, (int)high, _out[1], _out[0]);
|
||||
#endif
|
||||
writeReg(REG_OUT0 + port, _out[port]);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user